package com.erp.crosstenant; import com.erp.dto.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * SUPER_ADMIN 의 cross-tenant 어드민 합산 엔드포인트. * * 모든 엔드포인트는 진입 시 두 가드를 통과해야 한다: * 1. JWT role == "SUPER_ADMIN" → 미통과 시 403 * 2. 현재 컨텍스트 == META DB → 미통과 시 400 * 통과 후 {@link CrossTenantAggregator#fanOut} 으로 회사 N 개에 fan-out. * * Phase A (2026-04-27): 인프라만. 스모크 테스트용 {@code /_active-companies} 엔드포인트 1개. * Phase B 부터 {@code /users}, {@code /menus} ... 14개 메뉴 fan-out 엔드포인트 추가 예정. * * 라우팅 규약: admin.invyone.com 또는 메인 도메인(메타 컨텍스트) 에서만 호출 가능. */ @RestController @RequestMapping("/api/admin/cross-tenant") @RequiredArgsConstructor @Slf4j public class CrossTenantController { /** * 회사당 cap 디폴트. cross-tenant 는 "전사 둘러보기" 용이라 정확한 페이지네이션 불필요 — * 200건 넘으면 검색으로 좁히거나 회사 도메인 단일 모드로 전환하는 게 본 설계의 의도. * 호출자가 ?per_company_limit= 으로 override 가능. */ private static final int DEFAULT_PER_COMPANY_LIMIT = 200; private static final int MAX_PER_COMPANY_LIMIT = 2000; // 안전 가드 private final CrossTenantAggregator aggregator; /** * Phase A 스모크 테스트 엔드포인트. * * 가드 두 개 통과 후 메타 DB 의 {@code COMPANY_MNG} 에서 활성 회사 목록 반환. * Aggregator 의 fan-out 자체는 호출하지 않음 — Phase B 에서 첫 mapper (listUsers) 추가 시 활성화. * * 검증 항목: * - 컨트롤러 라우팅 정상 * - SUPER_ADMIN 가드 (다른 role 이면 403) * - META 컨텍스트 가드 (회사 도메인이면 400) * - {@code provisioning.listActiveCompanies} mapper 등록 확인 */ @GetMapping("/_active-companies") public ResponseEntity>> activeCompaniesSmoke(HttpServletRequest request) { if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); } if (!CrossTenantContext.isMetaContext()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI())); } List> rows = aggregator.listActiveCompaniesForSmokeTest(); Map data = new LinkedHashMap<>(); data.put("rows", rows); data.put("total", rows.size()); return ResponseEntity.ok(ApiResponse.success(data, "Phase A smoke test ok")); } /** * GET /api/admin/cross-tenant/users * * 활성 회사 N 개에 fan-out 으로 사용자 목록 합산. * 응답 행마다 {@code company_code} 박혀있어 화면 측에서 회사 컬럼/필터 가능. * * 지원 파라미터: search, status, dept_code (단일 회사 화면과 동일). * 페이지네이션은 1차 구현에선 비지원 — 회사당 전체 반환 후 클라이언트에서 페이지네이션 (설계서 §9.3). * * 회사 한 곳이 실패해도 나머지는 반환 (실패 격리). 응답에 {@code companies_failed} + * 헤더 {@code X-CrossTenant-Failed} 로 어떤 회사가 빠졌는지 명시. */ @GetMapping("/users") public ResponseEntity>> listUsers( HttpServletRequest request, @RequestParam Map queryParams) { if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); } if (!CrossTenantContext.isMetaContext()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI())); } // search 파라미터 ILIKE 패턴화 — admin.selectUserList 와 동일한 변환 규칙 (양쪽 % 감쌈) Map params = new HashMap<>(queryParams); Object rawSearch = params.get("search"); if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) { params.put("search", "%" + rawSearch + "%"); } int perCompanyLimit = resolvePerCompanyLimit(params); params.put("per_company_limit_plus_one", perCompanyLimit + 1); // ★ /users 는 includeMeta=true — 메타 DB 의 SUPER_ADMIN 들도 함께 반환 (company_code='*'). // SUPER_ADMIN 자기 자신들도 어디선가 관리되어야 한다는 요구. roles/batches/lang-keys 는 메타에 의미있는 데이터 없으니 false. CrossTenantAggregator.Result result = aggregator.fanOut( "admin-cross-tenant.listUsers", params, perCompanyLimit, true); return buildResponse(result, perCompanyLimit); } /** * GET /api/admin/cross-tenant/roles * * 활성 회사 N 개에 fan-out 으로 권한 그룹 목록 합산. * 단일 회사 GET /api/roles 와 동일한 컬럼 + 행마다 company_code 추가. * * 지원 파라미터: search (AUTH_NAME ILIKE 검색). */ @GetMapping("/roles") public ResponseEntity>> listRoles( HttpServletRequest request, @RequestParam Map queryParams) { return runFanOut(request, queryParams, "admin-cross-tenant.listRoleGroups", true); } /** * GET /api/admin/cross-tenant/batches * * 활성 회사 N 개에 fan-out 으로 배치 목록 합산. 행마다 company_code 박힘. * 지원 파라미터: search, is_active. */ @GetMapping("/batches") public ResponseEntity>> listBatches( HttpServletRequest request, @RequestParam Map queryParams) { // 배치는 search 를 SQL 안에서 '%' || #{search} || '%' 로 감싸므로 컨트롤러 변환 불필요 return runFanOut(request, queryParams, "admin-cross-tenant.listBatches", false); } /** * GET /api/admin/cross-tenant/lang-keys * * 활성 회사 N 개에 fan-out 으로 다국어 키 목록 합산. 행마다 company_code 박힘. * 지원 파라미터: search, menu_code. */ @GetMapping("/lang-keys") public ResponseEntity>> listLangKeys( HttpServletRequest request, @RequestParam Map queryParams) { return runFanOut(request, queryParams, "admin-cross-tenant.listLangKeys", true); } /** * 공통 fan-out 실행 헬퍼. * * @param wrapSearchWithPercent true 면 search 파라미터를 컨트롤러에서 % 로 감쌈 * (mapper 가 그대로 ILIKE 에 넣는 방식 — listUsers, listRoleGroups, listLangKeys). * false 면 mapper 안에서 '%' || ? || '%' 로 직접 감싸는 케이스 (listBatches). */ private ResponseEntity>> runFanOut( HttpServletRequest request, Map queryParams, String mapperId, boolean wrapSearchWithPercent) { if (!CrossTenantContext.isSuperAdmin(request)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResponse.error("super_admin_required", request.getRequestURI())); } if (!CrossTenantContext.isMetaContext()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error("cross_tenant_requires_meta_context", request.getRequestURI())); } Map params = new HashMap<>(queryParams); if (wrapSearchWithPercent) { Object rawSearch = params.get("search"); if (rawSearch != null && !String.valueOf(rawSearch).isBlank()) { params.put("search", "%" + rawSearch + "%"); } } int perCompanyLimit = resolvePerCompanyLimit(params); params.put("per_company_limit_plus_one", perCompanyLimit + 1); CrossTenantAggregator.Result result = aggregator.fanOut(mapperId, params, perCompanyLimit); return buildResponse(result, perCompanyLimit); } /** * 회사당 cap 결정 — 쿼리 파라미터 {@code per_company_limit} 가 있으면 사용 (1~MAX 사이 클램프), * 없으면 디폴트 200. */ private int resolvePerCompanyLimit(Map params) { Object raw = params.get("per_company_limit"); if (raw == null) return DEFAULT_PER_COMPANY_LIMIT; try { int v = Integer.parseInt(String.valueOf(raw)); if (v < 1) return DEFAULT_PER_COMPANY_LIMIT; if (v > MAX_PER_COMPANY_LIMIT) return MAX_PER_COMPANY_LIMIT; return v; } catch (NumberFormatException e) { return DEFAULT_PER_COMPANY_LIMIT; } } /** * Aggregator 결과 → 응답 봉투. truncated 정보 포함. * 응답 헤더에도 X-CrossTenant-Failed / X-CrossTenant-Truncated 박아 디버깅 편의. */ private ResponseEntity>> buildResponse( CrossTenantAggregator.Result result, int perCompanyLimit) { Map data = new LinkedHashMap<>(); data.put("rows", result.rows); data.put("total", result.rows.size()); data.put("companies_queried", result.companies_queried); data.put("companies_failed", result.companies_failed); data.put("truncated", !result.truncated_company_codes.isEmpty()); data.put("truncated_company_codes", result.truncated_company_codes); data.put("per_company_limit", perCompanyLimit); ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); if (!result.failed_company_codes.isEmpty()) { builder.header("X-CrossTenant-Failed", String.join(",", result.failed_company_codes)); } if (!result.truncated_company_codes.isEmpty()) { builder.header("X-CrossTenant-Truncated", String.join(",", result.truncated_company_codes)); } return builder.body(ApiResponse.success(data)); } }