fix(�μ�����): 25�� ���� �ϰ� ���� + ������ ���Ἲ ��ȭ (#4)
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m31s

johngreen �귣ġ�� �μ����� ���� + �м� ��Ʈ�� main ���� ����. �ڵ� ���� Ʈ����.
This commit was merged in pull request #4.
This commit is contained in:
2026-05-08 09:48:19 +00:00
12 changed files with 1833 additions and 97 deletions
@@ -113,6 +113,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
@@ -120,6 +124,8 @@ public class DepartmentController {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
@@ -149,6 +155,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
try {
int result = departmentService.deleteDepartment(deptCode);
@@ -187,8 +197,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode);
DepartmentService.RestoreResult result;
try {
result = departmentService.restoreDepartment(deptCode);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
switch (result) {
case OK:
Map<String, Object> data = new java.util.HashMap<>();
@@ -234,8 +253,17 @@ public class DepartmentController {
@GetMapping("/companies/{companyCode}/users/search")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestParam(required = false) String search) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
}
if (search == null || search.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
}
@@ -266,10 +294,13 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id");
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
if (userIdObj == null || userIdObj.toString().isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
}
@@ -307,6 +338,10 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
boolean removed = departmentService.removeDeptMember(deptCode, userId);
if (!removed) {
@@ -337,9 +372,17 @@ public class DepartmentController {
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
}
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
try {
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
// ──────────────────────────────────────────────────
@@ -81,21 +81,29 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성
String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code"));
String deptCode;
if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) {
// 중복 체크
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", requestedCode));
if (existing != null) {
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
}
deptCode = requestedCode;
} else {
// parent_dept_code cross-tenant / 존재 / 삭제 검증
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
String parentCode = parentObj != null ? parentObj.toString() : null;
validateParent(parentCode, companyCode);
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
// 재시도 로직 (race condition 대비, 최대 3회)
String deptCode = null;
for (int attempt = 0; attempt < 3; attempt++) {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
deptCode = "DEPT_" + nextNumber;
long nextNumber = codeResult != null && codeResult.get("next_number") != null
? ((Number) codeResult.get("next_number")).longValue()
: 1L;
String candidate = "DEPT_" + nextNumber;
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", candidate));
if (existing == null) {
deptCode = candidate;
break;
}
}
if (deptCode == null) {
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
}
// 부서 생성 (전체 필드)
@@ -104,7 +112,7 @@ public class DepartmentService extends BaseService {
insertParams.put("dept_name", deptName);
insertParams.put("company_code", companyCode);
insertParams.put("company_name", companyName);
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
insertParams.put("parent_dept_code", parentCode);
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
@@ -135,9 +143,34 @@ public class DepartmentService extends BaseService {
throw new IllegalArgumentException("부서명을 입력해주세요.");
}
// 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용)
Map<String, Object> existingDept = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", deptCode)
);
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
? existingDept.get("company_code").toString()
: null;
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null);
String newParentCode = newParent != null ? newParent.toString() : null;
// parent_dept_code cross-tenant / 존재 / 삭제 검증
if (deptCompanyCode != null) {
validateParent(newParentCode, deptCompanyCode);
}
verifyParentCycle(deptCode, newParentCode);
// 부서명 중복 검증 — 본인 dept_code 는 제외
if (deptCompanyCode != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", deptCompanyCode);
dupParams.put("dept_name", deptName);
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
@@ -227,6 +260,19 @@ public class DepartmentService extends BaseService {
}
}
// 동일 이름의 active 부서 중복 검증 (복구 시점)
Object companyCodeObj = dept.get("company_code");
Object deptNameObj = dept.get("dept_name");
if (companyCodeObj != null && deptNameObj != null) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCodeObj.toString());
dupParams.put("dept_name", deptNameObj.toString());
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
int restored = sqlSession.update("department.restoreDepartment", params);
@@ -242,6 +288,28 @@ public class DepartmentService extends BaseService {
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
}
/**
* parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증.
* null/blank 면 검증 스킵 (최상위 부서).
*/
private void validateParent(String parentCode, String companyCode) {
if (parentCode == null || parentCode.isBlank()) return;
Map<String, Object> parent = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", parentCode)
);
if (parent == null) {
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
}
if (parent.get("deleted_at") != null) {
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
}
Object parentCompany = parent.get("company_code");
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
}
}
/**
* parent_dept_code 변경 시 사이클 검증.
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
@@ -355,6 +423,15 @@ public class DepartmentService extends BaseService {
@Transactional
public void setPrimaryDept(String deptCode, String userId) {
// 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
if (existing == null) {
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
}
// 다른 부서의 주 부서 해제
Map<String, Object> clearParams = new HashMap<>();
clearParams.put("user_id", userId);
@@ -385,10 +462,13 @@ public class DepartmentService extends BaseService {
return val != null ? val : body.get(camelCase);
}
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
private Object nullIfBlank(Object value) {
if (value == null) return null;
if (value instanceof String s && s.trim().isEmpty()) return null;
if (value instanceof String s) {
String trimmed = s.trim();
return trimmed.isEmpty() ? null : trimmed;
}
return value;
}