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..e24252c9 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; @@ -49,11 +51,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..b5035e32 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,13 @@ 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" ); @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/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..38af0239 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -187,6 +187,9 @@ AND MENU.COMPANY_CODE = #{company_code} + + AND MENU.IS_SOLUTION_ONLY = FALSE + UNION ALL @@ -212,6 +215,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/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index c0ae89e2..5c234f67 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 (
{ + 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

-
-