diff --git a/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java index 59955af4..8f90c325 100644 --- a/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java +++ b/backend-spring/src/main/java/com/erp/constants/InputTypeConstants.java @@ -5,9 +5,15 @@ import java.util.Set; public final class InputTypeConstants { private InputTypeConstants() {} - /** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */ + /** + * INSERT/UPDATE-type 검증용 허용 INPUT_TYPE. + * 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean). + * 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려 + * 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로. + */ public static final Set USER_SELECTABLE_INPUT_TYPES = Set.of( "text", "number", "date", "code", "entity", - "numbering", "file", "image" + "numbering", "file", "image", + "category", "select", "textarea", "checkbox", "radio", "datetime", "boolean" ); } diff --git a/backend-spring/src/main/java/com/erp/controller/AdminController.java b/backend-spring/src/main/java/com/erp/controller/AdminController.java index a1c8d745..b4a14e78 100644 --- a/backend-spring/src/main/java/com/erp/controller/AdminController.java +++ b/backend-spring/src/main/java/com/erp/controller/AdminController.java @@ -1,7 +1,9 @@ package com.erp.controller; import com.erp.dto.ApiResponse; +import com.erp.provisioning.SuperAdminGuard; import com.erp.service.AdminService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -30,13 +32,17 @@ public class AdminController { @RequestAttribute("company_code") String companyCode, @RequestAttribute("role") String role, @RequestAttribute("user_id") String userId, - @RequestParam Map params) { + @RequestParam Map params, + HttpServletRequest request) { params.put("company_code", companyCode); params.put("user_type", role); params.put("user_id", userId); params.putIfAbsent("user_lang", "ko"); params.put("is_management_screen", params.get("menu_type") == null || "true".equals(params.get("include_inactive"))); + // 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외 + String host = request.getHeader("Host"); + params.put("is_management_host", !SuperAdminGuard.isTenantHost(host)); return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공")); } @@ -49,11 +55,15 @@ public class AdminController { @RequestAttribute("company_code") String companyCode, @RequestAttribute("role") String role, @RequestAttribute("user_id") String userId, - @RequestParam Map params) { + @RequestParam Map params, + HttpServletRequest request) { params.put("company_code", companyCode); params.put("user_type", role); params.put("user_id", userId); params.putIfAbsent("user_lang", "ko"); + // 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외 + String host = request.getHeader("Host"); + params.put("is_management_host", !SuperAdminGuard.isTenantHost(host)); return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공")); } diff --git a/backend-spring/src/main/java/com/erp/controller/CompanyManagementController.java b/backend-spring/src/main/java/com/erp/controller/CompanyManagementController.java index 64e41d8f..b8e18070 100644 --- a/backend-spring/src/main/java/com/erp/controller/CompanyManagementController.java +++ b/backend-spring/src/main/java/com/erp/controller/CompanyManagementController.java @@ -1,7 +1,9 @@ package com.erp.controller; import com.erp.dto.ApiResponse; +import com.erp.provisioning.SuperAdminGuard; import com.erp.service.CompanyManagementService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -16,6 +18,7 @@ import java.util.Map; @Slf4j public class CompanyManagementController { + private final SuperAdminGuard guard; private final CompanyManagementService companyManagementService; /** @@ -24,9 +27,12 @@ public class CompanyManagementController { */ @DeleteMapping("/{companyCode}") public ResponseEntity>> deleteCompany( + HttpServletRequest request, @PathVariable String companyCode, @RequestBody(required = false) Map body) { + guard.enforce(request); + Map params = new HashMap<>(); params.put("company_code", companyCode); if (body != null) { @@ -52,7 +58,11 @@ public class CompanyManagementController { * ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음) */ @GetMapping("/disk-usage/all") - public ResponseEntity>> getAllCompaniesDiskUsage() { + public ResponseEntity>> getAllCompaniesDiskUsage( + HttpServletRequest request) { + + guard.enforce(request); + try { Map data = companyManagementService.getAllCompaniesDiskUsage(); return ResponseEntity.ok(ApiResponse.success(data)); @@ -68,7 +78,11 @@ public class CompanyManagementController { */ @GetMapping("/{companyCode}/disk-usage") public ResponseEntity>> getCompanyDiskUsage( + HttpServletRequest request, @PathVariable String companyCode) { + + guard.enforce(request); + try { Map data = companyManagementService.getCompanyDiskUsage(companyCode); return ResponseEntity.ok(ApiResponse.success(data)); 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 7355b011..9466adc8 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -142,6 +142,135 @@ public class DepartmentController { } } + /** + * 일괄 미리보기 (read-only validation). + * POST /api/departments/companies/{companyCode}/departments/bulk/preview + * body: { action: "create"|"update_department"|"update_manager", rows: List } + * response: { rows: [...with row_index/result/error_detail], ok_count, error_count } + */ + @PostMapping("/companies/{companyCode}/departments/bulk/preview") + public ResponseEntity>> bulkPreview( + @PathVariable String companyCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestAttribute("role") String role, + @RequestBody Map body) { + + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다.")); + } + String action = body.get("action") != null ? body.get("action").toString() : ""; + @SuppressWarnings("unchecked") + List> rows = body.get("rows") instanceof List + ? (List>) body.get("rows") : null; + if (rows == null) { + return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다.")); + } + try { + List> result; + switch (action) { + case "create": + result = departmentService.bulkPreviewCreate(companyCode, rows); + break; + case "update_department": + result = departmentService.bulkPreviewUpdate(companyCode, "department", rows); + break; + case "update_manager": + result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows); + break; + default: + return ResponseEntity.status(400) + .body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나.")); + } + int ok = 0, err = 0; + for (Map r : result) { + if ("ok".equals(r.get("result"))) ok++; else err++; + } + Map data = new java.util.HashMap<>(); + data.put("rows", result); + data.put("ok_count", ok); + data.put("error_count", err); + return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료")); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage())); + } + } + + /** + * 일괄등록 적용 (@Transactional, all-or-nothing). + * POST /api/departments/companies/{companyCode}/departments/bulk/create + * body: { rows: List } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함. + */ + @PostMapping("/companies/{companyCode}/departments/bulk/create") + public ResponseEntity>> bulkCreate( + @PathVariable String companyCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestAttribute("role") String role, + @RequestBody Map body) { + + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다.")); + } + @SuppressWarnings("unchecked") + List> rows = body.get("rows") instanceof List + ? (List>) body.get("rows") : null; + if (rows == null || rows.isEmpty()) { + return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다.")); + } + try { + int inserted = departmentService.bulkSaveCreate(companyCode, rows); + Map data = new java.util.HashMap<>(); + data.put("inserted", inserted); + return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다.")); + } 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())); + } + } + + /** + * 일괄업데이트 적용 (@Transactional). mode = department | manager. + * POST /api/departments/companies/{companyCode}/departments/bulk/update + * body: { mode: "department"|"manager", rows: List } — 각 row 에 dept_code 필수. + */ + @PostMapping("/companies/{companyCode}/departments/bulk/update") + public ResponseEntity>> bulkUpdate( + @PathVariable String companyCode, + @RequestAttribute("company_code") String userCompanyCode, + @RequestAttribute("role") String role, + @RequestBody Map body) { + + if (!isAdmin(role)) { + return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); + } + if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다.")); + } + String mode = body.get("mode") != null ? body.get("mode").toString() : ""; + @SuppressWarnings("unchecked") + List> rows = body.get("rows") instanceof List + ? (List>) body.get("rows") : null; + if (rows == null || rows.isEmpty()) { + return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다.")); + } + try { + int updated = departmentService.bulkUpdate(companyCode, mode, rows); + Map data = new java.util.HashMap<>(); + data.put("updated", updated); + return ResponseEntity.ok(ApiResponse.success(data, 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())); + } + } + /** * 부서 삭제 (soft-delete, V1 slim scope). * - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경 diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java index f9060da5..ff0267d1 100644 --- a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java @@ -2,6 +2,8 @@ package com.erp.crosstenant; import com.erp.tenant.DbContextHolder; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; /** * Cross-tenant 어드민 엔드포인트 진입 가드. @@ -42,4 +44,16 @@ public final class CrossTenantContext { public static boolean isMetaContext() { return DbContextHolder.isMeta(); } + + /** + * 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절. + * cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙. + */ + public static void requireManagementHost(HttpServletRequest request) { + String host = request.getHeader("Host"); + if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Cross-tenant operations are only available on the management site"); + } + } } diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java index 788c41f5..92d89bab 100644 --- a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java @@ -59,6 +59,12 @@ public class CrossTenantController { */ @GetMapping("/_active-companies") public ResponseEntity>> activeCompaniesSmoke(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -92,6 +98,12 @@ public class CrossTenantController { public ResponseEntity>> listUsers( HttpServletRequest request, @RequestParam Map queryParams) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -173,6 +185,12 @@ public class CrossTenantController { Map queryParams, String mapperId, boolean wrapSearchWithPercent) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java index d7245db4..1aef2890 100644 --- a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantDeptController.java @@ -39,6 +39,12 @@ public class CrossTenantDeptController { public ResponseEntity> listDepartments( HttpServletRequest request, @RequestParam("company_code") String companyCode) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(errorBody(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(errorBody("super_admin_required", request.getRequestURI())); diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantRoleController.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantRoleController.java index ffd412ff..1610004a 100644 --- a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantRoleController.java +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantRoleController.java @@ -1,6 +1,7 @@ package com.erp.crosstenant; import com.erp.dto.ApiResponse; +import com.erp.provisioning.CompanyAuditLogService; import com.erp.service.RoleService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -33,6 +34,7 @@ public class CrossTenantRoleController { private final CrossTenantExecutor executor; private final RoleService roleService; + private final CompanyAuditLogService auditLogService; // ── 권한 그룹 CRUD ────────────────────────────────────────────── @@ -49,6 +51,7 @@ public class CrossTenantRoleController { if (g != null) return g; String targetCompany = (String) body.get("company_code"); + String actorId = (String) request.getAttribute("user_id"); try { Map result = executor.runInCompany(targetCompany, () -> { Map params = new HashMap<>(body); @@ -62,6 +65,10 @@ public class CrossTenantRoleController { } return roleService.createRoleGroup(params); }); + auditLogService.log(targetCompany, actorId, + CompanyAuditLogService.ACTION_CT_ROLE_UPDATE, + (String) body.get("auth_code"), + auditDetails(request, null)); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -81,6 +88,7 @@ public class CrossTenantRoleController { if (g != null) return g; String targetCompany = (String) body.get("company_code"); + String actorId = (String) request.getAttribute("user_id"); try { Map result = executor.runInCompany(targetCompany, () -> { Map params = new HashMap<>(body); @@ -94,6 +102,10 @@ public class CrossTenantRoleController { } return roleService.updateRoleGroup(params); }); + auditLogService.log(targetCompany, actorId, + CompanyAuditLogService.ACTION_CT_ROLE_UPDATE, + id, + auditDetails(request, id)); return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -111,12 +123,17 @@ public class CrossTenantRoleController { ResponseEntity> g = guardVoid(request); if (g != null) return g; + String actorId = (String) request.getAttribute("user_id"); try { executor.runInCompany(companyCode, () -> { Map p = new HashMap<>(); p.put("objid", id); roleService.deleteRoleGroup(p); }); + auditLogService.log(companyCode, actorId, + CompanyAuditLogService.ACTION_CT_ROLE_UPDATE, + id, + auditDetails(request, id)); return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -266,6 +283,12 @@ public class CrossTenantRoleController { // ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ──────── private ResponseEntity>> guardMap(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -278,6 +301,12 @@ public class CrossTenantRoleController { } private ResponseEntity> guardVoid(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -290,6 +319,12 @@ public class CrossTenantRoleController { } private ResponseEntity>>> guardList(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -301,6 +336,14 @@ public class CrossTenantRoleController { return null; } + /** audit log details 기본 맵 생성 헬퍼. */ + private Map auditDetails(HttpServletRequest request, String roleId) { + Map d = new HashMap<>(); + d.put("host", request.getHeader("Host")); + if (roleId != null) d.put("role_id", roleId); + return d; + } + /** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */ private String asYn(Object raw) { if (raw == null) return null; diff --git a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantUserController.java b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantUserController.java index 8a73f037..f6040d17 100644 --- a/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantUserController.java +++ b/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantUserController.java @@ -1,6 +1,7 @@ package com.erp.crosstenant; import com.erp.dto.ApiResponse; +import com.erp.provisioning.CompanyAuditLogService; import com.erp.service.AdminService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -36,6 +37,7 @@ public class CrossTenantUserController { private final CrossTenantExecutor executor; private final AdminService adminService; + private final CompanyAuditLogService auditLogService; // ── 등록 / 수정 ───────────────────────────────────────────────────── @@ -51,9 +53,14 @@ public class CrossTenantUserController { if (guard != null) return guard; String targetCompanyCode = (String) body.get("company_code"); + String actorId = (String) request.getAttribute("user_id"); try { Map result = executor.runInCompany(targetCompanyCode, () -> adminService.saveUser(body)); + auditLogService.log(targetCompanyCode, actorId, + CompanyAuditLogService.ACTION_CT_USER_CREATE, + (String) body.get("user_id"), + auditDetails(request, (String) body.get("user_id"))); return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); @@ -116,6 +123,7 @@ public class CrossTenantUserController { ResponseEntity> guard = guardVoid(request); if (guard != null) return guard; + String actorId = (String) request.getAttribute("user_id"); try { executor.runInCompany(companyCode, () -> { Map existing = adminService.getUserInfo(userId); @@ -124,6 +132,10 @@ public class CrossTenantUserController { } adminService.changeUserStatus(userId, "inactive"); }); + auditLogService.log(companyCode, actorId, + CompanyAuditLogService.ACTION_CT_USER_DELETE, + userId, + auditDetails(request, userId)); return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); @@ -166,9 +178,14 @@ public class CrossTenantUserController { String targetCompanyCode = (String) body.get("company_code"); String userId = (String) body.get("user_id"); + String actorId = (String) request.getAttribute("user_id"); try { executor.runInCompany(targetCompanyCode, () -> adminService.resetUserPassword(userId)); + auditLogService.log(targetCompanyCode, actorId, + CompanyAuditLogService.ACTION_CT_PW_RESET, + userId, + auditDetails(request, userId)); return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage())); @@ -260,6 +277,12 @@ public class CrossTenantUserController { /** Map 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */ private ResponseEntity>> guard(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -273,6 +296,12 @@ public class CrossTenantUserController { /** Void 응답용 가드 (제네릭만 다름). */ private ResponseEntity> guardVoid(HttpServletRequest request) { + try { + CrossTenantContext.requireManagementHost(request); + } catch (org.springframework.web.server.ResponseStatusException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(e.getReason(), request.getRequestURI())); + } if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); @@ -283,4 +312,12 @@ public class CrossTenantUserController { } return null; } + + /** audit log details 기본 맵 생성 헬퍼. */ + private Map auditDetails(HttpServletRequest request, String targetUserId) { + Map d = new HashMap<>(); + d.put("host", request.getHeader("Host")); + if (targetUserId != null) d.put("target_user_id", targetUserId); + return d; + } } 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 4f0c05bf..1655eece 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -203,7 +203,110 @@ public class StartupSchemaMigrator { FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE ) """, - "CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)" + "CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)", + + // V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가. + // 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그. + // 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만 + // 프로비저닝된 테넌트 DB 는 부팅 때 동기화. + "ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL", + + // V023 데이터 동기화: 솔루션 전용 메뉴 마킹. + // 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함. + // 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신. + """ + UPDATE MENU_INFO + SET IS_SOLUTION_ONLY = TRUE + WHERE IS_SOLUTION_ONLY = FALSE + AND MENU_URL IN ( + '/admin/sysMng/subdomainList', + '/admin/userMng/companyList', + '/admin/audit-log' + ) + """, + + // V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename. + // 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만 + // 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환. + // PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리: + // - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행 + // - 이미 CODE_INFO 인 신규 테넌트: no-op + // - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적) + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_category' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_info' + ) THEN + ALTER TABLE TABLE_TYPE_COLUMNS + RENAME COLUMN CODE_CATEGORY TO CODE_INFO; + END IF; + END $$ + """, + + // V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리. + // PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는 + // UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음. + // 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를 + // updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태. + // 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거. + // 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음). + """ + DELETE FROM TABLE_TYPE_COLUMNS + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE + ORDER BY UPDATED_DATE DESC NULLS LAST, + id::bigint DESC + ) AS rn + FROM TABLE_TYPE_COLUMNS + ) r + WHERE r.rn > 1 + ) + """, + + // V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가. + // mapper 의 upsertColumnSettings / upsertNullable / upsertUnique / + // upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) + // 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500. + // 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의 + // IF NOT EXISTS 누락 문제도 회피. + "CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)", + + // V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리. + // 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의 + // 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12, + // datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림. + // 매핑: + // category / select / radio / checkbox / boolean → code (commonCode 통합 의도) + // textarea → text (single/multi line 구분 손실 — UI 동작 가벼움) + // datetime → date + // 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0. + // WHERE 절로 멱등 (재실행 시 0 row). + """ + UPDATE TABLE_TYPE_COLUMNS + SET INPUT_TYPE = CASE INPUT_TYPE + WHEN 'category' THEN 'code' + WHEN 'select' THEN 'code' + WHEN 'radio' THEN 'code' + WHEN 'checkbox' THEN 'code' + WHEN 'boolean' THEN 'code' + WHEN 'textarea' THEN 'text' + WHEN 'datetime' THEN 'date' + END, + UPDATED_DATE = NOW() + WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime') + """ ); @EventListener(ApplicationReadyEvent.class) diff --git a/backend-spring/src/main/java/com/erp/provisioning/CompanyAuditLogService.java b/backend-spring/src/main/java/com/erp/provisioning/CompanyAuditLogService.java index 7bca6ed2..d0500e6a 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/CompanyAuditLogService.java +++ b/backend-spring/src/main/java/com/erp/provisioning/CompanyAuditLogService.java @@ -40,6 +40,12 @@ public class CompanyAuditLogService { public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET"; public static final String ACTION_RECOPY = "TEMPLATES_RECOPY"; + // cross-tenant write 감사 액션 + public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE"; + public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE"; + public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET"; + public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE"; + private final SqlSession sqlSession; private final ObjectMapper objectMapper; diff --git a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java index 926d66ea..1f76021b 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java +++ b/backend-spring/src/main/java/com/erp/provisioning/ProvisioningController.java @@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSession; -import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; import java.security.SecureRandom; import java.util.Arrays; @@ -40,13 +37,7 @@ public class ProvisioningController { private final ProvisioningRegistry registry; private final SqlSession sqlSession; private final CompanyStatsService statsService; - - /** - * 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본. - * 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true - */ - @Value("${tenant.provisioning.require-super-admin:false}") - private boolean requireSuperAdmin; + private final SuperAdminGuard guard; @GetMapping("/table-groups") public ResponseEntity>> tableGroups(HttpServletRequest request) { @@ -208,23 +199,11 @@ public class ProvisioningController { } // ------------------------------------------------------------------ - // 권한 체크 - // - // 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증. - // JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅. - // 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단. - // 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403. + // 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증). + // CompanyMgmtController 와 동일한 가드를 공유. // ------------------------------------------------------------------ private void enforceSuperAdmin(HttpServletRequest request) { - String userType = (String) request.getAttribute("user_type"); - if ("SUPER_ADMIN".equals(userType)) return; - - if (!requireSuperAdmin && userType == null) { - log.warn("[Provisioning] anonymous access allowed in dev mode (set " + - "tenant.provisioning.require-super-admin=true in production)"); - return; - } - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only"); + guard.enforce(request); } // --- Validation helpers --- diff --git a/backend-spring/src/main/java/com/erp/provisioning/SuperAdminGuard.java b/backend-spring/src/main/java/com/erp/provisioning/SuperAdminGuard.java index c7d65583..fbfd88ba 100644 --- a/backend-spring/src/main/java/com/erp/provisioning/SuperAdminGuard.java +++ b/backend-spring/src/main/java/com/erp/provisioning/SuperAdminGuard.java @@ -1,5 +1,6 @@ package com.erp.provisioning; +import com.erp.tenant.ReservedSubdomains; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; +import java.util.regex.Pattern; + /** * `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드. * + * - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403. + * 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작. + * (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음) * - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과 * - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단 * @@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException; @Slf4j public class SuperAdminGuard { + private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$"); + @Value("${tenant.provisioning.require-super-admin:false}") private boolean requireSuperAdmin; public void enforce(HttpServletRequest request) { + // 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함. + String host = request.getHeader("Host"); + if (isTenantHost(host)) { + log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}", + host, request.getRequestURI(), request.getAttribute("user_type")); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Provisioning is only available on the management site"); + } + + // 2) role 체크 String userType = (String) request.getAttribute("user_type"); if ("SUPER_ADMIN".equals(userType)) return; if (!requireSuperAdmin && userType == null) { @@ -37,4 +55,40 @@ public class SuperAdminGuard { String userId = (String) request.getAttribute("user_id"); return userId == null ? "dev-anonymous" : userId; } + + /** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */ + public String requestHost(HttpServletRequest request) { + String host = request.getHeader("Host"); + return host == null ? "" : host; + } + + /** + * "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙. + * - localhost / IP / 베이스 도메인 → false (관리 호스트) + * - solution.invyone.com 등 예약어 prefix → false (관리 호스트) + * - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true + */ + public static boolean isTenantHost(String host) { + if (host == null || host.isBlank()) return false; + + int colon = host.indexOf(':'); + if (colon != -1) host = host.substring(0, colon); + host = host.toLowerCase(); + + if ("localhost".equals(host)) return false; + if (IPV4.matcher(host).matches()) return false; + + String[] parts = host.split("\\."); + if (parts.length == 2) { + // {sub}.localhost (dev) + if (!"localhost".equals(parts[1])) return false; + String first = parts[0]; + if (first.isEmpty()) return false; + return !ReservedSubdomains.VALUES.contains(first); + } + if (parts.length < 3) return false; + + String first = parts[0]; + return !ReservedSubdomains.VALUES.contains(first); + } } diff --git a/backend-spring/src/main/java/com/erp/service/CommonCodeService.java b/backend-spring/src/main/java/com/erp/service/CommonCodeService.java index b0dc23e3..6430fbd6 100644 --- a/backend-spring/src/main/java/com/erp/service/CommonCodeService.java +++ b/backend-spring/src/main/java/com/erp/service/CommonCodeService.java @@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService { @Transactional public Map insertCodeInfo(Map body, String companyCode, String userId) { + Object rawCodeInfo = body.get("code_info"); + String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim(); + if (codeInfo != null && !codeInfo.isEmpty() + && getCodeInfoInfo(codeInfo, companyCode) != null) { + throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo); + } + Map params = new HashMap<>(); params.put("code_info", body.get("code_info")); params.put("code_name", body.get("code_name")); 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 46719035..92d61908 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -365,6 +366,330 @@ public class DepartmentService extends BaseService { } } + // ────────────────────────────────────────────────── + // 일괄등록 / 일괄업데이트 (Bulk) + // ────────────────────────────────────────────────── + + private static final int BULK_MAX_ROWS = 1000; + + /** + * 일괄등록 — preview (read-only validation). DB 쓰기 없음. + * batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증. + * 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환. + */ + public List> bulkPreviewCreate(String companyCode, List> rows) { + List> results = new ArrayList<>(); + if (rows == null || rows.isEmpty()) return results; + if (rows.size() > BULK_MAX_ROWS) { + throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다."); + } + Set existingNames = collectActiveDeptNames(companyCode); + Set batchNames = new HashSet<>(); + + for (int i = 0; i < rows.size(); i++) { + Map input = rows.get(i); + Map out = new HashMap<>(input); + out.put("row_index", i); + String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames); + if (error == null) { + out.put("result", "ok"); + out.put("error_detail", null); + String dn = trimString(input.get("dept_name")); + if (dn != null) batchNames.add(dn.toLowerCase()); + } else { + out.put("result", "error"); + out.put("error_detail", error); + } + results.add(out); + } + return results; + } + + /** + * 일괄업데이트 — preview (read-only). mode = department | manager. + */ + public List> bulkPreviewUpdate(String companyCode, String mode, List> rows) { + List> results = new ArrayList<>(); + if (rows == null || rows.isEmpty()) return results; + if (rows.size() > BULK_MAX_ROWS) { + throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다."); + } + if (!"department".equals(mode) && !"manager".equals(mode)) { + throw new IllegalArgumentException("mode 는 department | manager 여야 합니다."); + } + for (int i = 0; i < rows.size(); i++) { + Map input = rows.get(i); + Map out = new HashMap<>(input); + out.put("row_index", i); + String error = validateBulkUpdateRow(input, companyCode, mode); + if (error == null) { + out.put("result", "ok"); + out.put("error_detail", null); + } else { + out.put("result", "error"); + out.put("error_detail", error); + } + results.add(out); + } + return results; + } + + /** + * 일괄등록 — 실제 저장 (@Transactional, all-or-nothing). + * 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름. + * 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백. + */ + @Transactional + public int bulkSaveCreate(String companyCode, List> rows) { + if (rows == null || rows.isEmpty()) return 0; + if (rows.size() > BULK_MAX_ROWS) { + throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다."); + } + int inserted = 0; + for (int i = 0; i < rows.size(); i++) { + Map row = rows.get(i); + String label = trimString(row.get("dept_name")); + try { + createDepartment(companyCode, row); + inserted++; + } catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) { + throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage()); + } + } + log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted); + return inserted; + } + + /** + * 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager. + * department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존). + * manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all). + */ + @Transactional + public int bulkUpdate(String companyCode, String mode, List> rows) { + if (rows == null || rows.isEmpty()) return 0; + if (rows.size() > BULK_MAX_ROWS) { + throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다."); + } + if (!"department".equals(mode) && !"manager".equals(mode)) { + throw new IllegalArgumentException("mode 는 department | manager 여야 합니다."); + } + int updated = 0; + for (int i = 0; i < rows.size(); i++) { + Map row = rows.get(i); + String deptCode = trimString(row.get("dept_code")); + if (deptCode == null) { + throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수."); + } + Map existing = getDepartment(deptCode); + if (existing == null) { + throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode); + } + String deptCompanyCode = existing.get("company_code") != null + ? existing.get("company_code").toString() : null; + if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) { + throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode); + } + try { + if ("department".equals(mode)) { + Map merged = buildMergedDeptBody(existing, row); + Map result = updateDepartment(deptCode, merged); + if (result == null) { + throw new IllegalStateException("수정 실패: " + deptCode); + } + } else { + // manager mode — row 에 명시된 role 만 sync + if (row.containsKey("approval_managers")) { + syncManagers(deptCode, companyCode, row, "approval"); + } + if (row.containsKey("dept_managers")) { + syncManagers(deptCode, companyCode, row, "dept"); + } + if (row.containsKey("org_leaders")) { + syncManagers(deptCode, companyCode, row, "org_leader"); + } + } + updated++; + } catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) { + throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage()); + } + } + log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated); + return updated; + } + + /** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */ + private Set collectActiveDeptNames(String companyCode) { + Set names = new HashSet<>(); + for (Map d : getDepartments(companyCode, false, null)) { + Object name = d.get("dept_name"); + if (name != null) names.add(name.toString().trim().toLowerCase()); + } + return names; + } + + /** + * 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error. + */ + private String validateBulkCreateRow(Map row, String companyCode, + Set existingNames, Set batchNames) { + String deptName = trimString(row.get("dept_name")); + if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다."; + String lower = deptName.toLowerCase(); + if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName; + if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName; + + String dt = trimString(row.get("dept_type")); + if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) { + return "부서유형은 dept|team|temp 중 하나: " + dt; + } + String parent = trimString(row.get("parent_dept_code")); + String parentErr = validateParentForBulk(parent, companyCode); + if (parentErr != null) return parentErr; + + String dateErr = validateDateRange(row); + if (dateErr != null) return dateErr; + + String mgrErr = validateManagerIds(row, companyCode); + if (mgrErr != null) return mgrErr; + return null; + } + + /** + * 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증. + */ + private String validateBulkUpdateRow(Map row, String companyCode, String mode) { + String deptCode = trimString(row.get("dept_code")); + if (deptCode == null) return "부서코드(dept_code) 필수."; + Map existing = getDepartment(deptCode); + if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode; + String deptCompanyCode = existing.get("company_code") != null + ? existing.get("company_code").toString() : null; + if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) { + return "다른 회사의 부서: " + deptCode; + } + if ("department".equals(mode)) { + String newName = trimString(row.get("dept_name")); + if (newName != null && !newName.isEmpty()) { + String existingName = existing.get("dept_name") != null + ? existing.get("dept_name").toString().trim() : ""; + if (!newName.equalsIgnoreCase(existingName)) { + Map dupParams = new HashMap<>(); + dupParams.put("company_code", companyCode); + dupParams.put("dept_name", newName); + Map dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams); + if (dup != null && !deptCode.equals(dup.get("dept_code"))) { + return "이미 존재하는 부서명: " + newName; + } + } + } + String dt = trimString(row.get("dept_type")); + if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) { + return "부서유형은 dept|team|temp 중 하나: " + dt; + } + String dateErr = validateDateRange(row); + if (dateErr != null) return dateErr; + String parent = trimString(row.get("parent_dept_code")); + String parentErr = validateParentForBulk(parent, companyCode); + if (parentErr != null) return parentErr; + } + return validateManagerIds(row, companyCode); + } + + private String validateParentForBulk(String parent, String companyCode) { + if (parent == null) return null; + Map p = sqlSession.selectOne( + "department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent)); + if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent; + if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent; + Object pc = p.get("company_code"); + if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) { + return "다른 회사의 부서를 상위로 지정 불가: " + parent; + } + return null; + } + + private String validateDateRange(Map row) { + String sd = trimString(row.get("start_date")); + String ed = trimString(row.get("end_date")); + if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd; + if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed; + if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음."; + return null; + } + + private String validateManagerIds(Map row, String companyCode) { + for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) { + Object raw = row.get(key); + if (raw instanceof List list && list.size() > 10) { + return key + " 는 최대 10명까지 등록 가능합니다."; + } + } + List ids = collectManagerUserIds(row); + if (ids.isEmpty()) return null; + Map vParams = new HashMap<>(); + vParams.put("user_ids", ids); + vParams.put("company_code", companyCode); + List valid = sqlSession.selectList("department.selectValidUserIds", vParams); + if (valid == null || valid.size() != ids.size()) { + Set invalid = new HashSet<>(ids); + if (valid != null) invalid.removeAll(valid); + return "유효하지 않은 사용자 ID: " + invalid; + } + return null; + } + + private List collectManagerUserIds(Map row) { + List ids = new ArrayList<>(); + for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) { + Object raw = row.get(key); + if (raw instanceof List list) { + for (Object item : list) { + String uid = null; + if (item instanceof Map m) { + Object v = m.get("user_id"); + if (v != null) uid = v.toString().trim(); + } else if (item != null) { + uid = item.toString().trim(); + } + if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid); + } + } + } + return ids; + } + + /** + * 일괄업데이트 department mode — 기존값 + row override 머지. + * row 값이 null/미지정이면 기존값 보존 (PATCH semantic). + * 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸). + */ + private Map buildMergedDeptBody(Map existing, Map row) { + Map merged = new HashMap<>(); + String[] textKeys = { + "dept_name", "parent_dept_code", "short_name", "dept_type", "org_system", + "approval_manager", "dept_manager", "zipcode", "address1", "address2", + "sort_order", "status", "location" + }; + for (String k : textKeys) merged.put(k, existing.get(k)); + merged.put("start_date", stringifyDate(existing.get("start_date"))); + merged.put("end_date", stringifyDate(existing.get("end_date"))); + for (Map.Entry e : row.entrySet()) { + String k = e.getKey(); + if ("dept_code".equals(k)) continue; + if (e.getValue() == null) continue; + if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue; + merged.put(k, e.getValue()); + } + return merged; + } + + private String stringifyDate(Object date) { + if (date == null) return null; + String s = date.toString(); + return s.length() >= 10 ? s.substring(0, 10) : null; + } + // ────────────────────────────────────────────────── // 부서원 관리 // ────────────────────────────────────────────────── diff --git a/backend-spring/src/main/resources/db/migration/V023__add_solution_only_menu_flag.sql b/backend-spring/src/main/resources/db/migration/V023__add_solution_only_menu_flag.sql new file mode 100644 index 00000000..f0d00068 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V023__add_solution_only_menu_flag.sql @@ -0,0 +1,12 @@ +ALTER TABLE MENU_INFO + ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL; + +COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.'; + +-- 솔루션 전용 메뉴 마킹 +UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE + WHERE MENU_URL IN ( + '/admin/sysMng/subdomainList', + '/admin/userMng/companyList', + '/admin/audit-log' + ); diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index 1211310b..91a64af0 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -58,6 +58,9 @@ AND RMA.READ_YN = 'Y' ) + + AND MENU.IS_SOLUTION_ONLY = FALSE + UNION ALL @@ -105,6 +108,9 @@ AND RMA.READ_YN = 'Y' ) + + AND S.IS_SOLUTION_ONLY = FALSE + ) SELECT V.LEV @@ -187,6 +193,9 @@ AND MENU.COMPANY_CODE = #{company_code} + + AND MENU.IS_SOLUTION_ONLY = FALSE + UNION ALL @@ -212,6 +221,9 @@ ON S.PARENT_OBJ_ID = V.OBJID WHERE S.OBJID != ALL(V.PATH) AND S.STATUS = 'active' + + AND S.IS_SOLUTION_ONLY = FALSE + ) SELECT V.LEV diff --git a/backend-spring/src/main/resources/mapper/tableManagement.xml b/backend-spring/src/main/resources/mapper/tableManagement.xml index 8b727d4e..4e576fa3 100644 --- a/backend-spring/src/main/resources/mapper/tableManagement.xml +++ b/backend-spring/src/main/resources/mapper/tableManagement.xml @@ -667,15 +667,15 @@ SET PROPERTIES = JSONB_SET( JSONB_SET( - SL.PROPERTIES, + SL.PROPERTIES::JSONB, '{widgetType}', TO_JSONB(#{component_id}::TEXT) ), '{componentType}', TO_JSONB(#{component_id}::TEXT) - ) + )::TEXT FROM SCREEN_DEFINITIONS SD WHERE SL.SCREEN_ID = SD.SCREEN_ID - AND SL.PROPERTIES->>'tableName' = #{table_name} - AND SL.PROPERTIES->>'columnName' = #{column_name} + AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name} + AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name} AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*') diff --git a/db/migrations/RUN_089_MIGRATION.md b/db/migrations/RUN_089_MIGRATION.md new file mode 100644 index 00000000..b1c5c56e --- /dev/null +++ b/db/migrations/RUN_089_MIGRATION.md @@ -0,0 +1,143 @@ +# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename + +작성일: 2026-05-15 +작성자: johngreen +관련: +- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c) +- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e) + +## 목적 + +V023 과 V024 두 건의 누락된 운영 문서를 합본 처리. +앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다. + +### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상) + +테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그. +- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외. +- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본. + +### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심) + +5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서 +`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨. +그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API +(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환: + +``` +ERROR: column cl.code_info does not exist +``` + +본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다. + +## 스키마 + +### MENU_INFO (V023) + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 | + +### TABLE_TYPE_COLUMNS (V024) + +| 변경 | 설명 | +|---|---| +| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) | + +## SQL + +```sql +-- ================================================================= +-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent) +-- ================================================================= + +ALTER TABLE MENU_INFO + ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL; + +UPDATE MENU_INFO + SET IS_SOLUTION_ONLY = TRUE + WHERE IS_SOLUTION_ONLY = FALSE + AND MENU_URL IN ( + '/admin/sysMng/subdomainList', + '/admin/userMng/companyList', + '/admin/audit-log' + ); + +-- ================================================================= +-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent) +-- ================================================================= +-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로 +-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename). + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_category' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'table_type_columns' + AND column_name = 'code_info' + ) THEN + ALTER TABLE TABLE_TYPE_COLUMNS + RENAME COLUMN CODE_CATEGORY TO CODE_INFO; + END IF; +END $$; +``` + +## 멱등성 + +- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전. +- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기. + - 신규 테넌트 DB (이미 CODE_INFO 면): no-op + - 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행 + - 둘 다 있거나 둘 다 없으면: no-op (방어적) + +## 적용 방법 + +부팅 시 자동 적용 — 별도 작업 불필요. +`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에 +위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다. + +수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전): +```bash +psql -h -U -d -f - <<'SQL' +-- 위 SQL 본문 붙여넣기 +SQL +``` + +## 검증 + +```sql +-- V023 +SELECT COLUMN_NAME FROM information_schema.columns + WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only'; +-- → 1 row + +SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO + WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log'); +-- → 모두 IS_SOLUTION_ONLY = TRUE + +-- V024 +SELECT COLUMN_NAME FROM information_schema.columns + WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info'); +-- → 1 row: code_info (code_category 는 존재하면 안 됨) +``` + +## 영향 범위 + +- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소. +- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨). +- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음. + +## 롤백 + +V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음. +만약 필요하면: +```sql +ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY; +``` ++ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert. diff --git a/db/migrations/RUN_090_MIGRATION.md b/db/migrations/RUN_090_MIGRATION.md new file mode 100644 index 00000000..aee77f8c --- /dev/null +++ b/db/migrations/RUN_090_MIGRATION.md @@ -0,0 +1,109 @@ +# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX + +작성일: 2026-05-15 +작성자: johngreen +관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환. + +## 증상 + +``` +PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification + mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType +``` + +## 원인 + +`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도 +`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음. +mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …` +구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500. + +RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가 +빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서 +실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중 +중복 키 그룹 2개 = 추가 4 row 가 그 흔적). + +## 조치 + +### (1) 중복 행 정리 + +각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서 +`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE. + +```sql +DELETE FROM TABLE_TYPE_COLUMNS + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE + ORDER BY UPDATED_DATE DESC NULLS LAST, + id::bigint DESC + ) AS rn + FROM TABLE_TYPE_COLUMNS + ) r + WHERE r.rn > 1 + ); +``` + +실측(2026-05-15) 중복: + +| DB | 중복 그룹 | 삭제될 row | +|---|---|---| +| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 | +| `siflex_invyone` | 0 | 0 | +| `test01_invyone` | 0 | 0 | +| `test02_invyone` | 0 | 0 | + +남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상). + +### (2) UNIQUE INDEX 추가 + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC + ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE); +``` + +PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이 +즉시 정상 동작. `IF NOT EXISTS` 로 멱등. + +## 적용 방법 + +부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에 +V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성 +테넌트 DB 에 차례로 실행된다. + +## 검증 + +```sql +-- 중복 없음 +SELECT COUNT(*) FROM ( + SELECT 1 FROM TABLE_TYPE_COLUMNS + GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1 +) d; +-- → 0 + +-- 인덱스 존재 +SELECT indexname FROM pg_indexes + WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc'; +-- → 1 row +``` + +브라우저 검증: +1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭 +2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다" +3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다" + +## 영향 범위 + +- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화. +- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip. +- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음. + +## 롤백 + +```sql +DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC; +``` +DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라 +복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요. diff --git a/db/migrations/RUN_091_MIGRATION.md b/db/migrations/RUN_091_MIGRATION.md new file mode 100644 index 00000000..2077c5db --- /dev/null +++ b/db/migrations/RUN_091_MIGRATION.md @@ -0,0 +1,81 @@ +# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리 + +작성일: 2026-05-16 +작성자: johngreen +관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션. + +## 배경 + +5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를 +표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만, +운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고 +프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다. + +긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로 +끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리. + +## 매핑 + +| Legacy | → | Standard | 사유 | +|---|---|---|---| +| `category` | → | `code` | commonCode 통합 의도와 일치 | +| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 | +| `radio` | → | `code` | enum 선택 | +| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) | +| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 | +| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) | +| `datetime` | → | `date` | 표준에 datetime 분리 없음 | + +## 영향 범위 (실측 2026-05-16) + +| DB | 갱신 row | +|---|---| +| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) | +| `siflex_invyone` | 0 (테이블 비어있음) | +| `test01_invyone` | 0 | +| `test02_invyone` | 0 | + +## SQL + +```sql +UPDATE TABLE_TYPE_COLUMNS + SET INPUT_TYPE = CASE INPUT_TYPE + WHEN 'category' THEN 'code' + WHEN 'select' THEN 'code' + WHEN 'radio' THEN 'code' + WHEN 'checkbox' THEN 'code' + WHEN 'boolean' THEN 'code' + WHEN 'textarea' THEN 'text' + WHEN 'datetime' THEN 'date' + END, + UPDATED_DATE = NOW() + WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime'); +``` + +## 멱등성 + +`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op. + +## 적용 방법 + +부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로 +등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다. + +## 검증 + +```sql +-- 화이트리스트 밖 row 0 이어야 함 +SELECT input_type, COUNT(*) FROM table_type_columns + WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image') + GROUP BY 1; +-- → 0 rows +``` + +## 후속 cleanup (별도 PR 거리) + +본 마이그레이션이 모든 환경에 한 번 적용된 다음에는: +1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거. +2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도). +3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리. + +이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후. diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 2167a3b9..2f54093d 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -64,6 +65,7 @@ import { import { getCompanyList } from "@/lib/api/company"; import { useAuth } from "@/hooks/useAuth"; import { Company } from "@/types/company"; +import { isManagementHost } from "@/lib/tenant/subdomain"; const RESOURCE_TYPE_CONFIG: Record< string, @@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map { } export default function AuditLogPage() { + const router = useRouter(); + const [hostBlocked, setHostBlocked] = useState(false); + useEffect(() => { + if (typeof window === "undefined") return; + if (!isManagementHost(window.location.hostname)) { + setHostBlocked(true); + router.replace("/main"); + } + }, [router]); + const { user } = useAuth(); const isSuperAdmin = user?.company_code === "*"; @@ -393,6 +405,8 @@ export default function AuditLogPage() { setDetailOpen(true); }; + if (hostBlocked) return null; + return (
diff --git a/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx index 3060af8f..c2943e9a 100644 --- a/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx +++ b/frontend/app/(main)/admin/sysMng/subdomainList/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; import { getCompaniesStats } from "@/lib/api/provisioning"; @@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio import Wizard from "@/components/admin/provisioning/wizard/Wizard"; import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer"; import { toCsvString, downloadCsv } from "@/lib/csvExport"; +import { isManagementHost } from "@/lib/tenant/subdomain"; const PAGE_SIZE = 10; @@ -18,8 +20,22 @@ const PAGE_SIZE = 10; * * 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름. * 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용. + * + * 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능. + * 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트. + * 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절. */ export default function SubdomainListPage() { + const router = useRouter(); + const [hostBlocked, setHostBlocked] = useState(false); + useEffect(() => { + if (typeof window === "undefined") return; + if (!isManagementHost(window.location.hostname)) { + setHostBlocked(true); + router.replace("/main"); + } + }, [router]); + const [openKey, setOpenKey] = useState(null); const [q, setQ] = useState(""); const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all"); @@ -51,6 +67,7 @@ export default function SubdomainListPage() { const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({ queryKey: ["companies-stats"], queryFn: getCompaniesStats, + enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect refetchInterval: (query) => { // provisioning 중인 회사 있으면 3초 폴링, 없으면 30초 const hasProvisioning = Array.isArray(query.state.data) @@ -95,6 +112,12 @@ export default function SubdomainListPage() { const provisCount = rows.filter((r) => r.db_status === "provisioning").length; const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length; + // 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면. + // 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단. + if (hostBlocked) { + return null; + } + return (
- {/* 3패널 메인 */} -
+ {/* 메인 (우측 패널은 overlay 라 2패널 layout) */} +
{/* 좌측: 테이블 목록 (240px) */}
{/* 검색 */} @@ -1401,7 +1402,7 @@ export default function TableManagementPage() { />
{isSuperAdmin && ( -
+
{table.display_name || table.table_name}
-
+
{table.table_name}
@@ -1551,26 +1552,24 @@ export default function TableManagementPage() { className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight" /> ) : ( -
{ - setEditingHeaderValue(tableLabel); - setEditingHeaderField("label"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
+ + {tableLabel || ( + {selectedTable} + )} + +
)} {/* table_name (코드, 편집 불가) */} @@ -1596,26 +1595,24 @@ export default function TableManagementPage() { className="mt-1 h-7 -mx-2 px-2 text-xs" /> ) : ( -
{ - setEditingHeaderValue(tableDescription); - setEditingHeaderField("description"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); +
+ + {tableDescription || ( + + 설명 추가 + )} + +
)}
@@ -1655,7 +1652,7 @@ export default function TableManagementPage() { setSelectedColumn((prev) => (prev === c ? null : c))} onColumnChange={(columnName, field, value) => { if (field === "is_unique") { const currentColumn = columns.find((c) => c.column_name === columnName); @@ -1690,10 +1687,14 @@ export default function TableManagementPage() { )}
- {/* 우측: 상세 패널 (selectedColumn 있을 때만) */} - {selectedColumn && ( -
- + c.column_name === selectedColumn) ?? null} tables={tables} referenceTableColumns={referenceTableColumns} @@ -1719,8 +1720,7 @@ export default function TableManagementPage() { codeInfoOptions={commonCodeOptions} referenceTableOptions={referenceTableOptions} /> -
- )} +
{/* DDL 모달 컴포넌트들 */} diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx index a36cd9c3..b0828d94 100644 --- a/frontend/app/(main)/admin/userMng/companyList/page.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx @@ -1,5 +1,8 @@ "use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { isManagementHost } from "@/lib/tenant/subdomain"; import { useCompanyManagement } from "@/hooks/useCompanyManagement"; import { CompanyToolbar } from "@/components/admin/CompanyToolbar"; import { CompanyTable } from "@/components/admin/CompanyTable"; @@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; * 모든 회사 관리 기능을 통합하여 제공 */ export default function CompanyPage() { + const router = useRouter(); + const [hostBlocked, setHostBlocked] = useState(false); + useEffect(() => { + if (typeof window === "undefined") return; + if (!isManagementHost(window.location.hostname)) { + setHostBlocked(true); + router.replace("/main"); + } + }, [router]); + const { // 데이터 companies, @@ -51,6 +64,8 @@ export default function CompanyPage() { clearError, } = useCompanyManagement(); + if (hostBlocked) return null; + return (
diff --git a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx index 4b2c4c4c..e0b3856f 100644 --- a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx +++ b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import * as XLSX from "xlsx"; import { ArrowDownToLine, ArrowUpToLine, Building2, + CheckCircle2, ChevronDown, ChevronRight, ChevronUp, @@ -12,6 +14,7 @@ import { ChevronsUpDown, Eye, EyeOff, + FileDown, Folder, FolderOpen, FolderTree, @@ -28,6 +31,7 @@ import { Upload, Users, X, + XCircle, } from "lucide-react"; import { DropdownMenu, @@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/hooks/useAuth"; import { cn } from "@/lib/utils"; @@ -152,11 +158,15 @@ export default function DeptMngListPage() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null); - // ── 일괄등록 / 변경이력 모달 ───────────────────────── + // ── 일괄등록 / 일괄업데이트 모달 ───────────────────── const [bulkOpen, setBulkOpen] = useState(false); - const [bulkText, setBulkText] = useState(""); - const [bulkUploading, setBulkUploading] = useState(false); - const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]); + const [bulkTab, setBulkTab] = useState<"create" | "update">("create"); + const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department"); + const [bulkRows, setBulkRows] = useState[]>([]); + const [bulkPreviewRows, setBulkPreviewRows] = useState([]); + const [bulkSelected, setBulkSelected] = useState>(new Set()); + const [bulkBusy, setBulkBusy] = useState(false); + const [bulkFileName, setBulkFileName] = useState(""); // ── 트리 ⋮ 메뉴: 이동/삭제 대상 ─────────────────────── const [moveTargetDept, setMoveTargetDept] = useState(null); @@ -611,6 +621,251 @@ export default function DeptMngListPage() { } }; + // ───────────────────────────────────────────────────── + // 일괄등록 / 일괄업데이트 helpers + // ───────────────────────────────────────────────────── + + const BULK_HEADERS_CREATE: Record = { + "부서명": "dept_name", + "상위부서코드": "parent_dept_code", + "부서유형": "dept_type", + "약칭": "short_name", + "조직체계": "org_system", + "정렬순서": "sort_order", + "사용여부": "status", + "시작일": "start_date", + "종료일": "end_date", + "결재관리자": "approval_managers", + "부서관리자": "dept_managers", + "조직장": "org_leaders", + }; + const BULK_HEADERS_UPDATE_DEPT: Record = { + "부서코드": "dept_code", + "부서명": "dept_name", + "상위부서코드": "parent_dept_code", + "부서유형": "dept_type", + "약칭": "short_name", + "조직체계": "org_system", + "정렬순서": "sort_order", + "사용여부": "status", + "시작일": "start_date", + "종료일": "end_date", + }; + const BULK_HEADERS_UPDATE_MGR: Record = { + "부서코드": "dept_code", + "결재관리자": "approval_managers", + "부서관리자": "dept_managers", + "조직장": "org_leaders", + }; + const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]); + + const currentHeaderMap = () => + bulkTab === "create" + ? BULK_HEADERS_CREATE + : bulkUpdateMode === "department" + ? BULK_HEADERS_UPDATE_DEPT + : BULK_HEADERS_UPDATE_MGR; + + const currentBulkAction = (): departmentAPI.BulkAction => + bulkTab === "create" + ? "create" + : bulkUpdateMode === "department" + ? "update_department" + : "update_manager"; + + const resetBulkData = useCallback(() => { + setBulkRows([]); + setBulkPreviewRows([]); + setBulkSelected(new Set()); + setBulkFileName(""); + }, []); + + const openBulkModal = () => { + if (!selectedCompanyCode) { + toast({ title: "회사를 먼저 선택하세요", variant: "destructive" }); + return; + } + setBulkTab("create"); + setBulkUpdateMode("department"); + resetBulkData(); + setBulkOpen(true); + }; + + /** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */ + const downloadBulkTemplate = () => { + const action = currentBulkAction(); + const headerMap = + action === "create" + ? BULK_HEADERS_CREATE + : action === "update_department" + ? BULK_HEADERS_UPDATE_DEPT + : BULK_HEADERS_UPDATE_MGR; + const columns = Object.keys(headerMap); + const example: Record = {}; + columns.forEach((c) => { + const snake = headerMap[c]; + if (snake === "dept_name") example[c] = "경영지원본부"; + else if (snake === "dept_code") example[c] = "DEPT_1"; + else if (snake === "dept_type") example[c] = "dept"; + else if (snake === "status") example[c] = "active"; + else if (snake === "sort_order") example[c] = 10; + else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : ""; + else example[c] = ""; + }); + const ws = XLSX.utils.json_to_sheet([example], { header: columns }); + ws["!cols"] = columns.map(() => ({ wch: 16 })); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "부서"); + const fileName = + action === "create" + ? "부서_일괄등록_템플릿.xlsx" + : action === "update_department" + ? "부서정보_일괄업데이트_템플릿.xlsx" + : "부서관리자_일괄업데이트_템플릿.xlsx"; + XLSX.writeFile(wb, fileName); + }; + + /** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */ + const handleBulkFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setBulkFileName(file.name); + try { + const buf = await file.arrayBuffer(); + const wb = XLSX.read(buf, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const raw = XLSX.utils.sheet_to_json>(ws, { defval: "" }); + const headerMap = currentHeaderMap(); + const rows = raw + .map((row) => { + const out: Record = {}; + for (const [korean, snake] of Object.entries(headerMap)) { + const v = row[korean]; + if (v === undefined || v === null || v === "") continue; + if (MANAGER_KEYS.has(snake)) { + const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean); + if (ids.length > 0) out[snake] = ids; + } else if (snake === "sort_order") { + const n = Number(v); + if (!Number.isNaN(n)) out[snake] = n; + } else { + out[snake] = String(v).trim(); + } + } + return out; + }) + .filter((r) => Object.keys(r).length > 0); + setBulkRows(rows); + setBulkPreviewRows([]); + setBulkSelected(new Set()); + toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." }); + } catch (err: any) { + toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" }); + } finally { + // 동일 파일 재선택 가능하도록 + e.target.value = ""; + } + }; + + const handleBulkPreview = async () => { + if (bulkRows.length === 0) return; + setBulkBusy(true); + try { + const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows); + if (res.success && (res as any).data) { + const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows; + setBulkPreviewRows(rows); + // 기본: ok 인 row 만 선택 + setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index))); + } else { + toast({ + title: "미리보기 실패", + description: (res as any).error || (res as any).message || "오류", + variant: "destructive", + }); + } + } finally { + setBulkBusy(false); + } + }; + + const handleBulkApply = async () => { + const okSelected = bulkPreviewRows.filter( + (r) => bulkSelected.has(r.row_index) && r.result === "ok", + ); + if (okSelected.length === 0) { + toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" }); + return; + } + const payload = okSelected.map((r) => { + const { row_index, result, error_detail, ...rest } = r as any; + return rest as Record; + }); + setBulkBusy(true); + try { + const res = + bulkTab === "create" + ? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload) + : await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload); + if (res.success) { + const count = + (res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length; + toast({ + title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료", + description: `${count}건 처리됨`, + }); + setBulkOpen(false); + resetBulkData(); + await loadDepartments(); + } else { + toast({ + title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패", + description: (res as any).error || (res as any).message || "오류", + variant: "destructive", + }); + } + } finally { + setBulkBusy(false); + } + }; + + const previewColumns = useMemo(() => { + if (bulkTab === "create") { + return [ + { key: "dept_name", label: "부서명" }, + { key: "parent_dept_code", label: "상위부서코드" }, + { key: "dept_type", label: "유형" }, + { key: "sort_order", label: "순서" }, + { key: "approval_managers", label: "결재관리자", manager: true }, + { key: "dept_managers", label: "부서관리자", manager: true }, + { key: "org_leaders", label: "조직장", manager: true }, + ]; + } + if (bulkUpdateMode === "department") { + return [ + { key: "dept_code", label: "부서코드" }, + { key: "dept_name", label: "부서명" }, + { key: "parent_dept_code", label: "상위부서코드" }, + { key: "dept_type", label: "유형" }, + { key: "sort_order", label: "순서" }, + ]; + } + return [ + { key: "dept_code", label: "부서코드" }, + { key: "approval_managers", label: "결재관리자", manager: true }, + { key: "dept_managers", label: "부서관리자", manager: true }, + { key: "org_leaders", label: "조직장", manager: true }, + ]; + }, [bulkTab, bulkUpdateMode]); + + const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length; + const bulkErrCount = bulkPreviewRows.length - bulkOkCount; + const allOkSelected = + bulkOkCount > 0 && + bulkPreviewRows + .filter((r) => r.result === "ok") + .every((r) => bulkSelected.has(r.row_index)); + const isDirty = originalDraft ? JSON.stringify(originalDraft) !== JSON.stringify(draft) : isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null); @@ -636,14 +891,7 @@ export default function DeptMngListPage() { variant="outline" size="sm" className="h-8 gap-1.5 text-xs" - onClick={() => { - if (!selectedCompanyCode) { - toast({ title: "회사를 먼저 선택하세요", variant: "destructive" }); - return; - } - setBulkText(""); - setBulkOpen(true); - }} + onClick={openBulkModal} > 일괄등록 @@ -1013,106 +1261,229 @@ export default function DeptMngListPage() { title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"} /> - {/* 일괄등록 */} + {/* 일괄등록 / 일괄업데이트 */} - + - 부서 일괄등록 + 부서 일괄등록 / 일괄업데이트 -
-
-

CSV 형식으로 한 줄에 하나씩 입력하세요

-

- 형식: 부서명,상위부서코드,부서유형(dept|team|temp) -

-

부서코드는 저장 시 자동 부여됩니다 (DEPT_n).

-

예시: 경영지원본부,,dept

-
-