Files
invyone/backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java
T
hjjeong e16fb16987 어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤
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>
2026-04-28 17:52:30 +09:00

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));
}
}