e16fb16987
SUPER_ADMIN 토큰(company_code=*)이면 등록 회사들 DB 를 순회해 결과를 집계해 돌려주는 CrossTenantAggregator/Controller 추가. 사용자/권한그룹/ 배치/다국어 키 4개 도메인의 list API 가 cross-tenant 모드 지원. UserTable + ResponsiveDataView 에 compact/scrollContainer prop 추가. 페이지 헤더/툴바/페이지네이션은 고정, 테이블만 자체 스크롤. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
11 KiB
Java
243 lines
11 KiB
Java
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<ApiResponse<Map<String, Object>>> 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<Map<String, Object>> rows = aggregator.listActiveCompaniesForSmokeTest();
|
|
|
|
Map<String, Object> 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<ApiResponse<Map<String, Object>>> listUsers(
|
|
HttpServletRequest request,
|
|
@RequestParam Map<String, Object> 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<String, Object> 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<ApiResponse<Map<String, Object>>> listRoles(
|
|
HttpServletRequest request,
|
|
@RequestParam Map<String, Object> 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<ApiResponse<Map<String, Object>>> listBatches(
|
|
HttpServletRequest request,
|
|
@RequestParam Map<String, Object> 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<ApiResponse<Map<String, Object>>> listLangKeys(
|
|
HttpServletRequest request,
|
|
@RequestParam Map<String, Object> 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<ApiResponse<Map<String, Object>>> runFanOut(
|
|
HttpServletRequest request,
|
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<ApiResponse<Map<String, Object>>> buildResponse(
|
|
CrossTenantAggregator.Result result, int perCompanyLimit) {
|
|
Map<String, Object> 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));
|
|
}
|
|
}
|