feat: Implement user ID duplication check in user registration and update processes
- Added functionality to check for existing user IDs during new user registration and updates to prevent overwriting accounts from different companies. - Enhanced error handling to return appropriate messages when a duplicate user ID is detected. - Updated the frontend to include user ID duplication verification, ensuring a smoother user experience during user creation and editing. - These changes aim to improve data integrity and user management across multiple company implementations.
This commit is contained in:
@@ -2744,6 +2744,30 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔒 신규 등록(POST) 시 user_id 중복 체크 (타 회사 계정 덮어쓰기 방지)
|
||||
if (!isUpdate) {
|
||||
const existingById = await queryOne<{ user_id: string; company_code: string; user_name: string }>(
|
||||
`SELECT user_id, company_code, user_name FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
if (existingById) {
|
||||
logger.warn(`신규 사용자 등록 차단 - 이미 존재하는 user_id`, {
|
||||
userId: userData.userId,
|
||||
existingCompany: existingById.company_code,
|
||||
requestCompany: userData.companyCode,
|
||||
});
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: `이미 사용 중인 사용자 아이디입니다: ${userData.userId}`,
|
||||
error: {
|
||||
code: "DUPLICATE_USER_ID",
|
||||
details: `user_id '${userData.userId}' already exists (company: ${existingById.company_code})`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||
let encryptedPassword = null;
|
||||
if (userData.userPassword) {
|
||||
@@ -4161,11 +4185,31 @@ export const saveUserWithDept = async (
|
||||
|
||||
// 1. 기존 사용자 확인
|
||||
const existingUser = await client.query(
|
||||
"SELECT user_id FROM user_info WHERE user_id = $1",
|
||||
"SELECT user_id, company_code FROM user_info WHERE user_id = $1",
|
||||
[userInfo.user_id]
|
||||
);
|
||||
const isExistingUser = existingUser.rows.length > 0;
|
||||
|
||||
// 🔒 신규 등록(isUpdate=false) 요청인데 이미 존재하면 중복 차단 (타 회사 계정 덮어쓰기 방지)
|
||||
if (!isUpdate && isExistingUser) {
|
||||
await client.query("ROLLBACK");
|
||||
const existingCompany = existingUser.rows[0]?.company_code;
|
||||
logger.warn(`신규 사용자(+부서) 등록 차단 - 이미 존재하는 user_id`, {
|
||||
userId: userInfo.user_id,
|
||||
existingCompany,
|
||||
requestCompany: companyCode,
|
||||
});
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: `이미 사용 중인 사용자 아이디입니다: ${userInfo.user_id}`,
|
||||
error: {
|
||||
code: "DUPLICATE_USER_ID",
|
||||
details: `user_id '${userInfo.user_id}' already exists (company: ${existingCompany})`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
|
||||
let encryptedPassword = null;
|
||||
if (userInfo.user_password) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function createPkgUnit(
|
||||
const {
|
||||
pkg_code, pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks, item_number,
|
||||
} = req.body;
|
||||
|
||||
if (!pkg_code || !pkg_name) {
|
||||
@@ -64,12 +64,12 @@ export async function createPkgUnit(
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO pkg_unit
|
||||
(company_code, pkg_code, pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
(id, company_code, pkg_code, pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, item_number, writer, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, NOW())
|
||||
RETURNING *`,
|
||||
[companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE",
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, item_number || pkg_code,
|
||||
req.user!.userId]
|
||||
);
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function updatePkgUnit(
|
||||
const {
|
||||
pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks, item_number,
|
||||
} = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -100,12 +100,14 @@ export async function updatePkgUnit(
|
||||
pkg_name=$1, pkg_type=$2, status=$3,
|
||||
width_mm=$4, length_mm=$5, height_mm=$6,
|
||||
self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10,
|
||||
updated_date=NOW(), writer=$11
|
||||
WHERE id=$12 AND company_code=$13
|
||||
item_number=COALESCE($11, item_number),
|
||||
updated_date=NOW(), writer=$12
|
||||
WHERE id=$13 AND company_code=$14
|
||||
RETURNING *`,
|
||||
[pkg_name, pkg_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, volume_l, remarks,
|
||||
item_number,
|
||||
req.user!.userId, id, companyCode]
|
||||
);
|
||||
|
||||
@@ -286,7 +288,7 @@ export async function createLoadingUnit(
|
||||
const {
|
||||
loading_code, loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks, item_number,
|
||||
} = req.body;
|
||||
|
||||
if (!loading_code || !loading_name) {
|
||||
@@ -305,12 +307,12 @@ export async function createLoadingUnit(
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO loading_unit
|
||||
(company_code, loading_code, loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
(id, company_code, loading_code, loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, item_number, writer, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, NOW())
|
||||
RETURNING *`,
|
||||
[companyCode, loading_code, loading_name, loading_type, status || "ACTIVE",
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, item_number || loading_code,
|
||||
req.user!.userId]
|
||||
);
|
||||
|
||||
@@ -333,7 +335,7 @@ export async function updateLoadingUnit(
|
||||
const {
|
||||
loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks, item_number,
|
||||
} = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -341,12 +343,14 @@ export async function updateLoadingUnit(
|
||||
loading_name=$1, loading_type=$2, status=$3,
|
||||
width_mm=$4, length_mm=$5, height_mm=$6,
|
||||
self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10,
|
||||
updated_date=NOW(), writer=$11
|
||||
WHERE id=$12 AND company_code=$13
|
||||
item_number=COALESCE($11, item_number),
|
||||
updated_date=NOW(), writer=$12
|
||||
WHERE id=$13 AND company_code=$14
|
||||
RETURNING *`,
|
||||
[loading_name, loading_type, status,
|
||||
width_mm, length_mm, height_mm,
|
||||
self_weight_kg, max_load_kg, max_stack, remarks,
|
||||
item_number,
|
||||
req.user!.userId, id, companyCode]
|
||||
);
|
||||
|
||||
|
||||
@@ -949,14 +949,25 @@ export async function addTableData(
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
|
||||
// 🆕 멀티테넌시: company_code 강제 설정 (테이블에 company_code 컬럼이 있는 경우)
|
||||
// 일반 관리자는 자신의 company_code로 강제, 슈퍼관리자(*)는 클라이언트 값 허용(프리뷰용)
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (companyCode && !data.company_code) {
|
||||
// 테이블에 company_code 컬럼이 있는지 확인
|
||||
if (companyCode) {
|
||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||
if (hasCompanyCodeColumn) {
|
||||
data.company_code = companyCode;
|
||||
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 클라이언트가 보낸 company_code 허용, 없으면 '*'
|
||||
if (!data.company_code) {
|
||||
data.company_code = companyCode;
|
||||
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||
}
|
||||
} else {
|
||||
// 일반 관리자: 항상 자신의 company_code로 강제 (타 회사 주입 방지)
|
||||
if (data.company_code && data.company_code !== companyCode) {
|
||||
logger.warn(`멀티테넌시: 타 회사 company_code 주입 시도 차단 - 요청값: ${data.company_code}, 강제값: ${companyCode}`);
|
||||
}
|
||||
data.company_code = companyCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1126,37 @@ export async function editTableData(
|
||||
const tableManagementService = new TableManagementService();
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 🔒 멀티테넌시 보안: 일반 관리자는 타 회사 데이터 수정 불가 + company_code 변경 차단
|
||||
if (companyCode !== "*") {
|
||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||
if (hasCompanyCodeColumn) {
|
||||
// 1. 원본 데이터의 company_code 확인
|
||||
if (originalData?.id) {
|
||||
try {
|
||||
const existing = await tableManagementService.getTableData(tableName, {
|
||||
page: 1, size: 1,
|
||||
search: { id: String(originalData.id) },
|
||||
});
|
||||
const existingRow = existing.data?.[0];
|
||||
if (existingRow && existingRow.company_code && existingRow.company_code !== companyCode && existingRow.company_code !== "*") {
|
||||
logger.warn(`🔒 타 회사 데이터 수정 차단: ${tableName} id=${originalData.id}, 요청자=${companyCode}, 데이터소속=${existingRow.company_code}`);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "해당 데이터를 수정할 권한이 없습니다.",
|
||||
error: { code: "FORBIDDEN_COMPANY", details: "cross-company edit blocked" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch { /* skip 검증 실패 시 원래 흐름 진행 */ }
|
||||
}
|
||||
// 2. updatedData에 company_code 변경 시도가 있으면 제거
|
||||
if (updatedData.company_code && updatedData.company_code !== companyCode) {
|
||||
logger.warn(`🔒 company_code 변경 시도 차단: ${tableName}, 요청값=${updatedData.company_code}`);
|
||||
delete updatedData.company_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
|
||||
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||
tableName,
|
||||
|
||||
@@ -502,7 +502,8 @@ class NumberingRuleService {
|
||||
|
||||
let baseSequence = currentCounter;
|
||||
|
||||
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회
|
||||
// 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX만 사용 (순수 MAX+1 방식)
|
||||
// - 삭제된 번호 재사용 가능 (카운터 값 무시)
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
|
||||
@@ -515,12 +516,10 @@ class NumberingRuleService {
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
if (maxFromTable > baseSequence) {
|
||||
logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", {
|
||||
ruleId, companyCode, currentCounter, maxFromTable,
|
||||
});
|
||||
baseSequence = maxFromTable;
|
||||
}
|
||||
logger.info("테이블 MAX 기준 채번", {
|
||||
ruleId, companyCode, currentCounter, maxFromTable,
|
||||
});
|
||||
baseSequence = maxFromTable;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
@@ -1422,7 +1421,7 @@ class NumberingRuleService {
|
||||
? 0
|
||||
: await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey);
|
||||
|
||||
// 대상 테이블에서 실제 최대 시퀀스 조회
|
||||
// 대상 테이블에서 실제 최대 시퀀스만 사용 (순수 MAX+1)
|
||||
let baseSeq = currentSeq;
|
||||
if (rule.tableName && rule.columnName) {
|
||||
try {
|
||||
@@ -1436,13 +1435,10 @@ class NumberingRuleService {
|
||||
psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode
|
||||
);
|
||||
|
||||
|
||||
if (maxFromTable > baseSeq) {
|
||||
logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", {
|
||||
ruleId, companyCode, currentSeq, maxFromTable,
|
||||
});
|
||||
baseSeq = maxFromTable;
|
||||
}
|
||||
logger.info("미리보기: 테이블 MAX 기준 채번", {
|
||||
ruleId, companyCode, currentSeq, maxFromTable,
|
||||
});
|
||||
baseSeq = maxFromTable;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", {
|
||||
|
||||
Reference in New Issue
Block a user