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:
kjs
2026-04-13 18:20:24 +09:00
parent 21b4459757
commit 2c75677394
32 changed files with 1104 additions and 217 deletions

View File

@@ -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) {

View File

@@ -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]
);

View File

@@ -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,

View File

@@ -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 조회 실패, 카운터 기반 폴백", {