From 4f13d2e44003d83756cc4113604e3b4de494e640 Mon Sep 17 00:00:00 2001 From: johngreen Date: Thu, 14 May 2026 17:25:31 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EB=B6=80=EC=84=9C=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20+=20=EC=9A=B4=EC=98=81=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B2=84=EA=B7=B8=208=EA=B1=B4=20(PR=20#1?= =?UTF-8?q?8/#19=20=ED=9B=84=EC=86=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security: syncManagers 가 user_id 의 회사 격리·실존 검증 (cross-tenant injection 차단) - bug: base_date 필터에 START_DATE IS NULL 조건 추가 — 옛날 데이터가 기준일 켜자마자 사라지는 문제 해결 - bug: PUT partial update — 매니저 키 없으면 sync skip (단일 필드 수정 시 매니저 보존) - bug: 페이지 로드 시 단일 컬럼 backfill — PR #19 이전 데이터가 chip UI 에서 사라지는 문제 해결 - validation: Controller base_date YYYY-MM-DD regex (잘못된 형식 시 400) - validation: 프론트 handleSave start_date > end_date 체크 - robustness: parseManagersJson 64KB max + log.warn (catch silent swallow 제거) - ops: StartupSchemaMigrator 실패 테넌트 DB 명단 부팅 종료 시 log.error 집계 --- .../erp/controller/DepartmentController.java | 7 +++ .../erp/migration/StartupSchemaMigrator.java | 11 ++++- .../com/erp/service/DepartmentService.java | 44 +++++++++++++++---- .../src/main/resources/mapper/department.xml | 10 ++++- .../(main)/admin/userMng/deptMngList/page.tsx | 21 ++++++++- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index 44bb42ff..7355b011 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -18,6 +18,9 @@ public class DepartmentController { private final DepartmentService departmentService; + private static final java.util.regex.Pattern ISO_DATE_PATTERN = + java.util.regex.Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + /** * 부서 목록 조회 (회사별). * 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함. @@ -35,6 +38,10 @@ public class DepartmentController { return ResponseEntity.status(403) .body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다.")); } + if (baseDate != null && !baseDate.isBlank() && !ISO_DATE_PATTERN.matcher(baseDate).matches()) { + return ResponseEntity.status(400) + .body(ApiResponse.error("base_date 는 YYYY-MM-DD 형식이어야 합니다.")); + } List> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate); return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공")); diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index 125f92ec..4f0c05bf 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -226,9 +226,18 @@ public class StartupSchemaMigrator { } int ok = 0, fail = 0; + List failedDbs = new java.util.ArrayList<>(); for (String db : tenantDbs) { if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue; - if (applyTo(db, "tenant")) ok++; else fail++; + if (applyTo(db, "tenant")) { + ok++; + } else { + fail++; + failedDbs.add(db); + } + } + if (!failedDbs.isEmpty()) { + log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs); } log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail); } diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index 36019109..46719035 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -150,9 +150,9 @@ public class DepartmentService extends BaseService { insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location"))); sqlSession.insert("department.insertDepartment", insertParams); - syncManagers(deptCode, body, "approval"); - syncManagers(deptCode, body, "dept"); - syncManagers(deptCode, body, "org_leader"); + syncManagers(deptCode, companyCode, body, "approval"); + syncManagers(deptCode, companyCode, body, "dept"); + syncManagers(deptCode, companyCode, body, "org_leader"); log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName); @@ -221,9 +221,9 @@ public class DepartmentService extends BaseService { return null; } - syncManagers(deptCode, body, "approval"); - syncManagers(deptCode, body, "dept"); - syncManagers(deptCode, body, "org_leader"); + syncManagers(deptCode, deptCompanyCode, body, "approval"); + syncManagers(deptCode, deptCompanyCode, body, "dept"); + syncManagers(deptCode, deptCompanyCode, body, "org_leader"); log.info("부서 수정 성공: deptCode={}", deptCode); return getDepartment(deptCode); @@ -504,18 +504,29 @@ public class DepartmentService extends BaseService { private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER = new com.fasterxml.jackson.databind.ObjectMapper(); + private static final int MAX_MANAGERS_JSON_BYTES = 64 * 1024; + private void parseManagersJson(Map dept, String key) { Object raw = dept.get(key); if (raw == null) { dept.put(key, new java.util.ArrayList>()); return; } + String s = raw.toString(); + if (s.length() > MAX_MANAGERS_JSON_BYTES) { + log.warn("parseManagersJson 크기 초과 dept_code={} key={} len={}", + dept.get("dept_code"), key, s.length()); + dept.put(key, new java.util.ArrayList>()); + return; + } try { @SuppressWarnings("unchecked") - java.util.List> parsed = JSON_MAPPER.readValue(raw.toString(), + java.util.List> parsed = JSON_MAPPER.readValue(s, new com.fasterxml.jackson.core.type.TypeReference>>() {}); dept.put(key, parsed); } catch (Exception e) { + log.warn("parseManagersJson 실패 dept_code={} key={} err={}", + dept.get("dept_code"), key, e.getMessage()); dept.put(key, new java.util.ArrayList>()); } } @@ -526,13 +537,18 @@ public class DepartmentService extends BaseService { * 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출. * 최대 10명 검증 + 빈 user_id 무시. */ - private void syncManagers(String deptCode, Map body, String role) { + private void syncManagers(String deptCode, String companyCode, Map body, String role) { String bodyKey = switch (role) { case "approval" -> "approval_managers"; case "dept" -> "dept_managers"; case "org_leader" -> "org_leaders"; default -> throw new IllegalArgumentException("Unknown role: " + role); }; + // PUT partial update: 키가 명시적으로 존재할 때만 sync. + // body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도). + if (!body.containsKey(bodyKey)) { + return; + } Object raw = body.get(bodyKey); java.util.List userIds = new java.util.ArrayList<>(); if (raw instanceof java.util.List list) { @@ -558,6 +574,18 @@ public class DepartmentService extends BaseService { }; throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다."); } + // user_id 가 같은 회사 (or '*') 에 실존하는지 검증 — cross-tenant 차단 + if (!userIds.isEmpty()) { + Map vParams = new HashMap<>(); + vParams.put("user_ids", userIds); + vParams.put("company_code", companyCode); + List validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams); + if (validUserIds == null || validUserIds.size() != userIds.size()) { + Set invalid = new HashSet<>(userIds); + if (validUserIds != null) invalid.removeAll(validUserIds); + throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid); + } + } // delete-all Map delParams = new HashMap<>(); delParams.put("dept_code", deptCode); diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index f5168e25..054a75d5 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -37,7 +37,7 @@ AND D.DELETED_AT IS NULL - AND D.START_DATE <= #{base_date}::date + AND (D.START_DATE IS NULL OR D.START_DATE <= #{base_date}::date) AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date) GROUP BY @@ -339,4 +339,12 @@ + + + diff --git a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx index f92deae9..4b2c4c4c 100644 --- a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx +++ b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx @@ -313,8 +313,17 @@ export default function DeptMngListPage() { end_date: (dept.end_date ?? "").slice(0, 10), sort_order: dept.sort_order ?? 10, status: (dept.status as "active" | "inactive") ?? "active", - approval_managers: ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean), - dept_managers: ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean), + approval_managers: (() => { + const arr = ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean); + // 신규 매핑이 비어있고 옛날 단일 컬럼에 값 있으면 seed (PR #19 이전 데이터 호환) + if (arr.length === 0 && dept.approval_manager) return [dept.approval_manager]; + return arr; + })(), + dept_managers: (() => { + const arr = ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean); + if (arr.length === 0 && dept.dept_manager) return [dept.dept_manager]; + return arr; + })(), org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean), }; setDraft(loaded); @@ -489,6 +498,14 @@ export default function DeptMngListPage() { toast({ title: "회사를 선택해주세요", variant: "destructive" }); return; } + // 시작일/종료일 정합성 검증 + if (draft.start_date && draft.end_date && draft.start_date > draft.end_date) { + toast({ + title: "시작일은 종료일보다 빠르거나 같아야 합니다", + variant: "destructive", + }); + return; + } // 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후) const payload = {