Merge pull request 'fix(부서관리): 보안 + 운영 데이터 버그 8건 (PR #18/#19 후속)' (#20) from johngreen into main
Build & Deploy to K8s / build-and-deploy (push) Failing after 10m45s
Build & Deploy to K8s / build-and-deploy (push) Failing after 10m45s
This commit was merged in pull request #20.
This commit is contained in:
@@ -18,6 +18,9 @@ public class DepartmentController {
|
|||||||
|
|
||||||
private final DepartmentService departmentService;
|
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 된 부서도 포함.
|
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
|
||||||
@@ -35,6 +38,10 @@ public class DepartmentController {
|
|||||||
return ResponseEntity.status(403)
|
return ResponseEntity.status(403)
|
||||||
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
|
.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);
|
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted, baseDate);
|
||||||
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
|
||||||
|
|||||||
@@ -226,9 +226,18 @@ public class StartupSchemaMigrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int ok = 0, fail = 0;
|
int ok = 0, fail = 0;
|
||||||
|
List<String> failedDbs = new java.util.ArrayList<>();
|
||||||
for (String db : tenantDbs) {
|
for (String db : tenantDbs) {
|
||||||
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
|
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);
|
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")));
|
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
|
||||||
sqlSession.insert("department.insertDepartment", insertParams);
|
sqlSession.insert("department.insertDepartment", insertParams);
|
||||||
|
|
||||||
syncManagers(deptCode, body, "approval");
|
syncManagers(deptCode, companyCode, body, "approval");
|
||||||
syncManagers(deptCode, body, "dept");
|
syncManagers(deptCode, companyCode, body, "dept");
|
||||||
syncManagers(deptCode, body, "org_leader");
|
syncManagers(deptCode, companyCode, body, "org_leader");
|
||||||
|
|
||||||
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
|
||||||
|
|
||||||
@@ -221,9 +221,9 @@ public class DepartmentService extends BaseService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
syncManagers(deptCode, body, "approval");
|
syncManagers(deptCode, deptCompanyCode, body, "approval");
|
||||||
syncManagers(deptCode, body, "dept");
|
syncManagers(deptCode, deptCompanyCode, body, "dept");
|
||||||
syncManagers(deptCode, body, "org_leader");
|
syncManagers(deptCode, deptCompanyCode, body, "org_leader");
|
||||||
|
|
||||||
log.info("부서 수정 성공: deptCode={}", deptCode);
|
log.info("부서 수정 성공: deptCode={}", deptCode);
|
||||||
return getDepartment(deptCode);
|
return getDepartment(deptCode);
|
||||||
@@ -504,18 +504,29 @@ public class DepartmentService extends BaseService {
|
|||||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
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) {
|
private void parseManagersJson(Map<String, Object> dept, String key) {
|
||||||
Object raw = dept.get(key);
|
Object raw = dept.get(key);
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||||
return;
|
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 {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@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>>>() {});
|
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
|
||||||
dept.put(key, parsed);
|
dept.put(key, parsed);
|
||||||
} catch (Exception e) {
|
} 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>>());
|
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,13 +537,18 @@ public class DepartmentService extends BaseService {
|
|||||||
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
* 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출.
|
||||||
* 최대 10명 검증 + 빈 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) {
|
String bodyKey = switch (role) {
|
||||||
case "approval" -> "approval_managers";
|
case "approval" -> "approval_managers";
|
||||||
case "dept" -> "dept_managers";
|
case "dept" -> "dept_managers";
|
||||||
case "org_leader" -> "org_leaders";
|
case "org_leader" -> "org_leaders";
|
||||||
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
default -> throw new IllegalArgumentException("Unknown role: " + role);
|
||||||
};
|
};
|
||||||
|
// PUT partial update: 키가 명시적으로 존재할 때만 sync.
|
||||||
|
// body 에 키 자체가 없으면 기존 매핑 보존 (partial update 의도).
|
||||||
|
if (!body.containsKey(bodyKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Object raw = body.get(bodyKey);
|
Object raw = body.get(bodyKey);
|
||||||
java.util.List<String> userIds = new java.util.ArrayList<>();
|
java.util.List<String> userIds = new java.util.ArrayList<>();
|
||||||
if (raw instanceof java.util.List<?> list) {
|
if (raw instanceof java.util.List<?> list) {
|
||||||
@@ -558,6 +574,18 @@ public class DepartmentService extends BaseService {
|
|||||||
};
|
};
|
||||||
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
|
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
|
// delete-all
|
||||||
Map<String, Object> delParams = new HashMap<>();
|
Map<String, Object> delParams = new HashMap<>();
|
||||||
delParams.put("dept_code", deptCode);
|
delParams.put("dept_code", deptCode);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
AND D.DELETED_AT IS NULL
|
AND D.DELETED_AT IS NULL
|
||||||
</if>
|
</if>
|
||||||
<if test="base_date != null and base_date != ''">
|
<if test="base_date != null and base_date != ''">
|
||||||
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)
|
AND (D.END_DATE IS NULL OR D.END_DATE >= #{base_date}::date)
|
||||||
</if>
|
</if>
|
||||||
GROUP BY
|
GROUP BY
|
||||||
@@ -339,4 +339,12 @@
|
|||||||
</foreach>
|
</foreach>
|
||||||
</insert>
|
</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>
|
</mapper>
|
||||||
|
|||||||
@@ -313,8 +313,17 @@ export default function DeptMngListPage() {
|
|||||||
end_date: (dept.end_date ?? "").slice(0, 10),
|
end_date: (dept.end_date ?? "").slice(0, 10),
|
||||||
sort_order: dept.sort_order ?? 10,
|
sort_order: dept.sort_order ?? 10,
|
||||||
status: (dept.status as "active" | "inactive") ?? "active",
|
status: (dept.status as "active" | "inactive") ?? "active",
|
||||||
approval_managers: ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean),
|
approval_managers: (() => {
|
||||||
dept_managers: ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean),
|
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),
|
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
|
||||||
};
|
};
|
||||||
setDraft(loaded);
|
setDraft(loaded);
|
||||||
@@ -489,6 +498,14 @@ export default function DeptMngListPage() {
|
|||||||
toast({ title: "회사를 선택해주세요", variant: "destructive" });
|
toast({ title: "회사를 선택해주세요", variant: "destructive" });
|
||||||
return;
|
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 정리 후)
|
// 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후)
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user