Merge pull request 'fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)' (#20) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 10m45s

This commit was merged in pull request #20.
This commit is contained in:
2026-05-14 08:26:16 +00:00
5 changed files with 81 additions and 12 deletions
@@ -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<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
@@ -226,9 +226,18 @@ public class StartupSchemaMigrator {
}
int ok = 0, fail = 0;
List<String> 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);
}
@@ -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<String, Object> dept, String key) {
Object raw = dept.get(key);
if (raw == null) {
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
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<Map<String, Object>>());
return;
}
try {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(raw.toString(),
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(s,
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
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<Map<String, Object>>());
}
}
@@ -526,13 +537,18 @@ public class DepartmentService extends BaseService {
* 각 값은 List&lt;Map&gt; 형태이며 각 element 에서 "user_id" 만 추출.
* 최대 10명 검증 + 빈 user_id 무시.
*/
private void syncManagers(String deptCode, Map<String, Object> body, String role) {
private void syncManagers(String deptCode, String companyCode, Map<String, Object> 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<String> 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<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", userIds);
vParams.put("company_code", companyCode);
List<String> validUserIds = sqlSession.selectList("department.selectValidUserIds", vParams);
if (validUserIds == null || validUserIds.size() != userIds.size()) {
Set<String> invalid = new HashSet<>(userIds);
if (validUserIds != null) invalid.removeAll(validUserIds);
throw new IllegalArgumentException("유효하지 않은 사용자 ID: " + invalid);
}
}
// delete-all
Map<String, Object> delParams = new HashMap<>();
delParams.put("dept_code", deptCode);
@@ -37,7 +37,7 @@
AND D.DELETED_AT IS NULL
</if>
<if test="base_date != null and base_date != ''">
AND D.START_DATE &lt;= #{base_date}::date
AND (D.START_DATE IS NULL OR D.START_DATE &lt;= #{base_date}::date)
AND (D.END_DATE IS NULL OR D.END_DATE &gt;= #{base_date}::date)
</if>
GROUP BY
@@ -339,4 +339,12 @@
</foreach>
</insert>
<!-- 사용자 ID 들이 같은 회사(또는 글로벌 *) 에 실존하는지 검증 — cross-tenant injection 방지 -->
<select id="selectValidUserIds" parameterType="map" resultType="string">
SELECT USER_ID FROM USER_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND USER_ID IN
<foreach collection="user_ids" item="u" open="(" separator="," close=")">#{u}</foreach>
</select>
</mapper>
@@ -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 = {