package com.erp.provisioning; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 회사 관리 화면 라이프사이클 엔드포인트. * * 프로비저닝(생성) 은 {@link ProvisioningController} 가 담당. 이 컨트롤러는 그 이후의 * 관리 액션 — 비활성/재활성/삭제, 관리자 계정 관리, 구성원 조회, 템플릿 재복제, 감사 로그. * * 전부 SUPER_ADMIN 전용 ({@link SuperAdminGuard}). */ @RestController @RequestMapping("/api/admin/provisioning") @RequiredArgsConstructor @Slf4j public class CompanyMgmtController { private final SuperAdminGuard guard; private final CompanyLifecycleService lifecycleService; private final CompanyAdminService adminService; private final CompanyMembersService membersService; private final CompanyTemplatesService templatesService; private final CompanyAuditLogService auditLogService; @Value("${spring.datasource.url}") private String metaJdbcUrl; // ─────────────── 관리자 계정 ─────────────── @GetMapping("/companies/{companyCode}/admin") public ResponseEntity> getAdmin(HttpServletRequest req, @PathVariable String companyCode) { guard.enforce(req); Map company = lifecycleService.getCompany(companyCode); if (company == null) return ResponseEntity.notFound().build(); String dbName = (String) company.get("db_name"); Map admin = adminService.getAdmin(dbName); admin.put("company_code", companyCode); admin.put("db_name", dbName); return ResponseEntity.ok(admin); } @PostMapping("/companies/{companyCode}/admin/reset-password") public ResponseEntity> resetAdminPassword(HttpServletRequest req, @PathVariable String companyCode) { guard.enforce(req); Map company = lifecycleService.getCompany(companyCode); if (company == null) return ResponseEntity.notFound().build(); String dbName = (String) company.get("db_name"); Map result = adminService.resetAdminPassword(dbName); String actor = guard.actorUserId(req); if (result.containsKey("error")) { auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET, dbName, Map.of("error_kind", result.get("error")), (String) result.get("error")); return ResponseEntity.status(500).body(result); } auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET, (String) result.get("admin_user_id"), Map.of("db_name", dbName)); return ResponseEntity.ok() .header("Cache-Control", "no-store") .header("Pragma", "no-cache") .body(result); } // ─────────────── 구성원 ─────────────── @GetMapping("/companies/{companyCode}/members") public ResponseEntity> listMembers(HttpServletRequest req, @PathVariable String companyCode) { guard.enforce(req); Map company = lifecycleService.getCompany(companyCode); if (company == null) return ResponseEntity.notFound().build(); String dbName = (String) company.get("db_name"); List> members = membersService.listMembers(dbName); Map out = new LinkedHashMap<>(); out.put("company_code", companyCode); out.put("db_name", dbName); out.put("members", members); out.put("total", members.size()); return ResponseEntity.ok(out); } // ─────────────── 템플릿 ─────────────── @GetMapping("/companies/{companyCode}/installed-groups") public ResponseEntity>> installedGroups(HttpServletRequest req, @PathVariable String companyCode) { guard.enforce(req); return ResponseEntity.ok(templatesService.listInstalledGroups(companyCode)); } @PostMapping("/companies/{companyCode}/re-copy") public ResponseEntity> recopyTemplates(HttpServletRequest req, @PathVariable String companyCode, @RequestBody Map body) { guard.enforce(req); Map company = lifecycleService.getCompany(companyCode); if (company == null) return ResponseEntity.notFound().build(); String dstDb = (String) company.get("db_name"); String srcDb = resolveMetaDbName(); @SuppressWarnings("unchecked") List groups = (List) body.getOrDefault("selected_groups", List.of()); if (groups.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "selected_groups required")); } Map result = templatesService.recopy(srcDb, dstDb, groups); String actor = guard.actorUserId(req); List errors = (List) result.get("errors"); if (errors != null && !errors.isEmpty()) { auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY, dstDb, Map.of("selected_groups", groups, "errors", errors), "partial_failure: " + errors.size() + " table(s)"); } else { auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY, dstDb, Map.of("selected_groups", groups, "total_inserted", result.get("total_inserted"))); } return ResponseEntity.ok(result); } // ─────────────── 라이프사이클 ─────────────── @PatchMapping("/companies/{companyCode}/status") public ResponseEntity> changeStatus(HttpServletRequest req, @PathVariable String companyCode, @RequestBody Map body) { guard.enforce(req); String target = (String) body.get("status"); String reason = (String) body.get("reason"); if (target == null) { return ResponseEntity.badRequest().body(Map.of("error", "status required")); } String actor = guard.actorUserId(req); Map result; String action; if ("suspended".equals(target)) { result = lifecycleService.deactivate(companyCode, reason); action = CompanyAuditLogService.ACTION_DEACTIVATE; } else if ("active".equals(target)) { result = lifecycleService.reactivate(companyCode); action = CompanyAuditLogService.ACTION_REACTIVATE; } else { return ResponseEntity.badRequest().body(Map.of("error", "unsupported status: " + target)); } if (result.containsKey("error")) { auditLogService.logFailure(companyCode, actor, action, null, Map.of("target_status", target, "reason", reason == null ? "" : reason), String.valueOf(result.get("error"))); return ResponseEntity.badRequest().body(result); } auditLogService.log(companyCode, actor, action, (String) result.get("subdomain"), Map.of("target_status", target, "reason", reason == null ? "" : reason)); return ResponseEntity.ok(result); } /** * 영구 삭제. 안전장치: 요청 본문에 {@code confirm_subdomain} 을 담아 보내야 함 (프론트 타이핑 확인). * 이 값이 현재 서브도메인과 정확히 일치해야 실행. */ @DeleteMapping("/companies/{companyCode}") public ResponseEntity> deleteCompany(HttpServletRequest req, @PathVariable String companyCode, @RequestBody(required = false) Map body) { guard.enforce(req); Map company = lifecycleService.getCompany(companyCode); if (company == null) return ResponseEntity.notFound().build(); String currentSub = (String) company.get("subdomain"); String confirmSub = body == null ? null : (String) body.get("confirm_subdomain"); if (confirmSub == null || !confirmSub.equals(currentSub)) { return ResponseEntity.status(400).body(Map.of( "error", "confirmation_mismatch", "message", "confirm_subdomain 이 실제 서브도메인과 일치해야 함")); } String actor = guard.actorUserId(req); Map result = lifecycleService.delete(companyCode); auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_DELETE, (String) result.get("db_name"), Map.of("subdomain", result.get("subdomain"), "meta_rows_removed", result.get("meta_rows_removed"))); return ResponseEntity.ok(result); } // ─────────────── 감사 로그 ─────────────── @GetMapping("/companies/{companyCode}/audit-log") public ResponseEntity> companyAuditLog(HttpServletRequest req, @PathVariable String companyCode, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "50") int limit) { guard.enforce(req); return ResponseEntity.ok(auditLogService.listByCompany(companyCode, page, limit)); } @GetMapping("/audit-log") public ResponseEntity> globalAuditLog(HttpServletRequest req, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "50") int limit, @RequestParam(required = false) String action) { guard.enforce(req); return ResponseEntity.ok(auditLogService.listAll(page, limit, action)); } // ─────────────── helpers ─────────────── /** jdbc:postgresql://host:port/{db_name}?... 에서 db_name 추출. */ private String resolveMetaDbName() { int slash = metaJdbcUrl.lastIndexOf('/'); if (slash < 0) return "invyone"; String tail = metaJdbcUrl.substring(slash + 1); int q = tail.indexOf('?'); return q < 0 ? tail : tail.substring(0, q); } }