Merge pull request 'hjjeong' (#3) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m38s
Build & Deploy to K8s / build-and-deploy (push) Successful in 5m38s
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
# direnv (per-developer JAVA_HOME / shell env)
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
# OMC (oh-my-claudecode) 작업용 임시 상태 — 절대 추적 금지
|
||||
# planning, autopilot state, agent transcript, project memory 등 포함
|
||||
.omc/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
21
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import com.erp.tenant.TenantDataSourceFactory;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
import com.erp.tenant.TenantRoutingDataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 어드민 14개 메뉴 전사 합산용 fan-out 집계기.
|
||||
*
|
||||
* 호출자(컨트롤러)는 {@link #fanOut(String, Map)} 에 cross-tenant 전용 mapper id 와
|
||||
* 파라미터를 넘긴다. Aggregator 가 메타 DB 에서 활성 회사 목록을 가져온 뒤,
|
||||
* 회사마다 {@link DbContextHolder} 를 잠깐 그 회사 DB 로 바꿔 같은 SELECT 를 돌리고,
|
||||
* 모든 응답 행에 {@code company_code} 를 박아 머지한다.
|
||||
*
|
||||
* 핵심 원칙 (설계서 §3, §5.3):
|
||||
* - 한 회사 실패해도 전체는 진행 (fail-open). 실패 카운트만 누적.
|
||||
* - 모든 행에 {@code company_code} 추가 (응답 측에서 회사 필터/그룹 가능하도록).
|
||||
* - SELECT 만. UPDATE/DELETE 는 회사 도메인 컨텍스트로 위임.
|
||||
* - 풀 정책 무변경 — 회사 풀 {@code minIdle=0} 그대로 유지.
|
||||
*
|
||||
* 1차 구현은 직렬·캐시 OFF (설계서 §7.2 — N≤20 까진 충분).
|
||||
*
|
||||
* @see com.erp.crosstenant.CrossTenantContext
|
||||
* @see com.erp.tenant.TenantRoutingDataSource
|
||||
* @see com.erp.provisioning.CompanyStatsService // raw JDBC 1세대 패턴 (참고용)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantAggregator {
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
|
||||
/** Aggregator 결과 봉투. 컨트롤러는 이걸 그대로 ApiResponse.data 로 감싸 반환. */
|
||||
public static class Result {
|
||||
public final List<Map<String, Object>> rows;
|
||||
public final int companies_queried;
|
||||
public final int companies_failed;
|
||||
public final List<String> failed_company_codes;
|
||||
public final List<String> truncated_company_codes;
|
||||
|
||||
public Result(List<Map<String, Object>> rows,
|
||||
int companies_queried,
|
||||
int companies_failed,
|
||||
List<String> failed_company_codes,
|
||||
List<String> truncated_company_codes) {
|
||||
this.rows = rows;
|
||||
this.companies_queried = companies_queried;
|
||||
this.companies_failed = companies_failed;
|
||||
this.failed_company_codes = failed_company_codes;
|
||||
this.truncated_company_codes = truncated_company_codes;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getRows() { return rows; }
|
||||
public int getCompanies_queried() { return companies_queried; }
|
||||
public int getCompanies_failed() { return companies_failed; }
|
||||
public List<String> getFailed_company_codes() { return failed_company_codes; }
|
||||
public List<String> getTruncated_company_codes() { return truncated_company_codes; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out (cap 없음, 메타 포함 X).
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params) {
|
||||
return fanOut(mapperId, params, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out + 회사당 cap (메타 포함 X).
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit) {
|
||||
return fanOut(mapperId, params, perCompanyLimit, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 회사 N 개에 fan-out + 회사당 cap + 옵션으로 메타 DB 도 같이 조회.
|
||||
* 호출자는 미리 {@link CrossTenantContext#requireSuperAdmin}, {@link CrossTenantContext#requireMetaContext}
|
||||
* 통과를 확인해야 한다 (Aggregator 자체는 권한 가드 안 함 — 단일 책임).
|
||||
*
|
||||
* cap 동작:
|
||||
* - mapper 가 {@code LIMIT #{per_company_limit_plus_one}} 으로 cap+1 만큼 가져옴.
|
||||
* - Aggregator 가 회사별로 rows.size() > cap 인지 검사 → 초과면 잘라 cap 까지만 반환 + truncated 마킹.
|
||||
*
|
||||
* 메타 포함 (includeMeta=true):
|
||||
* - 회사 fan-out 전에 메타 DB 에서 같은 mapper 한 번 더 실행.
|
||||
* - 메타 행에는 {@code company_code='*'} 박힘 (시스템/공통 사용자 표시 — 기존 회사관리 화면 컨벤션과 동일).
|
||||
* - 사용자관리처럼 "SUPER_ADMIN 도 관리 대상"인 도메인에서만 사용. roles/batches 등은 메타에 의미있는 데이터 없으니 false.
|
||||
*
|
||||
* @param mapperId cross-tenant 전용 mapper id
|
||||
* @param params SQL 바인딩 파라미터
|
||||
* @param perCompanyLimit 회사당 cap. null 이면 cap 없음
|
||||
* @param includeMeta true 면 메타 DB 도 한 번 조회해서 결과 앞에 prepend (company_code='*')
|
||||
* @return fan-out 결과
|
||||
*/
|
||||
public Result fanOut(String mapperId, Map<String, Object> params, Integer perCompanyLimit, boolean includeMeta) {
|
||||
// 호출 직전 컨텍스트는 META 여야 한다 (CrossTenantContext.requireMetaContext 통과 후라고 가정).
|
||||
List<Map<String, Object>> companies = sqlSession.selectList("provisioning.listActiveCompanies");
|
||||
|
||||
List<Map<String, Object>> mergedRows = new ArrayList<>();
|
||||
List<String> failed = new ArrayList<>();
|
||||
List<String> truncated = new ArrayList<>();
|
||||
int queried = 0;
|
||||
|
||||
// includeMeta=true 면 메타 DB 결과를 먼저 추가 (company_code='*')
|
||||
// — 회사 fan-out 들어가기 전에 META 컨텍스트 그대로에서 실행.
|
||||
if (includeMeta) {
|
||||
try {
|
||||
List<Map<String, Object>> metaRows = params == null
|
||||
? sqlSession.selectList(mapperId)
|
||||
: sqlSession.selectList(mapperId, params);
|
||||
if (perCompanyLimit != null && metaRows.size() > perCompanyLimit) {
|
||||
metaRows = new ArrayList<>(metaRows.subList(0, perCompanyLimit));
|
||||
truncated.add("*");
|
||||
}
|
||||
for (Map<String, Object> r : metaRows) {
|
||||
r.put("company_code", "*");
|
||||
mergedRows.add(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[CrossTenant] meta query mapper={} failed: {}", mapperId, e.getMessage());
|
||||
failed.add("*");
|
||||
}
|
||||
}
|
||||
|
||||
for (Map<String, Object> c : companies) {
|
||||
String companyCode = (String) c.get("company_code");
|
||||
String dbName = (String) c.get("db_name");
|
||||
queried++;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> rows = queryOne(dbName, mapperId, params);
|
||||
|
||||
// cap 적용 — mapper 가 cap+1 만큼 가져왔으면 cap 으로 잘라 truncated 마킹
|
||||
if (perCompanyLimit != null && rows.size() > perCompanyLimit) {
|
||||
rows = new ArrayList<>(rows.subList(0, perCompanyLimit));
|
||||
truncated.add(companyCode);
|
||||
}
|
||||
|
||||
for (Map<String, Object> r : rows) {
|
||||
// company_code 강제 주입 — 응답 행이 어느 회사 것인지 명시.
|
||||
// mapper 내부에서 박은 값이 있더라도 메타 DB 라우팅 정보로 덮어씀.
|
||||
r.put("company_code", companyCode);
|
||||
mergedRows.add(r);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[CrossTenant] mapper={} failed for company={} db={} : {}",
|
||||
mapperId, companyCode, dbName, e.getMessage());
|
||||
failed.add(companyCode);
|
||||
}
|
||||
}
|
||||
|
||||
return new Result(mergedRows, queried, failed.size(), failed, truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 회사 DB 로 컨텍스트 잠깐 바꿔 SELECT 1번.
|
||||
* finally 에서 반드시 prev 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅됨.
|
||||
*
|
||||
* ★ 핵심: TenantRoutingDataSource 의 routing map 에 회사 DB 풀이 등록돼있지 않으면
|
||||
* META 로 fallback 됨. SubdomainResolverFilter 는 회사 도메인 진입 시에만 풀을 등록하므로,
|
||||
* SUPER_ADMIN 이 admin 도메인에서 호출하는 cross-tenant 경로에선 풀이 안 깔려 있다.
|
||||
* 따라서 매 호출마다 ensureTenantPool 로 lazy 생성 (이미 있으면 no-op).
|
||||
*/
|
||||
private List<Map<String, Object>> queryOne(String dbName, String mapperId, Map<String, Object> params) {
|
||||
ensureTenantPool(dbName);
|
||||
String prev = DbContextHolder.get();
|
||||
try {
|
||||
DbContextHolder.set(dbName);
|
||||
return params == null
|
||||
? sqlSession.selectList(mapperId)
|
||||
: sqlSession.selectList(mapperId, params);
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
DbContextHolder.clear();
|
||||
} else {
|
||||
DbContextHolder.set(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 DB 풀이 없으면 최초 1회 생성. minIdle=0 정책은 Factory 가 책임.
|
||||
* SubdomainResolverFilter.ensureTenantPool 과 동일한 패턴.
|
||||
*/
|
||||
private void ensureTenantPool(String dbName) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
synchronized (routingDataSource) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
HikariDataSource ds = TenantDataSourceFactory.createTenant(
|
||||
tenantDbSettings.buildJdbcUrl(dbName),
|
||||
tenantDbSettings.username(),
|
||||
tenantDbSettings.password(),
|
||||
dbName);
|
||||
routingDataSource.addTenant(dbName, ds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase A 스모크 테스트 전용 — Aggregator 우회하고 메타 DB 활성 회사 목록만 반환.
|
||||
* 컨트롤러가 가드 통과 검증 + mapper 등록 확인용으로 호출.
|
||||
*/
|
||||
public List<Map<String, Object>> listActiveCompaniesForSmokeTest() {
|
||||
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.listActiveCompanies");
|
||||
// 응답 키 순서 안정화 (Map 직렬화 시 순서 보장 위해 LinkedHashMap 으로 복사)
|
||||
List<Map<String, Object>> out = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> r : rows) {
|
||||
Map<String, Object> ordered = new LinkedHashMap<>();
|
||||
ordered.put("company_code", r.get("company_code"));
|
||||
ordered.put("company_name", r.get("company_name"));
|
||||
ordered.put("db_name", r.get("db_name"));
|
||||
out.add(ordered);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||
*
|
||||
* 정적 헬퍼 두 개. 컨트롤러는 이 둘을 호출해 boolean 으로 검사 후
|
||||
* 명시적으로 {@link org.springframework.http.ResponseEntity} 반환한다.
|
||||
* 예외 throw 방식을 안 쓰는 이유 — {@link com.erp.config.GlobalExceptionHandler} 의
|
||||
* catch-all 핸들러가 모든 예외를 500 으로 변환하므로, 가드 결과를 정확한 status code 로
|
||||
* 내려주려면 컨트롤러가 직접 결정해야 함.
|
||||
*
|
||||
* SecurityConfig 단계에서 매처로 가두지 않는 이유는 기존 95개 컨트롤러가
|
||||
* permitAll 로 동작 중이기 때문 ({@code SecurityConfig} L52~56 코멘트 참조).
|
||||
*
|
||||
* @see com.erp.crosstenant.CrossTenantAggregator
|
||||
* @see com.erp.tenant.DbContextHolder
|
||||
*/
|
||||
public final class CrossTenantContext {
|
||||
|
||||
public static final String ROLE_SUPER_ADMIN = "SUPER_ADMIN";
|
||||
|
||||
private CrossTenantContext() {}
|
||||
|
||||
/**
|
||||
* JWT 가 세팅한 role attribute 가 SUPER_ADMIN 인지.
|
||||
* JwtAuthenticationFilter 가 토큰 검증 후 {@code request.setAttribute("role", userType)} 박음.
|
||||
* 토큰 없거나 role 미스매치면 false.
|
||||
*/
|
||||
public static boolean isSuperAdmin(HttpServletRequest request) {
|
||||
Object role = request.getAttribute("role");
|
||||
return ROLE_SUPER_ADMIN.equals(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 요청이 META DB 컨텍스트인지.
|
||||
* SubdomainResolverFilter 가 admin.invyone.com / 메인 도메인일 때 setMeta() 박음.
|
||||
* 회사 도메인 (qnc.invyone.com 등) 에서 호출되면 false.
|
||||
*/
|
||||
public static boolean isMetaContext() {
|
||||
return DbContextHolder.isMeta();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.service.AdminService;
|
||||
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.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant 부서 조회 — 사용자 등록/수정 폼의 "부서" dropdown 을
|
||||
* 선택된 회사 DB 기준으로 채우기 위한 보조 endpoint.
|
||||
*
|
||||
* 단일 회사 모드의 {@code GET /api/admin/departments} 와 응답 형태 동일.
|
||||
* 차이점: company_code 가 query param 으로 명시되고, 그 회사 DB 컨텍스트로 임시 전환.
|
||||
*
|
||||
* @see CrossTenantUserController
|
||||
* @see com.erp.controller.AdminController#getDepartmentList // 단일 모드 원본
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/departments")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantDeptController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/departments?company_code=TEST02
|
||||
* 응답 구조는 단일 모드와 동일: { success, data: { departments, flat_list }, total, total_count }
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isMetaContext()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(errorBody("cross_tenant_requires_meta_context", request.getRequestURI()));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> serviceResult = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return adminService.getDepartmentList(params);
|
||||
});
|
||||
|
||||
int total = ((Number) serviceResult.get("total")).intValue();
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("departments", serviceResult.get("departments"));
|
||||
data.put("flat_list", serviceResult.get("flat_list"));
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("data", data);
|
||||
response.put("message", "부서 목록 조회 성공");
|
||||
response.put("total", total);
|
||||
response.put("total_count", total);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(errorBody(e.getMessage(), request.getRequestURI()));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> errorBody(String message, String path) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("success", false);
|
||||
body.put("message", message);
|
||||
body.put("path", path);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import com.erp.tenant.TenantDataSourceFactory;
|
||||
import com.erp.tenant.TenantDbSettings;
|
||||
import com.erp.tenant.TenantRoutingDataSource;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant WRITE 트랙 — 회사 컨텍스트 임시 전환 + 작업 실행 + 복원.
|
||||
*
|
||||
* READ 트랙({@link CrossTenantAggregator}) 와 달리 fan-out 안 함. 호출자가 명시한
|
||||
* 단일 회사(company_code) DB 컨텍스트로 잠깐 전환해서 INSERT/UPDATE/DELETE 실행.
|
||||
*
|
||||
* 사용 패턴 (컨트롤러):
|
||||
* <pre>
|
||||
* if (!CrossTenantContext.isSuperAdmin(request)) return forbidden();
|
||||
* if (!CrossTenantContext.isMetaContext()) return badRequest();
|
||||
*
|
||||
* String targetCompany = (String) body.get("company_code");
|
||||
* Map<String,Object> result = executor.runInCompany(targetCompany, () ->
|
||||
* adminService.saveUser(body)
|
||||
* );
|
||||
* </pre>
|
||||
*
|
||||
* 핵심 보장:
|
||||
* - 회사 풀 lazy 생성 (없으면 만들고, 이미 있으면 no-op). minIdle=0 정책 그대로.
|
||||
* - finally 에서 prev 컨텍스트 복원 — 누수되면 후속 요청이 엉뚱한 회사 DB 로 라우팅.
|
||||
* - 알 수 없는 company_code 면 IllegalArgumentException — 컨트롤러가 400 으로 변환.
|
||||
*
|
||||
* @see CrossTenantAggregator // READ 트랙 (fan-out)
|
||||
* @see CrossTenantContext // 가드
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantExecutor {
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final TenantDbSettings tenantDbSettings;
|
||||
|
||||
/**
|
||||
* 지정 회사 DB 컨텍스트로 작업 실행. 결과 반환.
|
||||
*
|
||||
* @throws IllegalArgumentException company_code 가 비어있거나 active 회사가 아닐 때
|
||||
*/
|
||||
public <T> T runInCompany(String companyCode, Supplier<T> work) {
|
||||
String dbName = resolveDbName(companyCode);
|
||||
|
||||
ensureTenantPool(dbName);
|
||||
|
||||
String prev = DbContextHolder.get();
|
||||
try {
|
||||
DbContextHolder.set(dbName);
|
||||
log.info("[CrossTenant/Write] enter company={} db={}", companyCode, dbName);
|
||||
return work.get();
|
||||
} finally {
|
||||
if (prev == null) {
|
||||
DbContextHolder.clear();
|
||||
} else {
|
||||
DbContextHolder.set(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 결과 없는 작업용 (Runnable 형태). */
|
||||
public void runInCompany(String companyCode, Runnable work) {
|
||||
runInCompany(companyCode, () -> {
|
||||
work.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* company_code → db_name 매핑. META DB 의 COMPANY_MNG 에서 active 행만 인정.
|
||||
* 컨텍스트 전환 전에 호출돼야 하므로 META 컨텍스트(현재 컨텍스트)에서 실행.
|
||||
*/
|
||||
private String resolveDbName(String companyCode) {
|
||||
if (companyCode == null || companyCode.isBlank()) {
|
||||
throw new IllegalArgumentException("company_code 가 비어있음");
|
||||
}
|
||||
if ("*".equals(companyCode)) {
|
||||
throw new IllegalArgumentException("'*' 는 cross-tenant write 대상이 아님 (메타 = SUPER_ADMIN 자신)");
|
||||
}
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
String dbName = sqlSession.selectOne("provisioning.resolveDbNameByCompanyCode", params);
|
||||
if (dbName == null) {
|
||||
throw new IllegalArgumentException("등록되지 않았거나 비활성 회사: company_code=" + companyCode);
|
||||
}
|
||||
return dbName;
|
||||
}
|
||||
|
||||
/** 회사 풀이 없으면 최초 1회 생성. SubdomainResolverFilter / Aggregator 와 동일 패턴. */
|
||||
private void ensureTenantPool(String dbName) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
synchronized (routingDataSource) {
|
||||
if (routingDataSource.hasTenant(dbName)) return;
|
||||
HikariDataSource ds = TenantDataSourceFactory.createTenant(
|
||||
tenantDbSettings.buildJdbcUrl(dbName),
|
||||
tenantDbSettings.username(),
|
||||
tenantDbSettings.password(),
|
||||
dbName);
|
||||
routingDataSource.addTenant(dbName, ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.RoleService;
|
||||
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.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant ROLE WRITE/READ 엔드포인트 — Phase 2.
|
||||
*
|
||||
* 권한 그룹은 회사 DB 의 AUTHORITY_MASTER, 멤버/메뉴 권한도 회사 DB 내부 테이블.
|
||||
* 어느 회사의 권한 그룹인지 알아야 라우팅 가능 → 모든 endpoint 가 company_code 필수
|
||||
* (body 또는 query param).
|
||||
*
|
||||
* 단일 회사 모드 endpoint ({@link com.erp.controller.RoleController}) 는 무수정.
|
||||
*
|
||||
* @see CrossTenantExecutor
|
||||
* @see CrossTenantUserController // 같은 패턴, Phase 1
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/roles")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/roles
|
||||
* body: { company_code, auth_name, auth_code, ... }
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> createRole(
|
||||
HttpServletRequest request,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
params.put("writer", writer);
|
||||
params.put("objid", "AM" + System.currentTimeMillis());
|
||||
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
|
||||
params.put("auth_name", params.get("role_name"));
|
||||
}
|
||||
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
|
||||
params.put("auth_code", params.get("role_code"));
|
||||
}
|
||||
return roleService.createRoleGroup(params);
|
||||
});
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cross-tenant/roles/{id} body: { company_code, ... }
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateRole(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
params.put("objid", id);
|
||||
params.put("writer", writer);
|
||||
if (params.containsKey("role_name") && !params.containsKey("auth_name")) {
|
||||
params.put("auth_name", params.get("role_name"));
|
||||
}
|
||||
if (params.containsKey("role_code") && !params.containsKey("auth_code")) {
|
||||
params.put("auth_code", params.get("role_code"));
|
||||
}
|
||||
return roleService.updateRoleGroup(params);
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/roles/{id}?company_code=TEST02
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteRole(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("objid", id);
|
||||
roleService.deleteRoleGroup(p);
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 워크스페이스 / 메뉴 트리 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/roles/{id}/workspace?company_code=TEST02
|
||||
* 그룹 + 멤버 + non-members + 메뉴 + 메뉴 권한 한 번에.
|
||||
*/
|
||||
@GetMapping("/{id}/workspace")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getWorkspace(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> ws = executor.runInCompany(companyCode,
|
||||
() -> roleService.getRoleWorkspace(id));
|
||||
if (ws == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("권한 그룹을 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(ws, "워크스페이스 조회 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/roles/menus/all?company_code=TEST02
|
||||
* 회사 메뉴 트리 (권한 설정용 원천).
|
||||
*/
|
||||
@GetMapping("/menus/all")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAllMenus(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<List<Map<String, Object>>>> g = guardList(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> menus = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("company_code", companyCode);
|
||||
return roleService.getAllMenus(p);
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(menus, "메뉴 목록 조회 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 멤버 토글 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
|
||||
*/
|
||||
@PostMapping("/{id}/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> addSingleMember(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String userId,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
boolean inserted = roleService.addSingleRoleMember(id, userId, writer);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("inserted", inserted);
|
||||
r.put("master_objid", id);
|
||||
r.put("user_id", userId);
|
||||
return r;
|
||||
});
|
||||
String msg = Boolean.TRUE.equals(result.get("inserted")) ? "멤버 추가 성공" : "이미 멤버입니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(result, msg));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/roles/{id}/members/{userId}?company_code=TEST02
|
||||
*/
|
||||
@DeleteMapping("/{id}/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> removeSingleMember(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
boolean deleted = roleService.removeSingleRoleMember(id, userId);
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("deleted", deleted);
|
||||
r.put("master_objid", id);
|
||||
r.put("user_id", userId);
|
||||
return r;
|
||||
});
|
||||
String msg = Boolean.TRUE.equals(result.get("deleted")) ? "멤버 제거 성공" : "멤버가 존재하지 않습니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(result, msg));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 메뉴 권한 토글 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/cross-tenant/roles/{id}/menu-permissions/{menuObjid}
|
||||
* body: { company_code, create_yn?, read_yn?, update_yn?, delete_yn? }
|
||||
*/
|
||||
@PatchMapping("/{id}/menu-permissions/{menuObjid}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> toggleMenuPermission(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String id,
|
||||
@PathVariable String menuObjid,
|
||||
@RequestAttribute(value = "user_id", required = false) String writer,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> g = guardMap(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> roleService.toggleMenuPermission(
|
||||
id, menuObjid,
|
||||
asYn(body.get("create_yn")),
|
||||
asYn(body.get("read_yn")),
|
||||
asYn(body.get("update_yn")),
|
||||
asYn(body.get("delete_yn")),
|
||||
writer));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 권한 토글 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(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()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(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()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(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()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||
private String asYn(Object raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw instanceof Boolean b) return b ? "Y" : "N";
|
||||
String s = String.valueOf(raw).trim();
|
||||
if (s.isEmpty()) return null;
|
||||
if ("Y".equalsIgnoreCase(s) || "true".equalsIgnoreCase(s) || "1".equals(s)) return "Y";
|
||||
if ("N".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s) || "0".equals(s)) return "N";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.service.AdminService;
|
||||
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.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant USER WRITE 엔드포인트.
|
||||
*
|
||||
* 기본 패턴:
|
||||
* 1. {@link CrossTenantContext#isSuperAdmin} + {@link CrossTenantContext#isMetaContext} 가드
|
||||
* 2. 요청 body/path/query 에서 target {@code company_code} 추출 (필수)
|
||||
* 3. {@link CrossTenantExecutor#runInCompany} 로 그 회사 DB 컨텍스트 임시 전환
|
||||
* 4. 기존 {@link AdminService} 의 user write 메서드 호출 (재사용)
|
||||
* 5. finally 에서 컨텍스트 복원
|
||||
*
|
||||
* 기존 {@code POST /api/admin/users} 등 단일 회사 모드 엔드포인트는 무수정 — 회사 도메인
|
||||
* 컨텍스트에서 그대로 동작.
|
||||
*
|
||||
* @see CrossTenantExecutor
|
||||
* @see com.erp.controller.AdminController // 단일 회사 모드 원본
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/cross-tenant/users")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users
|
||||
* SUPER_ADMIN 이 특정 회사 사용자 등록/수정 (회사는 body.company_code 로 명시).
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/with-dept
|
||||
* 사원+부서 통합 저장 (cross-tenant).
|
||||
*/
|
||||
@PostMapping("/with-dept")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUserWithDept(body));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사원+부서 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cross-tenant/users/{userId}
|
||||
* 사용자 수정 (REST). target company_code 는 body 에 명시.
|
||||
*/
|
||||
@PutMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
body.put("user_id", userId);
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/cross-tenant/users/{userId}?company_code=TEST01
|
||||
* 사용자 삭제 (비활성화). target company_code 는 query param.
|
||||
*/
|
||||
@DeleteMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteUser(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId);
|
||||
}
|
||||
adminService.changeUserStatus(userId, "inactive");
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/cross-tenant/users/{userId}/status
|
||||
* 사용자 상태 변경. body: { "status": "active|inactive", "company_code": "TEST01" }
|
||||
*/
|
||||
@PatchMapping("/{userId}/status")
|
||||
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String status = (String) body.get("status");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.changeUserStatus(userId, status));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/reset-password
|
||||
* body: { "user_id": "...", "company_code": "TEST01" }
|
||||
*/
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<ApiResponse<Void>> resetUserPassword(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.resetUserPassword(userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── READ 보강 (단건 조회 / 중복확인 / 이력) ───────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/users/{userId}?company_code=TEST01
|
||||
* 단건 조회 — 회사 컨텍스트로 가서 USER_INFO 단건.
|
||||
*/
|
||||
@GetMapping("/{userId}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
try {
|
||||
Map<String, Object> info = executor.runInCompany(companyCode,
|
||||
() -> adminService.getUserInfo(userId));
|
||||
if (info == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(info));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/cross-tenant/users/{userId}/with-dept?company_code=TEST01
|
||||
*/
|
||||
@GetMapping("/{userId}/with-dept")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String userId,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("company_code", companyCode);
|
||||
p.put("user_id", userId);
|
||||
return adminService.getUserWithDept(p);
|
||||
});
|
||||
if (result == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/cross-tenant/users/check-duplicate
|
||||
* body: { "user_id": "...", "company_code": "TEST01" }
|
||||
*/
|
||||
@PostMapping("/check-duplicate")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
|
||||
HttpServletRequest request,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
ResponseEntity<ApiResponse<Map<String, Object>>> guard = guard(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("is_duplicate", existing != null);
|
||||
return out;
|
||||
});
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 가드 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(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()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Void 응답용 가드 (제네릭만 다름). */
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(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()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,11 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/localhost/www/admin 제외.
|
||||
* Host 헤더에서 서브도메인 추출. 포트 제거 + IP/bare localhost/예약어 제외.
|
||||
*
|
||||
* 운영 (3파트, e.g. qnc.invyone.com) → 첫 파트
|
||||
* dev (2파트, {sub}.localhost) → 첫 파트 (RFC 6761, 별도 DNS 불필요)
|
||||
* 그 외 (invyone.com 같은 베이스 / bare localhost / IP) → null (META)
|
||||
*/
|
||||
static String extractSubdomain(String host) {
|
||||
if (host == null || host.isBlank()) return null;
|
||||
@@ -99,8 +103,18 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
|
||||
if (IPV4.matcher(host).matches()) return null;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length < 3) return null; // invyone.com (2파트) → null
|
||||
|
||||
// 2파트 — "{sub}.localhost" 만 허용 (dev 전용). invyone.com 같은 베이스 도메인은 null.
|
||||
if (parts.length == 2) {
|
||||
if (!"localhost".equals(parts[1])) return null;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return null;
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
// 3파트 이상 (운영) — 첫 번째 파트가 서브도메인
|
||||
if (parts.length < 3) return null;
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ cors:
|
||||
# 콤마 구분 문자열. setAllowedOriginPatterns 로 매칭됨.
|
||||
# Spring CORS 문법: 포트 와일드카드는 `[*]` 로 표기. YAML 이 `[...]` 를 sequence 로 해석하지
|
||||
# 않도록 반드시 따옴표로 감싸기.
|
||||
# dev 디폴트: localhost + 사무실 Tailscale IP + 테넌트 서브도메인 (모든 포트) 패턴.
|
||||
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
|
||||
# dev 디폴트: localhost + 사무실 Tailscale IP + *.localhost 테넌트 (RFC 6761) + 테넌트 서브도메인 (모든 포트) 패턴.
|
||||
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772,http://*.localhost:[*],http://*.invyone.com:[*],https://*.invyone.com:[*],http://*.invyone.com,https://*.invyone.com}"
|
||||
|
||||
file:
|
||||
upload-dir: ./uploads
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<!--
|
||||
Cross-tenant 어드민 합산 전용 mapper.
|
||||
|
||||
핵심 규칙 (notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §6.2):
|
||||
- SELECT 만. UPDATE/DELETE/INSERT 금지 (수정은 회사 도메인 컨텍스트로 위임).
|
||||
- WHERE 에 COMPANY_CODE 필터 박지 말 것 — fan-out 시 각 회사 DB 안에서만 실행되므로
|
||||
그 회사 데이터로 정의상 한정됨.
|
||||
- SELECT 절에도 COMPANY_CODE 박지 말 것 — Aggregator 가 메타 DB 라우팅 정보 기준으로
|
||||
응답 행에 박아준다 (회사 DB 안에 저장된 stale COMPANY_CODE 값 우선시 금지).
|
||||
- JOIN 은 회사 DB 내부 테이블끼리만. 메타 DB 조인 금지.
|
||||
|
||||
namespace 단일: "admin-cross-tenant"
|
||||
-->
|
||||
<mapper namespace="admin-cross-tenant">
|
||||
|
||||
<!--
|
||||
사용자 목록 — 단일 회사 admin.selectUserList 와 컬럼 동일 (COMPANY_CODE 제외).
|
||||
company_code 는 Aggregator 가 응답에 박는다.
|
||||
cross-tenant 1차 구현은 페이지네이션 없이 회사당 전체 반환 (설계서 §9.3).
|
||||
필터는 search / status / dept_code 만 지원 (단일 회사 화면과 동일).
|
||||
-->
|
||||
<select id="listUsers" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
SABUN AS sabun
|
||||
, USER_ID AS user_id
|
||||
, USER_NAME AS user_name
|
||||
, COALESCE(USER_NAME_ENG, '') AS user_name_eng
|
||||
, COALESCE(DEPT_CODE, '') AS dept_code
|
||||
, COALESCE(DEPT_NAME, '') AS dept_name
|
||||
, COALESCE(POSITION_CODE, '') AS position_code
|
||||
, COALESCE(POSITION_NAME, '') AS position_name
|
||||
, COALESCE(EMAIL, '') AS email
|
||||
, COALESCE(TEL, '') AS tel
|
||||
, COALESCE(CELL_PHONE, '') AS cell_phone
|
||||
, COALESCE(USER_TYPE, '') AS user_type
|
||||
, COALESCE(USER_TYPE_NAME, '') AS user_type_name
|
||||
, COALESCE(TO_CHAR(CREATED_DATE, 'YYYY-MM-DD'), '') AS reg_date
|
||||
, STATUS AS status
|
||||
, COALESCE(LOCALE, '') AS locale
|
||||
FROM USER_INFO
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND (USER_ID ILIKE #{search}
|
||||
OR USER_NAME ILIKE #{search}
|
||||
OR DEPT_NAME ILIKE #{search}
|
||||
OR POSITION_NAME ILIKE #{search}
|
||||
OR USER_TYPE_NAME ILIKE #{search}
|
||||
OR SABUN ILIKE #{search}
|
||||
OR EMAIL ILIKE #{search}
|
||||
OR TEL ILIKE #{search}
|
||||
OR CELL_PHONE ILIKE #{search})
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
AND STATUS = #{status}
|
||||
</if>
|
||||
<if test="dept_code != null and dept_code != ''">
|
||||
AND DEPT_CODE = #{dept_code}
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC, USER_NAME ASC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
권한 그룹 목록 — 단일 회사 role.getRoleGroupList 와 컬럼 동일 (COMPANY_CODE 제외).
|
||||
member_count / menu_count 서브쿼리 그대로 유지. 회사 DB 안에서 동작하므로
|
||||
그 회사의 AUTHORITY_SUB_USER / AUTHORITY_SUB_MENU 만 카운트됨.
|
||||
-->
|
||||
<select id="listRoleGroups" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
AM.OBJID AS objid
|
||||
, AM.AUTH_NAME AS auth_name
|
||||
, AM.AUTH_CODE AS auth_code
|
||||
, AM.STATUS AS status
|
||||
, AM.WRITER AS writer
|
||||
, AM.CREATED_DATE AS created_date
|
||||
, (SELECT COUNT(*) FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = AM.OBJID) AS member_count
|
||||
, (SELECT COUNT(*) FROM AUTHORITY_SUB_MENU WHERE MASTER_OBJID = AM.OBJID) AS menu_count
|
||||
FROM AUTHORITY_MASTER AM
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND AM.AUTH_NAME ILIKE #{search}
|
||||
</if>
|
||||
ORDER BY AM.CREATED_DATE DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
배치 목록 — 단일 회사 batch.getBatchList 와 동일하게 SELECT *.
|
||||
PostgreSQL 컬럼명이 그대로 lowercase Map key 로 떨어짐.
|
||||
페이지네이션은 cross-tenant 1차 구현엔 비지원 (회사당 전체 반환).
|
||||
-->
|
||||
<select id="listBatches" parameterType="map" resultType="map">
|
||||
SELECT *
|
||||
FROM BATCH_CONFIGS
|
||||
WHERE 1=1
|
||||
<if test="search != null and search != ''">
|
||||
AND (BATCH_NAME ILIKE '%' || #{search} || '%'
|
||||
OR DESCRIPTION ILIKE '%' || #{search} || '%')
|
||||
</if>
|
||||
<if test="is_active != null and is_active != ''">
|
||||
AND IS_ACTIVE = #{is_active}
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
다국어 키 목록 — 단일 회사 multilang.getMultilangKeyList 와 컬럼 동일.
|
||||
회사 DB 안에서 동작하므로 filter_company_code 분기 불필요 (그 회사 데이터로 정의상 한정).
|
||||
1차 구현은 category_id 재귀 필터 비지원 — 필요해지면 후속 추가.
|
||||
-->
|
||||
<select id="listLangKeys" parameterType="map" resultType="map">
|
||||
SELECT
|
||||
KEY_ID AS key_id
|
||||
, USAGE_NOTE AS usage_note
|
||||
, LANG_KEY AS lang_key
|
||||
, DESCRIPTION AS description
|
||||
, IS_ACTIVE AS is_active
|
||||
, CATEGORY_ID AS category_id
|
||||
, CREATED_DATE AS created_date
|
||||
, CREATED_BY AS created_by
|
||||
, UPDATED_DATE AS updated_date
|
||||
, UPDATED_BY AS updated_by
|
||||
FROM MULTI_LANG_KEY_MASTER
|
||||
WHERE 1=1
|
||||
<if test="menu_code != null and menu_code != ''">
|
||||
AND USAGE_NOTE = #{menu_code}
|
||||
</if>
|
||||
<if test="search != null and search != ''">
|
||||
AND (LANG_KEY ILIKE #{search}
|
||||
OR DESCRIPTION ILIKE #{search})
|
||||
</if>
|
||||
ORDER BY CREATED_DATE DESC, KEY_ID DESC
|
||||
<if test="per_company_limit_plus_one != null">
|
||||
LIMIT #{per_company_limit_plus_one}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -202,4 +202,34 @@
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
Phase A (cross-tenant 집계, 2026-04-27): SUPER_ADMIN fan-out 대상 회사 목록.
|
||||
listCompaniesForUi 와 다름 — 그 쿼리는 회사관리 화면 렌더용으로 모든 상태 포함.
|
||||
이건 active 상태만, 라우팅 가능한(DB_NAME 박힌) 회사만.
|
||||
-->
|
||||
<select id="listActiveCompanies" resultType="map">
|
||||
SELECT
|
||||
COMPANY_CODE AS company_code
|
||||
, COMPANY_NAME AS company_name
|
||||
, DB_NAME AS db_name
|
||||
FROM COMPANY_MNG
|
||||
WHERE DB_STATUS = 'active'
|
||||
AND DB_NAME IS NOT NULL
|
||||
ORDER BY COMPANY_CODE
|
||||
</select>
|
||||
|
||||
<!--
|
||||
company_code 로 db_name 직접 조회 (cross-tenant write 라우팅용).
|
||||
SUPER_ADMIN 이 admin 도메인에서 특정 회사 DB 로 임시 컨텍스트 전환할 때
|
||||
CrossTenantExecutor 가 호출. active 회사만 라우팅 허용.
|
||||
-->
|
||||
<select id="resolveDbNameByCompanyCode" parameterType="map" resultType="string">
|
||||
SELECT DB_NAME
|
||||
FROM COMPANY_MNG
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
AND DB_STATUS = 'active'
|
||||
AND DB_NAME IS NOT NULL
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
WHERE UI.USER_ID NOT IN (
|
||||
SELECT USER_ID FROM AUTHORITY_SUB_USER WHERE MASTER_OBJID = #{master_objid}
|
||||
)
|
||||
<if test="company_code != null and company_code != '' and company_code != '*'">
|
||||
<if test='company_code != null and company_code != "" and company_code != "*"'>
|
||||
AND (UI.COMPANY_CODE = #{company_code} OR UI.COMPANY_CODE = '*')
|
||||
</if>
|
||||
<if test="status_active != null and status_active == true">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.erp.tenant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* extractSubdomain() 단위 테스트.
|
||||
* - 운영 (3파트, *.invyone.com) 동작 회귀 없음
|
||||
* - dev (2파트, *.localhost) 신규 지원
|
||||
* - bare localhost / IP / 베이스 도메인 / 예약어 → null (META)
|
||||
*/
|
||||
class SubdomainResolverFilterTest {
|
||||
|
||||
@Test
|
||||
void nullOrBlank_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain(null));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain(""));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareLocalhost_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("localhost"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("localhost:9771"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ipv4_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("127.0.0.1"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("183.99.177.40:8081"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void baseDomain_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("invyone.com"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("invyone.com:443"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionThreePartHost_returnsFirstPart() {
|
||||
assertEquals("qnc", SubdomainResolverFilter.extractSubdomain("qnc.invyone.com"));
|
||||
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.invyone.com:443"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void devLocalhostHost_returnsFirstPart() {
|
||||
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.localhost"));
|
||||
assertEquals("test02", SubdomainResolverFilter.extractSubdomain("test02.localhost:9771"));
|
||||
assertEquals("qnc", SubdomainResolverFilter.extractSubdomain("QNC.LOCALHOST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservedSubdomain_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("admin.invyone.com"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("solution.invyone.com"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("admin.localhost:9771"));
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain("www.localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyFirstPart_returnsNull() {
|
||||
assertNull(SubdomainResolverFilter.extractSubdomain(".localhost"));
|
||||
}
|
||||
}
|
||||
@@ -155,17 +155,29 @@ invyone (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이
|
||||
- **Traefik static + env**: `notes/gbpark/2026-04-24-traefik-wildcard/docker-compose.yml` 참고 (Porkbun provider + environment 블록 포함)
|
||||
- **Let's Encrypt acme-dns.json**: 서버 `/opt/docker/traefik/letsencrypt/` 에 저장 (90일 자동 갱신)
|
||||
|
||||
### 4.2 로컬 개발 (본인 머신에서 docker up)
|
||||
### 4.2 로컬 개발 (본인 머신)
|
||||
|
||||
**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석.
|
||||
**`*.localhost` 자동 매핑** 사용. RFC 6761 + 최신 브라우저가 자동 해석. hosts 편집/DNS 설정 0.
|
||||
|
||||
```
|
||||
브라우저가 test07.localhost:9772 요청
|
||||
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:9772 해석
|
||||
→ hosts 편집 0, 설정 0
|
||||
브라우저가 test02.localhost:<port> 요청
|
||||
→ Chrome/Firefox/Edge 가 자동으로 127.0.0.1:<port> 해석
|
||||
```
|
||||
|
||||
**조건**: 본인 PC 에서 `docker compose up` 으로 프론트/백엔드를 직접 띄워야 함.
|
||||
두 가지 실행 모드 모두 동작:
|
||||
|
||||
**모드 A — `docker compose up` (프론트/백 통합)**
|
||||
|
||||
- 단일 포트(예: 9772) 로 프론트 + Next rewrite 통한 백엔드 프록시
|
||||
- `client.ts` 의 `*.invyone.com` 분기와 같은 패턴으로 `*.localhost` 도 직접 호출 → Host 헤더 보존
|
||||
- 접속: `http://test02.localhost:9772/login`
|
||||
|
||||
**모드 B — `npm run dev` + `./gradlew bootRun` (분리 실행)**
|
||||
|
||||
- 프론트 9771, 백엔드 8081 분리. `client.ts` 가 `http://test02.localhost:8081/api` 로 직결
|
||||
- 백엔드 `SubdomainResolverFilter` 가 2파트 `{sub}.localhost` 호스트를 첫 파트로 파싱 (3파트 운영 경로와 같은 분기 트리)
|
||||
- 접속: `http://test02.localhost:9771/login`
|
||||
- 단, **bare `localhost:9771`** 은 메타 컨텍스트 (cross-tenant admin) 로 떨어짐 — 이 경우엔 NEXT_PUBLIC_API_URL=/api → Next rewrite 경로
|
||||
|
||||
### 4.3 공유 개발 서버 (원격 서버 한 대로 여러 명이 접속)
|
||||
|
||||
@@ -241,13 +253,23 @@ cors:
|
||||
if (currentHost.endsWith(".invyone.com")) {
|
||||
return `https://${currentHost}/api`;
|
||||
}
|
||||
// 1-b) dev *.localhost — 같은 이유로 직접 호출. bare localhost 는 제외 (메타).
|
||||
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
|
||||
return `http://${currentHost}:8081/api`;
|
||||
}
|
||||
// 1-c) dev <prefix>.<IPv4>(.nip.io|.sslip.io) — 사무실 도커 등 와일드카드 DNS 없는 환경.
|
||||
if (DEV_TENANT_HOST.test(currentHost)) {
|
||||
return `http://${currentHost}:8083/api`;
|
||||
}
|
||||
// 2) (레거시) invyone.com 메인 도메인
|
||||
if (currentHost === "v1.invyone.com") return "https://api.invyone.com/api";
|
||||
if (currentHost === "v1.invyone.com" || currentHost === "solution.invyone.com") {
|
||||
return "https://api.invyone.com/api";
|
||||
}
|
||||
// 3) NEXT_PUBLIC_API_URL (docker-compose 주입)
|
||||
// 4) localhost 기본값
|
||||
```
|
||||
|
||||
> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1) 분기로 처리해 브라우저가 직접 `https://<host>/api` 로 Traefik 에 요청 → Traefik 이 Host 보존하며 backend 로 프록시.
|
||||
> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1)/1-b) 분기로 처리해 브라우저가 직접 `<host>/api` 로 요청 → 운영은 Traefik 이, dev 는 backend 8081 이 Host 보존한 상태로 받음.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type RecentLog,
|
||||
} from "@/lib/api/batch";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function cronToKorean(cron: string): string {
|
||||
@@ -318,6 +319,7 @@ export default function BatchManagementPage() {
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
@@ -336,6 +338,8 @@ export default function BatchManagementPage() {
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
]);
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
setBatchConfigs(configsResponse.data);
|
||||
// 각 배치의 스파크라인을 백그라운드로 로드
|
||||
@@ -465,6 +469,8 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CrossTenantBanner meta={crossTenantMeta} />
|
||||
|
||||
{/* 통계 요약 스트립 */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-0 rounded-lg border bg-card">
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -88,6 +89,7 @@ export default function RolesPage() {
|
||||
|
||||
// 권한 그룹 목록
|
||||
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
||||
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<RoleGroup | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||
@@ -151,6 +153,8 @@ export default function RolesPage() {
|
||||
: currentUser?.company_code;
|
||||
|
||||
const response = await roleAPI.getList({ companyCode: companyFilter });
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((response as any)?.cross_tenant_meta ?? null);
|
||||
if (response.success && response.data) {
|
||||
setRoleGroups(response.data);
|
||||
} else {
|
||||
@@ -176,18 +180,22 @@ export default function RolesPage() {
|
||||
// 선택 유효성
|
||||
useEffect(() => {
|
||||
if (!selectedRole) return;
|
||||
if (!roleGroups.find((r) => r.objid === selectedRole.objid)) {
|
||||
if (
|
||||
!roleGroups.find(
|
||||
(r) => r.objid === selectedRole.objid && r.company_code === selectedRole.company_code,
|
||||
)
|
||||
) {
|
||||
setSelectedRole(null);
|
||||
}
|
||||
}, [roleGroups, selectedRole]);
|
||||
|
||||
// ─────────── 워크스페이스 로드 ───────────
|
||||
const loadWorkspace = useCallback(async (roleId: number | string) => {
|
||||
const loadWorkspace = useCallback(async (roleId: number | string, companyCode?: string) => {
|
||||
setIsLoadingWorkspace(true);
|
||||
setCheckedMembers(new Set());
|
||||
setCheckedNonMembers(new Set());
|
||||
try {
|
||||
const res = await roleAPI.getWorkspace(roleId);
|
||||
const res = await roleAPI.getWorkspace(roleId, companyCode);
|
||||
if (res.success && res.data) {
|
||||
setMembers(res.data.members || []);
|
||||
setNonMembers(res.data.nonMembers || []);
|
||||
@@ -212,7 +220,7 @@ export default function RolesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRole) {
|
||||
loadWorkspace(selectedRole.objid);
|
||||
loadWorkspace(selectedRole.objid, selectedRole.company_code);
|
||||
} else {
|
||||
setMembers([]);
|
||||
setNonMembers([]);
|
||||
@@ -224,14 +232,20 @@ export default function RolesPage() {
|
||||
|
||||
// ─────────── 권한 그룹 목록 필터 ───────────
|
||||
const filteredRoleGroups = useMemo(() => {
|
||||
if (!searchText.trim()) return roleGroups;
|
||||
let list = roleGroups;
|
||||
// SUPER_ADMIN cross-tenant 모드: fan-out 결과는 모든 회사 그룹 합본이라
|
||||
// 회사 dropdown 선택을 client-side 에서 한 번 더 적용해야 그 회사 그룹만 보임.
|
||||
if (isSuperAdmin && selectedCompany !== "all") {
|
||||
list = list.filter((r) => r.company_code === selectedCompany);
|
||||
}
|
||||
if (!searchText.trim()) return list;
|
||||
const q = searchText.toLowerCase();
|
||||
return roleGroups.filter(
|
||||
return list.filter(
|
||||
(r) =>
|
||||
(r.auth_name || "").toLowerCase().includes(q) ||
|
||||
(r.auth_code || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [roleGroups, searchText]);
|
||||
}, [roleGroups, searchText, isSuperAdmin, selectedCompany]);
|
||||
|
||||
// ─────────── 멤버 이동 (이미지: --> 삭제 / <-- 추가) ───────────
|
||||
const handleAddMembers = useCallback(async () => {
|
||||
@@ -246,7 +260,7 @@ export default function RolesPage() {
|
||||
|
||||
try {
|
||||
for (const userId of ids) {
|
||||
const res = await roleAPI.addSingleMember(selectedRole.objid, userId);
|
||||
const res = await roleAPI.addSingleMember(selectedRole.objid, userId, selectedRole.company_code);
|
||||
if (!res.success) throw new Error(res.message);
|
||||
}
|
||||
await refreshMenus();
|
||||
@@ -254,7 +268,7 @@ export default function RolesPage() {
|
||||
} catch (err) {
|
||||
console.error("멤버 추가 오류:", err);
|
||||
alert("멤버 추가에 실패했습니다. 화면을 새로고침합니다.");
|
||||
loadWorkspace(selectedRole.objid);
|
||||
loadWorkspace(selectedRole.objid, selectedRole.company_code);
|
||||
}
|
||||
}, [selectedRole, checkedNonMembers, nonMembers, refreshMenus, loadRoleGroups, loadWorkspace]);
|
||||
|
||||
@@ -269,7 +283,7 @@ export default function RolesPage() {
|
||||
|
||||
try {
|
||||
for (const userId of ids) {
|
||||
const res = await roleAPI.removeSingleMember(selectedRole.objid, userId);
|
||||
const res = await roleAPI.removeSingleMember(selectedRole.objid, userId, selectedRole.company_code);
|
||||
if (!res.success) throw new Error(res.message);
|
||||
}
|
||||
await refreshMenus();
|
||||
@@ -277,7 +291,7 @@ export default function RolesPage() {
|
||||
} catch (err) {
|
||||
console.error("멤버 제거 오류:", err);
|
||||
alert("멤버 제거에 실패했습니다. 화면을 새로고침합니다.");
|
||||
loadWorkspace(selectedRole.objid);
|
||||
loadWorkspace(selectedRole.objid, selectedRole.company_code);
|
||||
}
|
||||
}, [selectedRole, checkedMembers, members, refreshMenus, loadRoleGroups, loadWorkspace]);
|
||||
|
||||
@@ -389,7 +403,7 @@ export default function RolesPage() {
|
||||
setPermissions((prev) => ({ ...prev, [menuId]: nextPerm }));
|
||||
|
||||
try {
|
||||
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, menuId, changes);
|
||||
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, menuId, changes, selectedRole.company_code);
|
||||
if (!res.success) throw new Error(res.message);
|
||||
|
||||
if (res.data) {
|
||||
@@ -467,14 +481,14 @@ export default function RolesPage() {
|
||||
|
||||
try {
|
||||
for (const id of flatMenuIds) {
|
||||
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, id, change);
|
||||
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, id, change, selectedRole.company_code);
|
||||
if (!res.success) throw new Error(res.message);
|
||||
}
|
||||
await refreshMenus();
|
||||
} catch (err) {
|
||||
console.error("일괄 변경 오류:", err);
|
||||
alert("일괄 변경 실패 — 화면을 새로고침합니다.");
|
||||
loadWorkspace(selectedRole.objid);
|
||||
loadWorkspace(selectedRole.objid, selectedRole.company_code);
|
||||
}
|
||||
},
|
||||
[selectedRole, flatMenuIds, refreshMenus, loadWorkspace],
|
||||
@@ -587,6 +601,8 @@ export default function RolesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CrossTenantBanner meta={crossTenantMeta} />
|
||||
|
||||
{error && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -653,10 +669,12 @@ export default function RolesPage() {
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{filteredRoleGroups.map((role) => {
|
||||
const isSelected = selectedRole?.objid === role.objid;
|
||||
const isSelected =
|
||||
selectedRole?.objid === role.objid &&
|
||||
selectedRole?.company_code === role.company_code;
|
||||
return (
|
||||
<li
|
||||
key={role.objid}
|
||||
key={`${role.company_code ?? "_"}-${role.objid}`}
|
||||
onClick={() => setSelectedRole(role)}
|
||||
className={cn(
|
||||
"group cursor-pointer p-2.5 transition-colors",
|
||||
@@ -769,6 +787,7 @@ export default function RolesPage() {
|
||||
<Checkbox
|
||||
checked={checkedMembers.has(u.user_id)}
|
||||
onCheckedChange={() => toggleMemberCheck(u.user_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
|
||||
@@ -865,6 +884,7 @@ export default function RolesPage() {
|
||||
<Checkbox
|
||||
checked={checkedNonMembers.has(u.user_id)}
|
||||
onCheckedChange={() => toggleNonMemberCheck(u.user_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{u.user_name || u.user_id}</div>
|
||||
@@ -908,9 +928,9 @@ export default function RolesPage() {
|
||||
등록된 메뉴가 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 32rem)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="py-2.5 pl-3 text-left text-xs font-semibold w-[40%]">
|
||||
메뉴 전체 트리구조
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Pagination } from "@/components/common/Pagination";
|
||||
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
|
||||
import { UserFormModal } from "@/components/admin/UserFormModal";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
|
||||
|
||||
/**
|
||||
* 사용자관리 페이지
|
||||
@@ -26,6 +27,7 @@ export default function UserMngPage() {
|
||||
isSearching,
|
||||
error,
|
||||
paginationInfo,
|
||||
crossTenantMeta,
|
||||
|
||||
// 검색 기능
|
||||
updateSearchFilter,
|
||||
@@ -109,8 +111,12 @@ export default function UserMngPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="w-full space-y-4 p-6">
|
||||
// 페이지 자체는 스크롤 X — 헤더/툴바/페이지네이션은 고정,
|
||||
// 테이블만 자체 스크롤 (UserTable 의 ResponsiveDataView scrollContainer prop).
|
||||
// 부모(TabContent) 가 overflow-hidden 이라 h-full 로 받아 flex-col 내에서
|
||||
// 마지막 자식 (UserTable wrapper) 이 flex-1 min-h-0 으로 남은 공간 차지.
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-1 border-b pb-3">
|
||||
<h1 className="text-xl font-bold tracking-tight">사용자 관리</h1>
|
||||
@@ -126,6 +132,9 @@ export default function UserMngPage() {
|
||||
onCreateClick={handleCreateUser}
|
||||
/>
|
||||
|
||||
{/* SUPER_ADMIN cross-tenant 모드의 truncated/failed 안내 */}
|
||||
<CrossTenantBanner meta={crossTenantMeta} />
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
|
||||
|
||||
@@ -49,7 +49,8 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await roleAPI.delete(role.objid);
|
||||
// cross-tenant 모드에선 role.company_code 가 그 회사 DB 라우팅 키
|
||||
const response = await roleAPI.delete(role.objid, role.company_code);
|
||||
|
||||
if (response.success) {
|
||||
displayAlert("권한 그룹이 삭제되었습니다.", "success");
|
||||
|
||||
@@ -141,11 +141,12 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||
let response;
|
||||
|
||||
if (isEditMode && editingRole) {
|
||||
// 수정
|
||||
// 수정 — cross-tenant 모드에선 editingRole.company_code 가 그 회사 DB 라우팅 키
|
||||
response = await roleAPI.update(editingRole.objid, {
|
||||
auth_name: formData.authName,
|
||||
auth_code: formData.authCode,
|
||||
status: formData.status,
|
||||
company_code: editingRole.company_code,
|
||||
});
|
||||
} else {
|
||||
// 생성
|
||||
|
||||
@@ -271,8 +271,18 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
return;
|
||||
}
|
||||
|
||||
// SUPER_ADMIN(메타) 은 어느 회사에서 체크할지 먼저 골라야 함.
|
||||
// 회사별 USER_INFO 라 회사 미선택 상태에선 체크 의미가 없음.
|
||||
if (isSuperAdmin && !formData.company_code) {
|
||||
setDuplicateCheckMessage("회사를 먼저 선택해주세요.");
|
||||
setDuplicateCheckType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userAPI.checkDuplicateId(formData.user_id);
|
||||
// cross-tenant 모드: 회사별 USER_INFO 라 그 회사 코드와 함께 중복 체크.
|
||||
// 단일 모드: 두번째 인자 무시 (백엔드가 JWT.company_code 사용).
|
||||
const response = await userAPI.checkDuplicateId(formData.user_id, formData.company_code);
|
||||
if (response.success && response.data) {
|
||||
// 백엔드 API 응답 구조: { is_duplicate: boolean, message: string }
|
||||
const isDuplicate = response.data.is_duplicate;
|
||||
@@ -445,6 +455,48 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 회사 선택 — 모든 후속 입력의 컨텍스트라 가장 위 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode" className="text-sm font-medium">
|
||||
회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Select
|
||||
value={formData.company_code}
|
||||
onValueChange={(value) => handleInputChange("company_code", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.company_code} value={company.company_code}>
|
||||
{company.company_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
회사를 먼저 선택해야 사용자 ID 중복확인과 부서 선택이 가능합니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="companyCode"
|
||||
value={
|
||||
companies.find((c) => c.company_code === formData.company_code)?.company_name ||
|
||||
formData.company_code
|
||||
}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">회사는 최고 관리자만 변경할 수 있습니다.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -458,17 +510,18 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="userId"
|
||||
placeholder="사용자 ID 입력"
|
||||
placeholder={isSuperAdmin && !formData.company_code ? "회사 먼저 선택" : "사용자 ID 입력"}
|
||||
value={formData.user_id}
|
||||
onChange={(e) => handleInputChange("user_id", e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSuperAdmin && !formData.company_code}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isUserIdChecked && lastCheckedUserId === formData.user_id ? "default" : "outline"}
|
||||
onClick={checkUserIdDuplicate}
|
||||
disabled={!formData.user_id.trim() || isLoading}
|
||||
disabled={!formData.user_id.trim() || isLoading || (isSuperAdmin && !formData.company_code)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{isUserIdChecked && lastCheckedUserId === formData.user_id ? "확인완료" : "중복확인"}
|
||||
@@ -532,48 +585,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 회사 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode" className="text-sm font-medium">
|
||||
회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Select
|
||||
value={formData.company_code}
|
||||
onValueChange={(value) => handleInputChange("company_code", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.company_code} value={company.company_code}>
|
||||
{company.company_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
권한 관리는 별도의 권한 관리 페이지에서 설정할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="companyCode"
|
||||
value={
|
||||
companies.find((c) => c.company_code === formData.company_code)?.company_name ||
|
||||
formData.company_code
|
||||
}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">회사는 최고 관리자만 변경할 수 있습니다.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -238,10 +238,12 @@ export function UserTable({
|
||||
<ResponsiveDataView<User>
|
||||
data={users}
|
||||
columns={columns}
|
||||
keyExtractor={(u) => u.user_id}
|
||||
keyExtractor={(u) => `${u.company_code || "C"}::${u.user_id}`}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="등록된 사용자가 없습니다."
|
||||
skeletonCount={10}
|
||||
compact
|
||||
scrollContainer
|
||||
cardTitle={(u) => u.user_name || ""}
|
||||
cardSubtitle={(u) => <span className="font-mono">{u.user_id}</span>}
|
||||
cardHeaderRight={(u) => (
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
|
||||
/**
|
||||
* SUPER_ADMIN cross-tenant 응답에 truncated/failed 정보가 있을 때 보여주는 안내 배너.
|
||||
*
|
||||
* 동작:
|
||||
* - meta 가 없거나 truncated/failed 둘 다 0 이면 아무것도 렌더링 안 함 (자리 안 잡음)
|
||||
* - truncated_company_codes 가 있으면 amber 톤 안내 ("N개 회사가 cap 에 걸려 일부만 표시")
|
||||
* - failed_company_codes 가 있으면 red 톤 경고 ("N개 회사 조회 실패")
|
||||
* - 두 가지 동시에 있을 수 있어 각각 독립 배너로 노출
|
||||
*
|
||||
* v5 디자인 토큰만 사용 — primary-color glow + amber/red 액센트, 솔리드 배경 (blur 금지).
|
||||
*
|
||||
* @see notes/hjjeong/2026-04-28-cross-tenant-execution-log.md §3.5
|
||||
*/
|
||||
export function CrossTenantBanner({ meta }: { meta?: Record<string, any> | null }) {
|
||||
if (!meta) return null;
|
||||
|
||||
const truncated: string[] = Array.isArray(meta.truncated_company_codes) ? meta.truncated_company_codes : [];
|
||||
const failed: string[] = Array.isArray(meta.failed_company_codes) ? meta.failed_company_codes : [];
|
||||
const cap: number | undefined = meta.per_company_limit;
|
||||
|
||||
if (truncated.length === 0 && failed.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-2">
|
||||
{truncated.length > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 px-3 py-2 rounded-[10px] border text-[0.8125rem]"
|
||||
style={{
|
||||
background: "var(--v5-surface-solid)",
|
||||
borderColor: "rgba(var(--v5-amber-rgb),0.4)",
|
||||
boxShadow: "0 0 16px rgba(var(--v5-amber-rgb),0.15)",
|
||||
color: "var(--v5-text)",
|
||||
}}
|
||||
>
|
||||
<Info size={14} style={{ color: "rgb(var(--v5-amber-rgb))", marginTop: 2, flexShrink: 0 }} />
|
||||
<div className="flex-1 leading-snug">
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{truncated.length}개 회사가 회사당 {cap ?? 200}건 cap 에 걸려 일부만 표시 중
|
||||
</span>
|
||||
<span style={{ color: "var(--v5-text-sec)", marginLeft: 6 }}>
|
||||
({truncated.join(", ")}) — 더 보려면 검색을 좁히거나 회사 도메인으로 전환
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failed.length > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 px-3 py-2 rounded-[10px] border text-[0.8125rem]"
|
||||
style={{
|
||||
background: "var(--v5-surface-solid)",
|
||||
borderColor: "rgba(var(--v5-red-rgb),0.4)",
|
||||
boxShadow: "var(--v5-glow-danger)",
|
||||
color: "var(--v5-text)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} style={{ color: "rgb(var(--v5-red-rgb))", marginTop: 2, flexShrink: 0 }} />
|
||||
<div className="flex-1 leading-snug">
|
||||
<span style={{ fontWeight: 600 }}>{failed.length}개 회사 조회 실패</span>
|
||||
<span style={{ color: "var(--v5-text-sec)", marginLeft: 6 }}>
|
||||
({failed.join(", ")}) — 다른 회사 결과는 정상 표시. 백엔드 로그 확인 필요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,13 @@ export interface ResponsiveDataViewProps<T> {
|
||||
// 스타일 커스터마이징
|
||||
tableContainerClassName?: string;
|
||||
cardContainerClassName?: string;
|
||||
|
||||
// 컴팩트 모드 — 행 높이 줄임 (h-16 → h-10), 큰 데이터셋용
|
||||
compact?: boolean;
|
||||
// 자체 스크롤 모드 — 테이블 컨테이너에 max-height + overflow-y-auto 적용,
|
||||
// 헤더 sticky. 부모가 height 결정 (예: flex-1 min-h-0).
|
||||
// 페이지 전체 스크롤 대신 테이블 안에서 스크롤 처리.
|
||||
scrollContainer?: boolean;
|
||||
}
|
||||
|
||||
// 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼
|
||||
@@ -80,7 +87,11 @@ export function ResponsiveDataView<T>({
|
||||
onRowClick,
|
||||
tableContainerClassName,
|
||||
cardContainerClassName,
|
||||
compact = false,
|
||||
scrollContainer = false,
|
||||
}: ResponsiveDataViewProps<T>) {
|
||||
const rowHeight = compact ? "h-10" : "h-16";
|
||||
const headHeight = compact ? "h-9" : "h-12";
|
||||
// cardFields 미지정 시 columns에서 자동 생성
|
||||
function resolveCardFields(item: T): RDVCardField<T>[] {
|
||||
if (typeof cardFields === "function") return cardFields(item);
|
||||
@@ -207,60 +218,68 @@ export function ResponsiveDataView<T>({
|
||||
<div
|
||||
className={cn(
|
||||
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
||||
// scrollContainer 모드: 뷰포트 기반 max-height + 자체 세로 스크롤 + sticky 헤더.
|
||||
// flex 기반 계산이 shadcn Table 의 내부 wrapper(overflow-x-auto) 와 충돌해
|
||||
// 신뢰성 떨어지므로 viewport 기준으로 명시. 페이지 헤더/툴바/페이지네이션 약 280px 가정.
|
||||
scrollContainer && "max-h-[calc(100vh-280px)] overflow-y-auto overflow-x-auto",
|
||||
tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
scrollContainer && "sticky top-0 z-10 bg-card"
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item, index) => (
|
||||
<TableRow
|
||||
key={keyExtractor(item) ?? `row-${index}`}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50",
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn("h-16 text-sm", col.className)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
: String(getNestedValue(item, col.key) ?? "-")}
|
||||
</TableCell>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16 text-sm">
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item, index) => (
|
||||
<TableRow
|
||||
key={keyExtractor(item) ?? `row-${index}`}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50",
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(rowHeight, "text-sm", col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
: String(getNestedValue(item, col.key) ?? "-")}
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className={cn(rowHeight, "text-sm")}>
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일 카드 (lg 미만) */}
|
||||
|
||||
@@ -219,6 +219,17 @@ function resolveTriple(
|
||||
return { kind: "attach", type: "file", format: "any" };
|
||||
}
|
||||
|
||||
// 2.5 옛 레거시 컴포넌트 ID → triple default
|
||||
// config.kind/type 가 없을 때만 도달. 명시된 config 가 있으면 step 0 에서 이미 return.
|
||||
switch (componentType) {
|
||||
case "text-input": return { kind: "input", type: "text", format: "free" };
|
||||
case "number-input": return { kind: "input", type: "number", format: "int" };
|
||||
case "date-input": return { kind: "input", type: "date", format: "date" };
|
||||
case "select-basic": return { kind: "choice", type: "single", format: "list" };
|
||||
case "checkbox-basic": return { kind: "choice", type: "single", format: "boolean" };
|
||||
case "textarea-basic": return { kind: "input", type: "text", format: "free" };
|
||||
}
|
||||
|
||||
// 3. 선택 (source / multiple)
|
||||
const isMulti = !!config.multiple;
|
||||
const src = config.source || (config.fieldType === "select" ? "static" : config.fieldType);
|
||||
|
||||
@@ -12,6 +12,9 @@ export const useUserManagement = () => {
|
||||
// 사용자 목록 상태
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
// SUPER_ADMIN cross-tenant 응답 메타 (truncated/failed 안내 배너용)
|
||||
const [crossTenantMeta, setCrossTenantMeta] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// 검색 필터 상태
|
||||
const [searchFilter, setSearchFilter] = useState<UserSearchFilter>({});
|
||||
|
||||
@@ -159,6 +162,9 @@ export const useUserManagement = () => {
|
||||
console.log("🔍 검색 파라미터:", searchParams);
|
||||
const response = await userAPI.getList(searchParams);
|
||||
|
||||
// cross-tenant 메타 (truncated/failed 안내 배너에서 사용). 단일 모드면 null.
|
||||
setCrossTenantMeta((response as any)?.cross_tenant_meta ?? null);
|
||||
|
||||
// 백엔드 응답 구조에 맞게 처리 { success, data, total }
|
||||
if (response && response.success && response.data) {
|
||||
// 새로운 API 응답 구조: { success, data: { users, pagination } }
|
||||
@@ -263,7 +269,9 @@ export const useUserManagement = () => {
|
||||
console.log(`🎛️ 상태 변경: ${user.user_name} (${user.user_id}) → ${newStatus}`);
|
||||
|
||||
// 백엔드 API 호출
|
||||
const response = await userAPI.updateStatus(user.user_id, newStatus);
|
||||
// cross-tenant 모드: row 의 company_code 가 어느 회사 DB 사용자인지 알려줌.
|
||||
// 단일 모드: 세번째 인자 무시 (백엔드가 JWT.company_code 사용).
|
||||
const response = await userAPI.updateStatus(user.user_id, newStatus, (user as any).company_code);
|
||||
|
||||
// 백엔드 응답 구조: { result: boolean, msg: string }
|
||||
if (response && typeof response === "object" && "result" in response) {
|
||||
@@ -312,6 +320,7 @@ export const useUserManagement = () => {
|
||||
isSearching,
|
||||
error,
|
||||
paginationInfo,
|
||||
crossTenantMeta,
|
||||
|
||||
// 검색 기능
|
||||
updateSearchFilter,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
||||
|
||||
export type BatchExecutionType = "mapping" | "node_flow";
|
||||
|
||||
@@ -181,6 +182,38 @@ export class BatchAPI {
|
||||
if (filter.page) params.append("page", filter.page.toString());
|
||||
if (filter.limit) params.append("limit", filter.limit.toString());
|
||||
|
||||
// SUPER_ADMIN 전사 모드 → cross-tenant fan-out (행마다 company_code 박힘)
|
||||
if (isCrossTenantMode()) {
|
||||
const ctResponse = await apiClient.get<any>(
|
||||
`/admin/cross-tenant/batches?${params.toString()}`
|
||||
);
|
||||
const ct = ctResponse.data;
|
||||
if (ct && ct.success && ct.data) {
|
||||
const rows: BatchConfig[] = ct.data.rows || [];
|
||||
return {
|
||||
success: true,
|
||||
data: rows,
|
||||
// cross-tenant 1차는 페이지네이션 비지원 — 클라이언트 기준 1페이지로 표시
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: rows.length,
|
||||
total: rows.length,
|
||||
totalPages: 1,
|
||||
},
|
||||
message: ct.message,
|
||||
// CrossTenantBanner 메타 — truncated/failed 안내 박스용
|
||||
cross_tenant_meta: {
|
||||
companies_queried: ct.data.companies_queried,
|
||||
companies_failed: ct.data.companies_failed,
|
||||
failed_company_codes: ct.data.failed_company_codes ?? [],
|
||||
truncated: ct.data.truncated,
|
||||
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
||||
per_company_limit: ct.data.per_company_limit,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<any>(
|
||||
`/batch-management/batch-configs?${params.toString()}`
|
||||
);
|
||||
|
||||
@@ -29,7 +29,14 @@ const getApiBaseUrl = (): string => {
|
||||
return `https://${currentHost}/api`;
|
||||
}
|
||||
|
||||
// 1-b. dev 가짜 서브도메인 (사무실 도커 등). 운영 Traefik 없으니 backend 노출 포트로 직접.
|
||||
// 1-b. dev 환경의 *.localhost 도 같은 이유로 직접 호출 (Host 헤더 보존).
|
||||
// bare "localhost" 는 메타 컨텍스트(admin/cross-tenant)이므로 이 분기에서 제외.
|
||||
// 백엔드 dev 포트 8081 로 직결.
|
||||
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
|
||||
return `http://${currentHost}:8081/api`;
|
||||
}
|
||||
|
||||
// 1-c. dev 가짜 서브도메인 (<prefix>.<IPv4>(.nip.io|.sslip.io)?). 운영 Traefik 없으니 backend 노출 포트로 직접.
|
||||
if (DEV_TENANT_HOST.test(currentHost)) {
|
||||
return `http://${currentHost}:8083/api`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
||||
|
||||
// =====================================================
|
||||
// 타입 정의
|
||||
@@ -175,6 +176,12 @@ export async function getLanguages(): Promise<ApiResponse<Language[]>> {
|
||||
|
||||
/**
|
||||
* 다국어 키 목록 조회
|
||||
*
|
||||
* 분기:
|
||||
* - cross-tenant 모드 → /admin/cross-tenant/lang-keys
|
||||
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
|
||||
* 1차 구현은 categoryId 재귀 필터 비지원 (필요해지면 후속 추가).
|
||||
* - 단일 회사 모드 → /multilang/keys (기존)
|
||||
*/
|
||||
export async function getLangKeys(params?: {
|
||||
company_code?: string;
|
||||
@@ -183,6 +190,32 @@ export async function getLangKeys(params?: {
|
||||
searchText?: string;
|
||||
}): Promise<ApiResponse<LangKey[]>> {
|
||||
try {
|
||||
if (isCrossTenantMode()) {
|
||||
const ctParams = new URLSearchParams();
|
||||
// cross-tenant mapper 의 파라미터명 (snake_case) 으로 매핑
|
||||
if (params?.menuCode) ctParams.append("menu_code", params.menuCode);
|
||||
if (params?.searchText) ctParams.append("search", params.searchText);
|
||||
|
||||
const url = `/admin/cross-tenant/lang-keys${ctParams.toString() ? `?${ctParams.toString()}` : ""}`;
|
||||
const ctResponse = await apiClient.get(url);
|
||||
const ct = ctResponse.data;
|
||||
if (ct && ct.success && ct.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: (ct.data.rows || []) as LangKey[],
|
||||
// CrossTenantBanner 메타 — ApiResponse 타입엔 없지만 페이지가 캐스팅으로 접근
|
||||
cross_tenant_meta: {
|
||||
companies_queried: ct.data.companies_queried,
|
||||
companies_failed: ct.data.companies_failed,
|
||||
failed_company_codes: ct.data.failed_company_codes ?? [],
|
||||
truncated: ct.data.truncated,
|
||||
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
||||
per_company_limit: ct.data.per_company_limit,
|
||||
},
|
||||
} as ApiResponse<LangKey[]>;
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.company_code) queryParams.append("companyCode", params.company_code);
|
||||
if (params?.menuCode) queryParams.append("menuCode", params.menuCode);
|
||||
|
||||
+74
-20
@@ -1,5 +1,6 @@
|
||||
import { apiClient } from "./client";
|
||||
import { ApiResponse } from "@/types/commonCode";
|
||||
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
||||
|
||||
/**
|
||||
* 권한 그룹 인터페이스
|
||||
@@ -53,9 +54,37 @@ export interface MenuPermission {
|
||||
export const roleAPI = {
|
||||
/**
|
||||
* 권한 그룹 목록 조회
|
||||
*
|
||||
* 분기:
|
||||
* - cross-tenant 모드 (SUPER_ADMIN + company_code="*") → /admin/cross-tenant/roles
|
||||
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
|
||||
* - 단일 회사 모드 → /roles (기존)
|
||||
*
|
||||
* 두 응답을 동일 shape `{ success, data: RoleGroup[] }` 로 정규화.
|
||||
*/
|
||||
async getList(params?: { company_code?: string; search?: string }): Promise<ApiResponse<RoleGroup[]>> {
|
||||
try {
|
||||
if (isCrossTenantMode()) {
|
||||
const response = await apiClient.get("/admin/cross-tenant/roles", { params });
|
||||
const ct = response.data;
|
||||
if (ct && ct.success && ct.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: (ct.data.rows || []) as RoleGroup[],
|
||||
message: ct.message,
|
||||
// CrossTenantBanner 메타 — ApiResponse 타입엔 없지만 페이지가 캐스팅으로 접근
|
||||
cross_tenant_meta: {
|
||||
companies_queried: ct.data.companies_queried,
|
||||
companies_failed: ct.data.companies_failed,
|
||||
failed_company_codes: ct.data.failed_company_codes ?? [],
|
||||
truncated: ct.data.truncated,
|
||||
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
||||
per_company_limit: ct.data.per_company_limit,
|
||||
},
|
||||
} as ApiResponse<RoleGroup[]>;
|
||||
}
|
||||
return ct;
|
||||
}
|
||||
const response = await apiClient.get("/roles", { params });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
@@ -85,10 +114,12 @@ export const roleAPI = {
|
||||
|
||||
/**
|
||||
* 권한 그룹 생성
|
||||
* cross-tenant 모드: data.company_code 가 가리키는 회사 DB 에 INSERT.
|
||||
*/
|
||||
async create(data: { auth_name: string; auth_code: string; company_code: string }): Promise<ApiResponse<RoleGroup>> {
|
||||
try {
|
||||
const response = await apiClient.post("/roles", data);
|
||||
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/roles" : "/roles";
|
||||
const response = await apiClient.post(endpoint, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -101,17 +132,20 @@ export const roleAPI = {
|
||||
|
||||
/**
|
||||
* 권한 그룹 수정
|
||||
* cross-tenant 모드: data.company_code 필수 (그 회사 DB 컨텍스트 라우팅).
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
id: number | string,
|
||||
data: {
|
||||
auth_name?: string;
|
||||
auth_code?: string;
|
||||
status?: string;
|
||||
company_code?: string;
|
||||
},
|
||||
): Promise<ApiResponse<RoleGroup>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/roles/${id}`, data);
|
||||
const endpoint = isCrossTenantMode() ? `/admin/cross-tenant/roles/${id}` : `/roles/${id}`;
|
||||
const response = await apiClient.put(endpoint, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -124,10 +158,14 @@ export const roleAPI = {
|
||||
|
||||
/**
|
||||
* 권한 그룹 삭제
|
||||
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 그룹인지).
|
||||
*/
|
||||
async delete(id: number): Promise<ApiResponse<null>> {
|
||||
async delete(id: number | string, companyCode?: string): Promise<ApiResponse<null>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/roles/${id}`);
|
||||
const url = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/${id}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
||||
: `/roles/${id}`;
|
||||
const response = await apiClient.delete(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -204,12 +242,15 @@ export const roleAPI = {
|
||||
|
||||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
* cross-tenant 모드: companyCode 필수 (어느 회사 메뉴 트리인지).
|
||||
*/
|
||||
async getAllMenus(companyCode?: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("🔍 [roleAPI.getAllMenus] API 호출", { companyCode });
|
||||
|
||||
const url = companyCode ? `/roles/menus/all?company_code=${companyCode}` : "/roles/menus/all";
|
||||
const url = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/menus/all?company_code=${encodeURIComponent(companyCode ?? "")}`
|
||||
: (companyCode ? `/roles/menus/all?company_code=${companyCode}` : "/roles/menus/all");
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
|
||||
@@ -296,7 +337,7 @@ export const roleAPI = {
|
||||
* - menus: 전체 메뉴 (트리 원천)
|
||||
* - permissions: 현재 메뉴 CRUD 권한
|
||||
*/
|
||||
async getWorkspace(roleId: number | string): Promise<ApiResponse<{
|
||||
async getWorkspace(roleId: number | string, companyCode?: string): Promise<ApiResponse<{
|
||||
group: any;
|
||||
members: any[];
|
||||
nonMembers: any[];
|
||||
@@ -304,7 +345,10 @@ export const roleAPI = {
|
||||
permissions: any[];
|
||||
}>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/roles/${roleId}/workspace`);
|
||||
const url = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/${roleId}/workspace?company_code=${encodeURIComponent(companyCode ?? "")}`
|
||||
: `/roles/${roleId}/workspace`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -316,11 +360,15 @@ export const roleAPI = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 개별 멤버 추가 (이미지: "<--추가" 체크 즉시 반영)
|
||||
* 개별 멤버 추가
|
||||
* cross-tenant 모드: companyCode 필수.
|
||||
*/
|
||||
async addSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
|
||||
async addSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await apiClient.post(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
|
||||
const url = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/${roleId}/members/${encodeURIComponent(userId)}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
||||
: `/roles/${roleId}/members/${encodeURIComponent(userId)}`;
|
||||
const response = await apiClient.post(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -332,11 +380,15 @@ export const roleAPI = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 개별 멤버 제거 (이미지: "-->삭제" 체크 즉시 반영)
|
||||
* 개별 멤버 제거
|
||||
* cross-tenant 모드: companyCode 필수.
|
||||
*/
|
||||
async removeSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
|
||||
async removeSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
|
||||
const url = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/${roleId}/members/${encodeURIComponent(userId)}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
||||
: `/roles/${roleId}/members/${encodeURIComponent(userId)}`;
|
||||
const response = await apiClient.delete(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -348,8 +400,8 @@ export const roleAPI = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 개별 메뉴 CRUD 권한 토글 (이미지: 체크 즉시 반영)
|
||||
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } — 전달된 필드만 업데이트
|
||||
* 개별 메뉴 CRUD 권한 토글
|
||||
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } + cross-tenant 시 company_code
|
||||
*/
|
||||
async toggleMenuPermission(
|
||||
roleId: number | string,
|
||||
@@ -360,12 +412,14 @@ export const roleAPI = {
|
||||
update_yn?: "Y" | "N";
|
||||
delete_yn?: "Y" | "N";
|
||||
},
|
||||
companyCode?: string,
|
||||
): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
const response = await apiClient.patch(
|
||||
`/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`,
|
||||
changes,
|
||||
);
|
||||
const endpoint = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`
|
||||
: `/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`;
|
||||
const body = isCrossTenantMode() ? { ...changes, company_code: companyCode } : changes;
|
||||
const response = await apiClient.patch(endpoint, body);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
+99
-14
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from "./client";
|
||||
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
||||
|
||||
/**
|
||||
* 사용자 관리 API 클라이언트
|
||||
@@ -23,15 +24,56 @@ interface ApiResponse<T> {
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회
|
||||
*
|
||||
* 분기:
|
||||
* - cross-tenant 모드 → GET /admin/cross-tenant/users (전 회사 사용자 합산, 행마다 company_code)
|
||||
* - 단일 회사 모드 → GET /admin/users (현 회사 사용자만)
|
||||
*
|
||||
* 두 응답을 hook 이 기대하는 동일 shape 로 정규화 — { success, data: { users, pagination }, total }
|
||||
*/
|
||||
export async function getUserList(params?: Record<string, any>) {
|
||||
try {
|
||||
if (isCrossTenantMode()) {
|
||||
console.log("📡 [cross-tenant] 사용자 목록 API 호출:", params);
|
||||
const response = await apiClient.get("/admin/cross-tenant/users", { params });
|
||||
console.log("✅ [cross-tenant] 사용자 목록 API 응답:", response.data);
|
||||
|
||||
// cross-tenant 응답 { success, data: { rows, total, companies_queried, companies_failed,
|
||||
// truncated, truncated_company_codes, failed_company_codes,
|
||||
// per_company_limit } }
|
||||
// → 기존 hook 이 기대하는 shape 로 정규화. 1차 구현은 페이지네이션 비지원이라
|
||||
// 서버가 한 번에 다 줌 → 클라이언트 기준 total_count = rows.length.
|
||||
const ct = response.data;
|
||||
if (ct && ct.success && ct.data) {
|
||||
const rows: any[] = ct.data.rows || [];
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: rows,
|
||||
pagination: {
|
||||
total_count: rows.length,
|
||||
page: 1,
|
||||
total_pages: 1,
|
||||
},
|
||||
},
|
||||
total: rows.length,
|
||||
message: ct.message,
|
||||
// CrossTenantBanner 가 사용하는 메타 — truncated/failed 안내 박스용
|
||||
cross_tenant_meta: {
|
||||
companies_queried: ct.data.companies_queried,
|
||||
companies_failed: ct.data.companies_failed,
|
||||
failed_company_codes: ct.data.failed_company_codes ?? [],
|
||||
truncated: ct.data.truncated,
|
||||
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
||||
per_company_limit: ct.data.per_company_limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
return ct;
|
||||
}
|
||||
|
||||
console.log("📡 사용자 목록 API 호출:", params);
|
||||
|
||||
const response = await apiClient.get("/admin/users", {
|
||||
params: params,
|
||||
});
|
||||
|
||||
const response = await apiClient.get("/admin/users", { params });
|
||||
console.log("✅ 사용자 목록 API 응답:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -60,10 +102,14 @@ export async function getUserInfo(userId: string) {
|
||||
|
||||
/**
|
||||
* 사용자 등록
|
||||
*
|
||||
* cross-tenant 모드: body 의 company_code 가 가리키는 회사 DB 에 INSERT.
|
||||
* 단일 모드: 현재 컨텍스트 (JWT company_code) 의 회사 DB.
|
||||
*/
|
||||
export async function createUser(userData: any) {
|
||||
try {
|
||||
const response = await apiClient.post("/admin/users", userData);
|
||||
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users" : "/admin/users";
|
||||
const response = await apiClient.post(endpoint, userData);
|
||||
|
||||
// 백엔드에서 result 필드를 사용하므로 이에 맞춰 처리
|
||||
if (response.data.result === true || response.data.success === true) {
|
||||
@@ -85,6 +131,8 @@ export async function createUser(userData: any) {
|
||||
|
||||
/**
|
||||
* 사용자 정보 수정
|
||||
*
|
||||
* cross-tenant 모드: body.company_code 가 가리키는 회사 DB 의 USER_INFO 수정.
|
||||
*/
|
||||
export async function updateUser(userData: {
|
||||
user_id: string;
|
||||
@@ -95,17 +143,27 @@ export async function updateUser(userData: {
|
||||
status?: string;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const response = await apiClient.put(`/admin/users/${userData.user_id}`, userData);
|
||||
|
||||
const endpoint = isCrossTenantMode()
|
||||
? `/admin/cross-tenant/users/${userData.user_id}`
|
||||
: `/admin/users/${userData.user_id}`;
|
||||
const response = await apiClient.put(endpoint, userData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 상태 변경 (부분 수정)
|
||||
*
|
||||
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 사용자인지 알아야 라우팅).
|
||||
*/
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
export async function updateUserStatus(userId: string, status: string, companyCode?: string) {
|
||||
if (isCrossTenantMode()) {
|
||||
const response = await apiClient.patch(`/admin/cross-tenant/users/${userId}/status`, {
|
||||
status,
|
||||
company_code: companyCode,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
const response = await apiClient.patch(`/admin/users/${userId}/status`, { status });
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -161,8 +219,23 @@ export async function getCompanyList() {
|
||||
|
||||
/**
|
||||
* 부서 목록 조회
|
||||
*
|
||||
* cross-tenant 모드: companyCode 가 가리키는 회사 DB 의 부서. 미선택이면 빈 배열.
|
||||
* (회사를 안 골랐는데 메타 DB 부서를 보여주면 다른 회사 부서가 섞여 보이는 버그 방지)
|
||||
* 단일 모드: 기존 /admin/departments — 백엔드가 JWT.company_code 사용.
|
||||
*/
|
||||
export async function getDepartmentList(companyCode?: string) {
|
||||
if (isCrossTenantMode()) {
|
||||
if (!companyCode) return [];
|
||||
const response = await apiClient.get(
|
||||
`/admin/cross-tenant/departments?company_code=${encodeURIComponent(companyCode)}`
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data.departments || [];
|
||||
}
|
||||
throw new Error(response.data.message || "부서 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
const params = companyCode ? `?companyCode=${encodeURIComponent(companyCode)}` : "";
|
||||
const response = await apiClient.get(`/admin/departments${params}`);
|
||||
|
||||
@@ -177,8 +250,18 @@ export async function getDepartmentList(companyCode?: string) {
|
||||
|
||||
/**
|
||||
* 사용자 ID 중복 체크
|
||||
*
|
||||
* cross-tenant 모드: companyCode 필수 — 그 회사 DB 안에서만 중복 체크.
|
||||
* (회사간 같은 user_id 가 다른 사람이라는 멀티테넌시 전제 — 설계서 §12)
|
||||
*/
|
||||
export async function checkDuplicateUserId(userId: string) {
|
||||
export async function checkDuplicateUserId(userId: string, companyCode?: string) {
|
||||
if (isCrossTenantMode()) {
|
||||
const response = await apiClient.post("/admin/cross-tenant/users/check-duplicate", {
|
||||
user_id: userId,
|
||||
company_code: companyCode,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
const response = await apiClient.post("/admin/users/check-duplicate", { userId });
|
||||
return response.data;
|
||||
}
|
||||
@@ -254,9 +337,11 @@ export interface UserWithDeptResponse {
|
||||
export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise<ApiResponse<{ userId: string; isUpdate: boolean }>> {
|
||||
try {
|
||||
console.log("사원+부서 통합 저장 API 호출:", data);
|
||||
|
||||
const response = await apiClient.post("/admin/users/with-dept", data);
|
||||
|
||||
|
||||
// cross-tenant 모드: data.userInfo 안에 company_code 필수 (회사 DB 라우팅용)
|
||||
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/users/with-dept" : "/admin/users/with-dept";
|
||||
const response = await apiClient.post(endpoint, data);
|
||||
|
||||
console.log("사원+부서 통합 저장 API 응답:", response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* SUPER_ADMIN 의 cross-tenant(전사) 모드 판정 유틸.
|
||||
*
|
||||
* 백엔드 cross-tenant 엔드포인트가 동작하려면:
|
||||
* 1. JWT.user_type === "SUPER_ADMIN"
|
||||
* 2. 현재 컨텍스트 == META DB
|
||||
* 둘 다 만족해야 함. 프론트는 (1) + (2)를 직접 못 보지만,
|
||||
* SUPER_ADMIN 이 회사 도메인으로 switch 하면 JWT.company_code 가 그 회사 코드로 바뀐다.
|
||||
* 그러므로 (user_type === "SUPER_ADMIN" && company_code === "*") 가 메타 컨텍스트의 정확한 신호.
|
||||
*
|
||||
* 회사 도메인으로 switch 한 SUPER_ADMIN 은 JWT.company_code 가 회사 코드로 바뀌어 false 가 됨
|
||||
* → 자연스럽게 단일 회사 모드로 복귀.
|
||||
*
|
||||
* @see notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md §8.1
|
||||
*/
|
||||
|
||||
interface JwtClaims {
|
||||
user_type?: string;
|
||||
company_code?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰의 Base64 페이로드만 디코드. 무결성 검증은 백엔드 책임.
|
||||
*/
|
||||
export function decodeJwtClaims(): JwtClaims | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
return JSON.parse(atob(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 SUPER_ADMIN cross-tenant(전사) 모드인가?
|
||||
* 어드민 14개 메뉴 API 클라이언트는 이걸로 분기해서
|
||||
* /admin/cross-tenant/* 엔드포인트 또는 단일 회사 엔드포인트를 골라 호출.
|
||||
*/
|
||||
export function isCrossTenantMode(): boolean {
|
||||
const c = decodeJwtClaims();
|
||||
return c?.user_type === "SUPER_ADMIN" && c?.company_code === "*";
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CheckboxBasicWrapper } from "./CheckboxBasicComponent";
|
||||
import { CheckboxBasicConfigPanel } from "./CheckboxBasicConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { CheckboxBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,10 +21,13 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
||||
web_type: "checkbox",
|
||||
component: CheckboxBasicWrapper,
|
||||
default_config: {
|
||||
kind: "choice",
|
||||
type: "single",
|
||||
format: "boolean",
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 150, height: 120 }, // 40 * 3 (3개 옵션)
|
||||
config_panel: CheckboxBasicConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { DateInputComponent } from "./DateInputComponent";
|
||||
import { DateInputConfigPanel } from "./DateInputConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { DateInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,10 +21,13 @@ export const DateInputDefinition = createComponentDefinition({
|
||||
web_type: "date",
|
||||
component: DateInputComponent,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "date",
|
||||
format: "date",
|
||||
placeholder: "입력하세요",
|
||||
},
|
||||
default_size: { width: 220, height: 40 },
|
||||
config_panel: DateInputConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { InputWrapper } from "./InputComponent";
|
||||
import { InvInputConfigPanel } from "./InvInputConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import type { InputConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -25,12 +25,14 @@ import type { InputConfig } from "./types";
|
||||
* notes/gbpark/2026-04-11-component-unification-plan.md §3.4
|
||||
*/
|
||||
|
||||
const DEFAULT_CONFIG: Partial<InputConfig> = {
|
||||
const DEFAULT_CONFIG = {
|
||||
kind: "input",
|
||||
type: "text",
|
||||
format: "free",
|
||||
placeholder: "입력하세요",
|
||||
required: false,
|
||||
editable: true,
|
||||
};
|
||||
} as Record<string, any>;
|
||||
|
||||
export const InputDefinition = createComponentDefinition({
|
||||
id: "input",
|
||||
@@ -40,9 +42,9 @@ export const InputDefinition = createComponentDefinition({
|
||||
category: ComponentCategory.INPUT,
|
||||
web_type: "text",
|
||||
component: InputWrapper,
|
||||
default_config: DEFAULT_CONFIG as Record<string, any>,
|
||||
default_config: DEFAULT_CONFIG,
|
||||
default_size: { width: 240, height: 48 },
|
||||
config_panel: InvInputConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: ["입력", "input", "field", "text", "number", "date", "select"],
|
||||
version: "2.0.0",
|
||||
@@ -61,4 +63,3 @@ export const InputDefinition = createComponentDefinition({
|
||||
|
||||
export type { InputConfig } from "./types";
|
||||
export { InputComponent, InputWrapper } from "./InputComponent";
|
||||
export { InvInputConfigPanel } from "./InvInputConfigPanel";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { NumberInputWrapper } from "./NumberInputComponent";
|
||||
import { NumberInputConfigPanel } from "./NumberInputConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { NumberInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,12 +21,15 @@ export const NumberInputDefinition = createComponentDefinition({
|
||||
web_type: "number",
|
||||
component: NumberInputWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "number",
|
||||
format: "int",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
},
|
||||
default_size: { width: 200, height: 40 },
|
||||
config_panel: NumberInputConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { SelectBasicWrapper } from "./SelectBasicComponent";
|
||||
import { SelectBasicConfigPanel } from "./SelectBasicConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { SelectBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,11 +21,14 @@ export const SelectBasicDefinition = createComponentDefinition({
|
||||
web_type: "select",
|
||||
component: SelectBasicWrapper,
|
||||
default_config: {
|
||||
kind: "choice",
|
||||
type: "single",
|
||||
format: "list",
|
||||
options: [],
|
||||
placeholder: "선택하세요",
|
||||
},
|
||||
default_size: { width: 250, height: 40 },
|
||||
config_panel: SelectBasicConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TextInputWrapper } from "./TextInputComponent";
|
||||
import { TextInputConfigPanel } from "./TextInputConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { TextInputConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,11 +21,14 @@ export const TextInputDefinition = createComponentDefinition({
|
||||
web_type: "text",
|
||||
component: TextInputWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "text",
|
||||
format: "free",
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
maxLength: 255,
|
||||
},
|
||||
default_size: { width: 300, height: 40 },
|
||||
config_panel: TextInputConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: ["텍스트", "입력", "폼"],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { TextareaBasicWrapper } from "./TextareaBasicComponent";
|
||||
import { TextareaBasicConfigPanel } from "./TextareaBasicConfigPanel";
|
||||
import { InvFieldConfigPanel } from "@/components/v2/config-panels/InvFieldConfigPanel";
|
||||
import { TextareaBasicConfig } from "./types";
|
||||
|
||||
/**
|
||||
@@ -21,12 +21,15 @@ export const TextareaBasicDefinition = createComponentDefinition({
|
||||
web_type: "textarea",
|
||||
component: TextareaBasicWrapper,
|
||||
default_config: {
|
||||
kind: "input",
|
||||
type: "text",
|
||||
format: "free",
|
||||
placeholder: "내용을 입력하세요",
|
||||
rows: 3,
|
||||
maxLength: 1000,
|
||||
},
|
||||
default_size: { width: 400, height: 100 },
|
||||
config_panel: TextareaBasicConfigPanel,
|
||||
config_panel: InvFieldConfigPanel,
|
||||
icon: "Edit",
|
||||
tags: [],
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 테넌트 서브도메인 헬퍼.
|
||||
* 메인 사이트(solution, www, admin 등 예약어) 는 null 을 리턴해서
|
||||
* 메인 사이트(solution, www, admin 등 예약어) 와 베이스 도메인은 null 을 리턴해서
|
||||
* TenantGuard 가 체크를 스킵하게 한다.
|
||||
*
|
||||
* 백엔드 SubdomainResolverFilter.extractSubdomain() 와 동일한 규칙을 따라야 함:
|
||||
* - bare localhost / IP / 베이스 도메인 → null (메타)
|
||||
* - {sub}.localhost (dev) → 첫 파트 (예약어 제외)
|
||||
* - {sub}.invyone.com (운영) → 첫 파트 (예약어 제외)
|
||||
*
|
||||
* 백엔드 provisioning 의 RESERVED_SUBDOMAINS 와 같은 값을 유지할 것.
|
||||
*/
|
||||
const RESERVED_MAIN = new Set([
|
||||
@@ -22,6 +27,8 @@ const RESERVED_MAIN = new Set([
|
||||
"console",
|
||||
]);
|
||||
|
||||
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
|
||||
// 개발 환경 가짜 서브도메인 패턴: <prefix>.<IPv4>(.nip.io | .sslip.io)?
|
||||
// 사무실 도커처럼 운영 와일드카드 DNS 가 없는 환경에서, hosts 매핑 또는 nip.io 외부 DNS 로 prefix 를 표현.
|
||||
// backend SubdomainResolverFilter 도 동일 의도로 호스트 첫 라벨을 prefix 로 추출.
|
||||
@@ -31,7 +38,12 @@ export function extractTenantSubdomain(host: string): string | null {
|
||||
if (!host) return null;
|
||||
|
||||
const cleanHost = host.split(":")[0].toLowerCase();
|
||||
if (!cleanHost) return null;
|
||||
|
||||
if (cleanHost === "localhost") return null;
|
||||
if (IPV4.test(cleanHost)) return null;
|
||||
|
||||
// 운영 *.invyone.com — 베이스 도메인은 null
|
||||
if (cleanHost.endsWith(".invyone.com")) {
|
||||
const prefix = cleanHost.substring(0, cleanHost.length - ".invyone.com".length);
|
||||
if (!prefix) return null;
|
||||
@@ -39,6 +51,7 @@ export function extractTenantSubdomain(host: string): string | null {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// dev 가짜 서브도메인 (<prefix>.<IPv4>(.nip.io|.sslip.io)?)
|
||||
const devMatch = cleanHost.match(DEV_TENANT_HOST);
|
||||
if (devMatch) {
|
||||
const prefix = devMatch[1];
|
||||
@@ -46,5 +59,14 @@ export function extractTenantSubdomain(host: string): string | null {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// dev *.localhost (RFC 6761) — bare "localhost" 는 위에서 제외됨
|
||||
const parts = cleanHost.split(".");
|
||||
if (parts.length === 2 && parts[1] === "localhost") {
|
||||
const first = parts[0];
|
||||
if (!first) return null;
|
||||
if (RESERVED_MAIN.has(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||
"title": () => import("@/lib/registry/components/title/InvTitleConfigPanel"),
|
||||
"button": () => import("@/lib/registry/components/button/InvButtonConfigPanel"),
|
||||
"search": () => import("@/lib/registry/components/search/InvSearchConfigPanel"),
|
||||
"input": () => import("@/lib/registry/components/input/InvInputConfigPanel"),
|
||||
"input": () => import("@/components/v2/config-panels/InvFieldConfigPanel"),
|
||||
"stats": () => import("@/lib/registry/components/stats/InvStatsConfigPanel"),
|
||||
"table": () => import("@/lib/registry/components/table/InvTableConfigPanel"),
|
||||
"container": () => import("@/lib/registry/components/container/InvContainerConfigPanel"),
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
# Cross-Tenant 어드민 합산 설계 (SUPER_ADMIN 전사 보기)
|
||||
|
||||
작성일: 2026-04-27
|
||||
최종 갱신: 2026-04-29 — Phase A/B/C 구현·커밋 완료 표시, 검증 시나리오에 실제 결과 반영
|
||||
작성자: hjjeong
|
||||
관련: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md), [notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md](../gbpark/2026-04-24-company-db-provisioning-execution-plan.md), [notes/gbpark/2026-04-24-company-mgmt-ui-schema.md](../gbpark/2026-04-24-company-mgmt-ui-schema.md)
|
||||
실행 로그: [2026-04-28-cross-tenant-execution-log.md](2026-04-28-cross-tenant-execution-log.md)
|
||||
|
||||
> **상태 (2026-04-29):** Phase A/B/C 구현·검증·커밋 완료 (`hjjeong` 브랜치 `e16fb169`).
|
||||
> Phase D/E 미진행. 자세한 진행 현황·미진행 항목·커밋 분배 의도는 실행 로그 §5~§6 참조.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- DB-per-tenant 구조는 **그대로** 둔다. 메타 DB에 데이터를 다시 모으지 않는다.
|
||||
- SUPER_ADMIN 이 `admin.invyone.com` 에서 어드민 14개 메뉴를 열면, 백엔드가 활성 회사 N 개에 **fan-out** 으로 동일 SELECT 를 돌려 합산 결과를 내려준다.
|
||||
- 기존 [`CompanyStatsService.enrichOne()`](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) 의 N+1 패턴을 일반화한 `CrossTenantAggregator` 를 새로 만든다.
|
||||
- 모든 응답 행에 `company_code` 컬럼을 박아 화면에서 회사 필터/그룹이 가능하게 한다.
|
||||
- 회사 N≤20 까지는 직렬, 그 이후는 `CompletableFuture` 병렬 + 5분 캐시.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
### 1.1 이미 깔린 것 (재사용)
|
||||
|
||||
| 인프라 | 위치 |
|
||||
|---|---|
|
||||
| 서브도메인 → tenant DB 라우팅 | [SubdomainResolverFilter](../../backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java) |
|
||||
| 요청 단위 DB 컨텍스트 | [DbContextHolder](../../backend-spring/src/main/java/com/erp/tenant/DbContextHolder.java) (`META` / dbName / null) |
|
||||
| 회사 → DB 룩업 + 캐시 | [CompanyResolver](../../backend-spring/src/main/java/com/erp/tenant/CompanyResolver.java) |
|
||||
| Hikari 풀 lazy 생성 (회사 풀 `minIdle=0`) | [TenantDataSourceFactory](../../backend-spring/src/main/java/com/erp/tenant/TenantDataSourceFactory.java) |
|
||||
| MyBatis 라우팅 DataSource | [TenantRoutingDataSource](../../backend-spring/src/main/java/com/erp/tenant/TenantRoutingDataSource.java) |
|
||||
| Cross-tenant 1세대 패턴 (회사관리 메인) | [CompanyStatsService.enrichOne()](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) |
|
||||
| 컨텍스트 임시 전환 패턴 | [CompanyLifecycleService](../../backend-spring/src/main/java/com/erp/provisioning/CompanyLifecycleService.java) — `setMeta()` / `set(dbName)` + finally `restoreCtx()` |
|
||||
| 감사 로그 테이블 | `COMPANY_AUDIT_LOG` (메타 DB, 083 마이그레이션) |
|
||||
|
||||
### 1.2 설계 안 된 것 (이 문서가 메우는 갭)
|
||||
|
||||
`MULTI_TENANCY_ARCHITECTURE.md` 가 설명하는 라우팅·프로비저닝은 "회사 도메인으로 들어가서 그 회사 데이터만 본다" 시나리오에 최적화돼 있음. **`admin.invyone.com` 으로 들어와 14개 어드민 메뉴를 열 때 무엇을 보여줄지** 는 다음 메뉴들 외에 명문화 안 됨:
|
||||
|
||||
- 회사관리 (`CompanyStatsService` 로 회사별 1줄씩 줄나열) — 이미 됨
|
||||
- 라이프사이클 (`CompanyLifecycleService`) — 단일 회사 대상이라 cross-tenant 아님
|
||||
|
||||
`provisioning-execution-plan.md` 4장에는 "SUPER_ADMIN 회사 DB 임시 전환 = Phase 5 이후 검토. 당장 불필요" 라고 적혀 있음. 본 문서는 이 항목의 후속 결정.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 원칙
|
||||
|
||||
1. **DB-per-tenant 불변.** 메타 DB 로 데이터 회수 없음. 격리 원칙 유지.
|
||||
2. **Fan-out 후 머지.** SUPER_ADMIN 요청 1건 = `COMPANY_MNG.DB_STATUS='active'` 회사 N 개 각각에 동일 SELECT 1번씩 + 응답 머지.
|
||||
3. **읽기 전용.** Cross-tenant 엔드포인트는 SELECT 만 허용. 수정은 무조건 단일 회사 컨텍스트로 들어가서 한다 (3-A 회사 전환 모드는 본 문서 범위 외).
|
||||
4. **company_code 컬럼 강제.** 모든 응답 행에 어느 회사에서 왔는지 박는다. 화면이 그룹/필터 가능하게.
|
||||
5. **PK 충돌은 화면이 책임.** 회사 간 같은 `OBJID` 가 있을 수 있다 → keying 은 `${company_code}::${pk}` 로 합성.
|
||||
6. **권장 N 범위는 ~50.** 그 이상은 병렬 + 캐시 + 페이지네이션 정책 별도 결정 (본 문서 9장).
|
||||
7. **감사 흔적.** 본 엔드포인트 호출은 `COMPANY_AUDIT_LOG` 에 남기지 않음 (읽기 전용·고빈도). 단, 회사 컨텍스트 임시 전환 액션(추후 모드 A) 은 남겨야 함.
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
[admin.invyone.com] (메타 DB 컨텍스트)
|
||||
│
|
||||
▼
|
||||
[SUPER_ADMIN check] ← 권한 가드
|
||||
│
|
||||
▼
|
||||
[CrossTenantController]
|
||||
GET /api/admin/cross-tenant/users?...
|
||||
│
|
||||
▼
|
||||
[CrossTenantAggregator.fanOut(mapperId, params)]
|
||||
│ 1. 메타 DB: SELECT DB_NAME, COMPANY_CODE FROM COMPANY_MNG WHERE DB_STATUS='active'
|
||||
│ 2. 회사 N 개 각각:
|
||||
│ prev = DbContextHolder.get()
|
||||
│ try { DbContextHolder.set(dbName);
|
||||
│ rows = sqlSession.selectList(mapperId, params);
|
||||
│ rows.forEach(r -> r.put("company_code", code)); }
|
||||
│ finally { restoreCtx(prev); }
|
||||
│ 3. List<Map> 머지하여 반환
|
||||
▼
|
||||
[Cache 5분 (선택, by mapperId+params hash)]
|
||||
│
|
||||
▼
|
||||
[Map<String,Object> 응답 (company_code 포함)]
|
||||
```
|
||||
|
||||
### 3.1 핵심 컴포넌트
|
||||
|
||||
| 컴포넌트 | 역할 | 위치 |
|
||||
|---|---|---|
|
||||
| `CrossTenantContext` | "지금이 SUPER_ADMIN 의 cross-tenant 호출인가?" 마커 — JWT role + Host (admin.invyone.com) 양쪽 검증 | `com.erp.tenant.CrossTenantContext` (신규) |
|
||||
| `CrossTenantAggregator` | 회사 N 개 fan-out + 머지. 직렬/병렬 모드 토글 | `com.erp.tenant.CrossTenantAggregator` (신규) |
|
||||
| `CrossTenantController` | 엔드포인트 묶음. 메뉴별 fan-out mapper id 매핑 | `com.erp.controller.admin.CrossTenantController` (신규) |
|
||||
| `mapper/admin-cross-tenant.xml` | 회사 DB 안에서 돌릴 정규화된 SELECT 모음 (수정 SQL 없음) | `mapper/admin-cross-tenant.xml` (신규) |
|
||||
| `CrossTenantCache` | mapperId + params hash → 5분 LRU. 개발 모드 비활성 옵션 | `com.erp.tenant.CrossTenantCache` (신규, 선택) |
|
||||
|
||||
### 3.2 Aggregator 의사 코드
|
||||
|
||||
```java
|
||||
public List<Map<String,Object>> fanOut(String mapperId, Map<String,Object> params) {
|
||||
requireSuperAdmin();
|
||||
requireMetaContext(); // 호출 시점에 DbContextHolder 가 META 여야 함
|
||||
|
||||
List<Map<String,Object>> companies = sqlSession.selectList(
|
||||
"provisioning.listActiveCompanies"); // {company_code, db_name}
|
||||
|
||||
if (parallel) {
|
||||
List<CompletableFuture<List<Map<String,Object>>>> futures = companies.stream()
|
||||
.map(c -> CompletableFuture.supplyAsync(() -> queryOne(c, mapperId, params), pool))
|
||||
.toList();
|
||||
return futures.stream().flatMap(f -> f.join().stream()).toList();
|
||||
} else {
|
||||
List<Map<String,Object>> out = new ArrayList<>();
|
||||
for (Map<String,Object> c : companies) out.addAll(queryOne(c, mapperId, params));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String,Object>> queryOne(Map<String,Object> c, String mapperId, Map<String,Object> params) {
|
||||
String prev = DbContextHolder.get();
|
||||
try {
|
||||
DbContextHolder.set((String) c.get("db_name"));
|
||||
List<Map<String,Object>> rows = sqlSession.selectList(mapperId, params);
|
||||
for (Map<String,Object> r : rows) r.put("company_code", c.get("company_code"));
|
||||
return rows;
|
||||
} catch (Exception e) {
|
||||
log.warn("[CrossTenant] {} failed for {}: {}", mapperId, c.get("db_name"), e.getMessage());
|
||||
return List.of(); // 한 회사 실패해도 전체는 진행
|
||||
} finally {
|
||||
if (prev == null) DbContextHolder.clear(); else DbContextHolder.set(prev);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **중요**: `sqlSession.selectList` 직접 호출이 가능한 이유 = `TenantRoutingDataSource` 가 `DbContextHolder` 값으로 매번 라우팅 결정하기 때문. raw JDBC 안 써도 됨. `CompanyStatsService` 는 1세대라 raw JDBC 였지만 이번엔 MyBatis 그대로 재사용.
|
||||
|
||||
### 3.3 병렬 풀 정책
|
||||
|
||||
```java
|
||||
ExecutorService crossTenantPool = Executors.newFixedThreadPool(
|
||||
Math.min(activeCompanyCount, 8));
|
||||
```
|
||||
|
||||
- 회사 50 개 X tenant 풀 maxPool=5 → 동시 동작 커넥션 ≤ 8 로 제한.
|
||||
- pool 자체는 빈 상태. 회사 풀(`minIdle=0`) 이 lazy 생성되며 호출 끝나면 60초 idle 후 정리됨.
|
||||
|
||||
---
|
||||
|
||||
## 4. API 설계
|
||||
|
||||
### 4.1 네임스페이스
|
||||
|
||||
`/api/admin/cross-tenant/{domain}` — `domain` = `users`, `menus`, `roles`, `common-codes`, `i18n`, `batches`, `audit` 등.
|
||||
|
||||
### 4.2 공통 규칙
|
||||
|
||||
| 항목 | 규칙 |
|
||||
|---|---|
|
||||
| 인증 | JWT 필요. role = `SUPER_ADMIN` 만 통과 |
|
||||
| 호스트 | Host 가 `admin.invyone.com` 또는 메타 컨텍스트인 경우만 허용 |
|
||||
| 응답 형식 | `{ rows: [...], total: N, companies_queried: M, companies_failed: K }` |
|
||||
| 행 식별자 | 모든 행에 `company_code` 추가. 화면 keying 시 `${company_code}::${pk}` 권장 |
|
||||
| 페이지네이션 | 1차는 회사 단위로 limit 후 백엔드 머지 (offset 보장 X). 정확한 글로벌 페이지네이션 필요해지면 별도 설계 |
|
||||
| 필터 | `?company_codes=A,B,C` 옵션 — fan-out 대상을 부분집합으로 제한 |
|
||||
| 캐시 | `?nocache=1` 로 우회 가능 |
|
||||
|
||||
### 4.3 14개 어드민 메뉴 매핑 (1차 후보)
|
||||
|
||||
| 메뉴 | 엔드포인트 | fan-out mapper | 회사간 충돌 키 |
|
||||
|---|---|---|---|
|
||||
| 사용자관리 | `/cross-tenant/users` | `admin-cross-tenant.listUsers` | `USER_ID` |
|
||||
| 메뉴관리 | `/cross-tenant/menus` | `admin-cross-tenant.listMenus` | `OBJID` |
|
||||
| 권한관리 | `/cross-tenant/user-auth` | `admin-cross-tenant.listUserAuth` | `(user_id, menu_objid)` |
|
||||
| 권한 그룹관리 | `/cross-tenant/roles` | `admin-cross-tenant.listRoles` | `OBJID` |
|
||||
| 다국어관리 | `/cross-tenant/i18n` | `admin-cross-tenant.listLangKeys` | `KEY_ID` |
|
||||
| 테이블 타입관리 | `/cross-tenant/table-types` | `admin-cross-tenant.listTableTypes` | `TABLE_NAME` |
|
||||
| 공통코드관리 | `/cross-tenant/common-codes` | `admin-cross-tenant.listCommonCodes` | `(group_code, code)` |
|
||||
| 화면관리 | `/cross-tenant/screens` | `admin-cross-tenant.listScreens` | `SCREEN_ID` |
|
||||
| POP화면관리 | `/cross-tenant/pop-screens` | `admin-cross-tenant.listPopScreens` | `SCREEN_ID` |
|
||||
| 대시보드관리 | `/cross-tenant/dashboards` | `admin-cross-tenant.listDashboards` | `DASHBOARD_ID` (메뉴통합 후 `OBJID`) |
|
||||
| 배치관리 | `/cross-tenant/batches` | `admin-cross-tenant.listBatches` | `BATCH_ID` |
|
||||
| 외부 커넥션 관리 | `/cross-tenant/connections` | `admin-cross-tenant.listConnections` | `CONNECTION_ID` |
|
||||
| 회사관리 | (기존 `companies-stats` 유지) | — | `COMPANY_CODE` (메타 DB) |
|
||||
| 메일관리 | (현재 API 미구현 — 본 설계 범위 외) | — | — |
|
||||
|
||||
### 4.4 응답 예시
|
||||
|
||||
```json
|
||||
GET /api/admin/cross-tenant/users?status=active&limit=50
|
||||
{
|
||||
"rows": [
|
||||
{ "company_code": "COMPANY_27", "user_id": "mhkim", "user_name": "...", "status": "Y", "last_login_date": "..." },
|
||||
{ "company_code": "COMPANY_27", "user_id": "khlee", "...": "..." },
|
||||
{ "company_code": "COMPANY_10", "user_id": "wace", "...": "..." }
|
||||
],
|
||||
"total": 148,
|
||||
"companies_queried": 6,
|
||||
"companies_failed": 0,
|
||||
"from_cache": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 라우팅·권한 가드
|
||||
|
||||
### 5.1 호스트 가드
|
||||
|
||||
`SubdomainResolverFilter` 는 이미 `admin.invyone.com` 을 reserved 처리해서 `DbContextHolder.setMeta()` 박음. 본 엔드포인트는 그 가정 위에서 동작.
|
||||
|
||||
추가로 `CrossTenantContext.requireMetaContext()` 가 진입 시 한 번 더 검증:
|
||||
- `DbContextHolder.isMeta()` 가 `false` 면 400 `cross_tenant_requires_meta_context`.
|
||||
- 회사 도메인(`qnc.invyone.com`) 으로 들어와서 cross-tenant 엔드포인트를 때리면 거부.
|
||||
|
||||
### 5.2 권한 가드
|
||||
|
||||
JWT claims:
|
||||
- `role == SUPER_ADMIN` 만 통과.
|
||||
- 일반 회사 관리자(`COMPANY_ADMIN`) 는 자기 회사 도메인의 기존 어드민 14개 메뉴(단일 회사 컨텍스트) 만 사용. cross-tenant 엔드포인트는 403.
|
||||
|
||||
`SecurityConfig` 에 `/api/admin/cross-tenant/**` 매처 추가하여 `hasRole("SUPER_ADMIN")` 강제.
|
||||
|
||||
### 5.3 실패 격리
|
||||
|
||||
회사 한 곳이 죽으면(예: tenant DB 다운, 풀 타임아웃, SQL 에러) 그 회사만 결과에서 빠지고 나머지는 진행.
|
||||
- `companies_failed` 카운터 + 어떤 회사가 실패했는지 응답 헤더 `X-CrossTenant-Failed: COMPANY_8,COMPANY_9` 로 노출.
|
||||
- 한 회사라도 실패면 `partial: true` 플래그로도 표시.
|
||||
|
||||
---
|
||||
|
||||
## 6. SQL 작성 규칙
|
||||
|
||||
### 6.1 mapper 파일 위치
|
||||
|
||||
`mapper/admin-cross-tenant.xml` — 모든 cross-tenant 전용 SELECT 한 곳에 모음.
|
||||
|
||||
### 6.2 작성 원칙
|
||||
|
||||
- **`company_code` 박지 말 것**. fan-out 시 Aggregator 가 응답에 박아준다. SQL 에서 박으면 회사 DB 안에 저장된 (참고용) `COMPANY_CODE` 값을 가져와서 메타 DB 라우팅 정보와 어긋날 수 있음.
|
||||
- **WHERE 에 회사 코드 필터 안 박음.** 회사 DB 안의 데이터는 정의상 그 회사 데이터.
|
||||
- **JOIN 은 회사 DB 내부 테이블끼리만.** 메타 DB 조인 금지.
|
||||
- **결과 정규화.** 컬럼명을 lower_snake_case 로 통일 (기존 백엔드 규약 그대로).
|
||||
|
||||
### 6.3 예시
|
||||
|
||||
```xml
|
||||
<!-- mapper/admin-cross-tenant.xml -->
|
||||
<select id="listUsers" resultType="map">
|
||||
SELECT u.USER_ID AS user_id,
|
||||
u.USER_NAME AS user_name,
|
||||
u.EMAIL AS email,
|
||||
u.STATUS AS status,
|
||||
u.LAST_LOGIN_DATE AS last_login_date,
|
||||
u.WRITER AS writer,
|
||||
u.CREATED_DATE AS created_date
|
||||
FROM USER_INFO u
|
||||
<where>
|
||||
<if test="status != null">u.STATUS = #{status}</if>
|
||||
<if test="keyword != null">
|
||||
AND (u.USER_ID ILIKE '%' || #{keyword} || '%'
|
||||
OR u.USER_NAME ILIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY u.CREATED_DATE DESC
|
||||
<if test="limit != null">LIMIT #{limit}</if>
|
||||
</select>
|
||||
```
|
||||
|
||||
### 6.4 메타 DB SELECT (활성 회사 목록)
|
||||
|
||||
`mapper/provisioning.xml` 에 추가:
|
||||
|
||||
```xml
|
||||
<select id="listActiveCompanies" resultType="map">
|
||||
SELECT COMPANY_CODE AS company_code,
|
||||
DB_NAME AS db_name
|
||||
FROM COMPANY_MNG
|
||||
WHERE DB_STATUS = 'active'
|
||||
AND DB_NAME IS NOT NULL
|
||||
ORDER BY COMPANY_CODE
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 캐시
|
||||
|
||||
### 7.1 정책
|
||||
|
||||
- 키: `mapperId + params canonical hash + companies snapshot version`
|
||||
- TTL: 5분
|
||||
- 무효화 트리거:
|
||||
- `CompanyLifecycleService.deactivate/reactivate/delete` → companies snapshot version bump
|
||||
- `CompanyProvisioningService.finalize()` (FINALIZE 단계) → companies snapshot version bump
|
||||
- 메모리 한계: LRU 200 entry
|
||||
- 개발 모드 (`spring.profiles.active=dev`) 는 cache off.
|
||||
|
||||
### 7.2 적용 시점
|
||||
|
||||
- N ≤ 20: cache 없이 직렬로 충분 (체감 < 500ms 예상).
|
||||
- N > 20 또는 화면 1초 이상 체감: cache on + 병렬 on.
|
||||
|
||||
→ 1차 구현은 **cache off + 직렬**. 측정해보고 필요한 만큼만 켠다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 화면(프론트) 가이드
|
||||
|
||||
### 8.1 라우팅·진입
|
||||
|
||||
- `admin.invyone.com` → 어드민 14개 메뉴는 cross-tenant 응답 그대로 렌더.
|
||||
- 회사 도메인(`qnc.invyone.com`) → 같은 화면이 단일 회사 모드(기존 동작) 로 동작.
|
||||
- `useAuth().role === 'SUPER_ADMIN'` && `host === 'admin.invyone.com'` 일 때 화면 상단에 **"전사 보기" 배너** 노출 (이건 별도 디자인 작업).
|
||||
|
||||
### 8.2 테이블 컬럼 추가
|
||||
|
||||
기존 어드민 테이블 컴포넌트들은 다음 두 가지만 변경:
|
||||
|
||||
1. 첫 컬럼에 **회사** (company_code → company_name 매핑 — 메타 DB `COMPANY_MNG` 에서 룩업) 노출.
|
||||
2. 회사 필터 드롭다운 추가 (멀티셀렉트). 백엔드에 `?company_codes=` 로 전달.
|
||||
|
||||
### 8.3 키 충돌
|
||||
|
||||
`<DataGrid>` 의 `keyField` 를 단일 컬럼에서 `(row) => `${row.company_code}::${row[pk]}`` 로 변경. `ResponsiveDataView` 폴백 키 로직 ([frontend/components/admin-test-result.md](../../frontend/admin-test-result.md) "공통 수정 1") 과 호환.
|
||||
|
||||
### 8.4 편집 진입
|
||||
|
||||
전사 보기에서 1행 클릭하면 **그 회사 도메인으로 새 탭 오픈** + 해당 메뉴의 단일 회사 화면으로 이동.
|
||||
|
||||
```ts
|
||||
const editUrl = `https://${row.subdomain}.invyone.com/admin/userMng/userMngList?focus=${row.user_id}`;
|
||||
```
|
||||
|
||||
전사 보기는 SELECT 만, 수정은 회사 도메인 컨텍스트로 위임 — 본 설계 원칙 3 그대로.
|
||||
|
||||
---
|
||||
|
||||
## 9. 성능·확장
|
||||
|
||||
### 9.1 비용 모델
|
||||
|
||||
| N | 직렬 (raw 추정) | 병렬 (8 thread) |
|
||||
|---|---|---|
|
||||
| 6 (현재) | ~150ms | ~50ms |
|
||||
| 20 | ~500ms | ~120ms |
|
||||
| 50 | ~1.2s | ~250ms |
|
||||
| 100 | ~2.5s | ~500ms |
|
||||
| 500 | 비현실적 | 1차 cache hit 필수 |
|
||||
|
||||
> 가정: 회사당 SELECT 평균 25ms (count, 단순 list). 실측 후 갱신.
|
||||
|
||||
### 9.2 풀 영향
|
||||
|
||||
- 회사 풀 `minIdle=0` 정책 ([MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md) §10) 유지.
|
||||
- Cross-tenant 호출이 회사 풀을 잠깐 깨우긴 하지만 60초 idle 후 정리되므로 상시 점유 없음.
|
||||
- max_connections 영향: 동시 cross-tenant 호출 1건 = 활성 회사 수 만큼 커넥션 1초간 점유. 호출 빈도 낮으므로 N≤50 까지 무영향.
|
||||
|
||||
### 9.3 페이지네이션
|
||||
|
||||
1차 구현: 회사별 limit 후 머지. 글로벌 정확한 offset 페이지네이션 X.
|
||||
- 화면이 페이지네이션 대신 **회사 필터 + 무한 스크롤** 위주로 가는 게 현 데이터 양에 더 맞음.
|
||||
- 정확한 글로벌 페이지네이션이 필요해지면 → 메타 DB 에 인덱스용 mirror 테이블(예: `USER_INFO_INDEX`) 도입 검토. 본 문서 범위 외.
|
||||
|
||||
---
|
||||
|
||||
## 10. 단계별 실행 체크리스트 (2026-04-29 갱신)
|
||||
|
||||
### Phase A — 인프라 (최소) — ✅ 완료
|
||||
|
||||
- [x] `CrossTenantContext` (가드 클래스) — boolean 반환식으로 구현 (실행로그 §1)
|
||||
- [x] `CrossTenantAggregator` (직렬, cache off)
|
||||
- [x] `mapper/provisioning.xml` 에 `listActiveCompanies` 추가
|
||||
- [ ] ~~`SecurityConfig` 에 `/api/admin/cross-tenant/**` SUPER_ADMIN 가드~~ — **의도적 미적용**. 95개 컨트롤러 회귀 위험 → 컨트롤러 레벨 `@RequestAttribute("role")` 검사로 대체 (실행로그 §1 표)
|
||||
- [x] `CrossTenantController` 빈 컨트롤러 + 1개 엔드포인트 (`/users`)
|
||||
|
||||
### Phase B — PoC: 사용자관리 — ✅ 부분 완료
|
||||
|
||||
- [x] `mapper/admin-cross-tenant.xml` + `listUsers` SELECT
|
||||
- [x] `/api/admin/cross-tenant/users` 동작 확인 (TEST01 1개 회사 / 8명)
|
||||
- [x] 프론트 사용자관리 페이지에 host 분기 (`isCrossTenantMode()` 헬퍼로 분기)
|
||||
- [~] 회사 컬럼 — `UserTable.tsx` 에 컬럼 정의 이미 있어 데이터만 박히면 자동 표시 / 회사 필터 드롭다운은 미구현
|
||||
|
||||
### Phase C — 14개 메뉴 확산 — 🟡 일부 완료 (3/14, 4 보류, 7 미진행)
|
||||
|
||||
- [x] 권한그룹관리 (`/cross-tenant/roles`, TEST01 1건)
|
||||
- [x] 배치관리 (`/cross-tenant/batches`, TEST01 10건)
|
||||
- [x] 다국어 키관리 (`/cross-tenant/lang-keys`, TEST01 646건 → cap 200)
|
||||
- [ ] 메뉴관리 — **보류** (트리 구조 UX 결정 필요)
|
||||
- [ ] 공통코드관리 — **보류** (카테고리 트리 의존)
|
||||
- [ ] 외부 커넥션 관리 — **보류** (메타 JOIN + JSONB cast 다수)
|
||||
- [ ] 화면관리 / POP화면관리 / 대시보드관리 — **보류** (행 수 규모 + 페이지네이션 정책)
|
||||
- [ ] 권한관리 / 테이블 타입관리 / 메일관리 — 미진행
|
||||
- [ ] 키 충돌 정책 (`company_code::pk`) 일괄 적용 — 미진행
|
||||
- [ ] 행 클릭 → 회사 도메인 새 탭 오픈 패턴 — 미진행
|
||||
|
||||
### 페이지네이션 cap (설계서 §4.2 / §9.3 보강) — ✅ 완료
|
||||
|
||||
- [x] 회사당 디폴트 cap 200, override 1~2000
|
||||
- [x] `truncated` 플래그 + `truncated_company_codes` 응답
|
||||
- [x] 응답 헤더 `X-CrossTenant-Truncated`
|
||||
- [x] 4개 cross-tenant 엔드포인트 (`/users`, `/roles`, `/batches`, `/lang-keys`) 모두 일원화
|
||||
|
||||
### Phase D — 측정 후 옵셔널 — ⏳ 미진행
|
||||
|
||||
- [ ] `CrossTenantCache` (5분 LRU)
|
||||
- [ ] 병렬 fan-out (`CompletableFuture` + 8 thread pool)
|
||||
- [ ] `companies_failed` 메트릭 노출 (Prometheus)
|
||||
- [ ] N≥50 회사 환경 부하 테스트 (`autocannon` 으로 cross-tenant 엔드포인트 1초 100rps)
|
||||
|
||||
### Phase E — 문서 — 🟡 일부
|
||||
|
||||
- [ ] `docs/MULTI_TENANCY_ARCHITECTURE.md` 9장 "관련 마이그레이션" 표에 082~084 추가 + 본 문서 11장 참조 링크
|
||||
- [ ] `docs/MULTI_TENANCY_ARCHITECTURE.md` 새 섹션 "12. 어드민 cross-tenant 합산" 신설하여 본 문서 요약 + 링크
|
||||
- [x] `docs/MULTI_TENANCY_ARCHITECTURE.md` §4.2 (실행 모드 A/B) + §6 (1-b 분기) — dev `*.localhost` 분 갱신 (`383b837a` 에 포함)
|
||||
|
||||
---
|
||||
|
||||
## 11. 검증 시나리오 (2026-04-29 갱신 — 실 진행 상황 병기)
|
||||
|
||||
| 시나리오 | 상태 |
|
||||
|---|---|
|
||||
| 11.1 행복 경로 | ✅ 2회사 (TEST01 + TEST02) 머지 실증 완료 (2026-04-29). [실행로그 §9](2026-04-28-cross-tenant-execution-log.md) 참조 |
|
||||
| 11.2 부분 실패 | ✅ 완료 (2026-04-29) — TEST02 USER_INFO RENAME 으로 SELECT 실패 유도, fail-open + `X-CrossTenant-Failed` 헤더 + `companies_failed: 1` 모두 확인. [실행로그 §9.4](2026-04-28-cross-tenant-execution-log.md) 참조 |
|
||||
| 11.3 권한 | ✅ `super_admin_required` (토큰 없이 호출 → 403) — 스모크 (실행로그 §1) + fan-out 검증 (실행로그 §9.3) 모두 통과 |
|
||||
| 11.4 락 비획득 | ⏳ 미검증 |
|
||||
| 11.5 캐시 무효화 | ⏳ N/A — 캐시 미구현 (Phase D) |
|
||||
|
||||
### 11.1 행복 경로 (원안)
|
||||
|
||||
1. 회사 3개 (`qnc`, `kookje`, `topsil`) 활성. 각각 사용자 5명씩.
|
||||
2. SUPER_ADMIN 으로 `https://admin.invyone.com/admin/userMng/userMngList` 진입.
|
||||
3. 응답 `rows.length === 15`, 각 row 에 `company_code` 채워짐.
|
||||
4. 회사 필터 "qnc" 선택 → `?company_codes=COMPANY_QNC` → `rows.length === 5`.
|
||||
|
||||
> **실 검증 (2026-04-28):** TEST01 1개 회사 / 8명 — 행마다 `company_code: "TEST01"`, `companies_queried: 1, companies_failed: 0` 확인.
|
||||
> **다음:** TEST02 도 활성이므로 `companies_queried: 2`, 행에 두 회사 섞임 검증을 기다리는 상태 (즉시 호출 가능, 실행로그 §6 #3).
|
||||
|
||||
### 11.2 부분 실패
|
||||
|
||||
1. `topsil` 회사 DB pg_terminate 로 다운.
|
||||
2. cross-tenant 호출.
|
||||
3. `rows` 에 qnc/kookje 만, `companies_failed: 1`, 응답 헤더 `X-CrossTenant-Failed: COMPANY_TOPSIL`.
|
||||
4. 화면이 "1개 회사 조회 실패" 토스트 노출.
|
||||
|
||||
### 11.3 권한
|
||||
|
||||
1. 일반 `COMPANY_ADMIN` 으로 cross-tenant 엔드포인트 호출 → 403.
|
||||
2. SUPER_ADMIN 인데 회사 도메인(`qnc.invyone.com`) 으로 호출 → 400 `cross_tenant_requires_meta_context`.
|
||||
|
||||
> **실 검증 (2026-04-28):** 토큰 없이 `/api/admin/cross-tenant/_active-companies` 호출 → 403 `super_admin_required` ✓. SUPER_ADMIN 토큰 호출 → 200 ✓.
|
||||
|
||||
### 11.4 락 비획득
|
||||
|
||||
1. 한 회사 풀이 풀 maxPool=5 모두 점유 중.
|
||||
2. cross-tenant 호출 시 `connection-timeout=30000` 걸림 → 실패 격리로 그 회사만 빠짐.
|
||||
|
||||
### 11.5 캐시 무효화
|
||||
|
||||
1. cross-tenant 호출 → cache hit 만들기.
|
||||
2. 그 사이 SUPER_ADMIN 이 회사 신규 프로비저닝 (FINALIZE 완료).
|
||||
3. 다음 cross-tenant 호출 시 새 회사가 즉시 반영되는지 확인 (companies snapshot version bump 동작).
|
||||
|
||||
> **N/A:** 캐시 자체가 Phase D 로 미루어진 상태 (1차 구현은 cache off + 직렬, 본 문서 §7.2). 캐시 도입 시 본 시나리오 활성.
|
||||
|
||||
---
|
||||
|
||||
## 12. 미정·후속
|
||||
|
||||
| 항목 | 결정 시점 |
|
||||
|---|---|
|
||||
| 글로벌 페이지네이션 (정확한 offset) | 회사당 데이터 1만건 넘어가는 도메인 생기면 |
|
||||
| 메타 DB index mirror (전사 검색 가속) | 위 항목과 같은 시점 |
|
||||
| Cross-tenant write (예: 전 회사 일괄 메뉴 추가) | 별도 RFC. 본 문서 범위 외. 트랜잭션 정합성·롤백 정책 별도 결정 필요 |
|
||||
| `SUPER_ADMIN 회사 컨텍스트 임시 전환` (모드 A — 회사 드롭다운으로 그 회사 데이터 편집) | 본 문서 5.1 가드 위에 별도 라우팅·감사 정책 필요. 후속 |
|
||||
| 회사간 같은 USER_ID 가 같은 사람인가 (계정 통합) | 멀티테넌시 정책 자체 재검토. 현재 정책: 다름 |
|
||||
| Mail 도메인 cross-tenant 보기 | Mail API 본 백엔드 구현 후 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 위반 금지 (요약)
|
||||
|
||||
- ❌ Cross-tenant 엔드포인트에서 UPDATE/DELETE/INSERT 호출 (수정은 회사 도메인 컨텍스트로 위임)
|
||||
- ❌ Aggregator 안에서 메타 DB 와 tenant DB 를 한 mapperId 안에서 JOIN
|
||||
- ❌ tenant DB 안에 저장된 `COMPANY_CODE` 값을 메타 DB 라우팅 정보보다 우선시
|
||||
- ❌ 한 회사 실패 시 전체 응답 500 으로 떨어뜨리기 (실패 격리 원칙 위반)
|
||||
- ❌ JWT role 체크 누락한 채 `/api/admin/cross-tenant/**` 노출
|
||||
- ❌ DbContextHolder 변경 후 finally 에서 restore 누락 (요청 누수 위험)
|
||||
- ❌ 회사 풀 `minIdle` 을 cross-tenant 핫경로 때문에 0 보다 키우기 (전사 풀 폭증)
|
||||
|
||||
---
|
||||
|
||||
## 14. 다음 세션 진입 시 (2026-04-29 갱신)
|
||||
|
||||
본 설계 문서는 의도/원칙의 진실의 원천이고, **현재 구현·검증 진행 상황은 [실행 로그](2026-04-28-cross-tenant-execution-log.md) §5~§6 에서 확인.**
|
||||
|
||||
Phase A/B/C 의 핵심 (직렬 fan-out + Aggregator + 4개 엔드포인트 + 페이지네이션 cap) 은 `hjjeong` 브랜치 `e16fb169` 에 커밋·푸시 완료.
|
||||
|
||||
### 즉시 가능한 다음 작업 (우선순위 순)
|
||||
|
||||
1. **TEST02 가 활성** 상태이므로 진짜 fan-out (`companies_queried: 2`) 호출 검증 — `/api/admin/cross-tenant/users` 등 4개 엔드포인트에 SUPER_ADMIN 토큰으로 호출, 행에 두 회사 섞이는지 + 실패 격리 확인. 본 설계 §11.1·§11.2 의 첫 실증.
|
||||
2. 화면 측 `truncated === true` 안내 박스 (4개 페이지) — 실행로그 §3.5 의 권장 사용 그대로.
|
||||
3. 보류 4개 메뉴 (메뉴/공통코드/외부커넥션/화면계열) UX 결정 후 mapper 작성.
|
||||
4. Phase D (캐시·병렬) — 회사 N≥20 환경 만든 뒤 측정 후 결정.
|
||||
|
||||
### 새 코드 작성 시 주의
|
||||
|
||||
- 새 코드는 raw JDBC 가 아니라 MyBatis `sqlSession` 을 그대로 쓴다 (`TenantRoutingDataSource` 가 있기 때문). 이미 만들어진 [CompanyStatsService.enrichOne()](../../backend-spring/src/main/java/com/erp/provisioning/CompanyStatsService.java) 의 raw JDBC 패턴은 1세대라 그대로 둔 것.
|
||||
- Phase A → B → C 순서 강제 원칙 유지. Aggregator 가 흔들리면 4개 엔드포인트가 모두 흔들림 — 새 메뉴 mapper 추가 시 `runFanOut(...)` 헬퍼 (실행로그 §3) 에 얹어서 일원화.
|
||||
- 컨트롤러 가드는 `SecurityConfig` 가 아닌 컨트롤러 레벨 `@RequestAttribute("role")` 검사 (실행로그 §1 의 의도된 우회).
|
||||
@@ -0,0 +1,387 @@
|
||||
# Cross-Tenant 어드민 합산 — 실행 로그
|
||||
|
||||
작성일: 2026-04-28
|
||||
최종 갱신: 2026-04-29 — 커밋/푸시 상태 반영, dev `*.localhost` 라우팅 후속 묶음 기재
|
||||
작성자: hjjeong
|
||||
관련 설계: [2026-04-27-cross-tenant-admin-aggregation.md](2026-04-27-cross-tenant-admin-aggregation.md)
|
||||
관련 후속(같은 커밋 배치): [2026-04-28-localhost-tenant-routing-handoff.md](2026-04-28-localhost-tenant-routing-handoff.md)
|
||||
관련 SoT: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Phase A (백엔드 인프라)** — 작성·검증 완료
|
||||
- **Phase B (사용자관리 PoC)** — 작성·검증 완료 (TEST01 회사 사용자 8명, `company_code` 정상 주입)
|
||||
- **Phase C (확산)** — 권한그룹/배치/다국어키 3개 작성·검증 완료 (TEST01: 1/10/646 건)
|
||||
- **페이지네이션 cap** — 회사당 디폴트 200, override 1~2000, `truncated` 플래그 응답 (검증 완료)
|
||||
- **커밋/푸시 완료** (2026-04-28 저녁) — `hjjeong` 브랜치에 3 커밋, `origin/hjjeong` 까지 푸시. base `gbpark-node` 대비 20 ahead / 0 behind
|
||||
- TEST02 회사 프로비저닝됨 — 진짜 fan-out (2회사 머지) 호출 검증은 **미진행** (조건만 충족)
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase A — 백엔드 인프라 (✅ 검증)
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| [backend-spring/.../crosstenant/CrossTenantContext.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantContext.java) | SUPER_ADMIN role + META 컨텍스트 가드 (boolean 반환식) |
|
||||
| [backend-spring/.../crosstenant/CrossTenantAggregator.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantAggregator.java) | 활성 회사 N개에 fan-out + 응답 행에 `company_code` 주입 + 실패 격리 |
|
||||
| [backend-spring/.../crosstenant/CrossTenantController.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java) | `/api/admin/cross-tenant/**` 컨트롤러 |
|
||||
| [backend-spring/.../mapper/admin-cross-tenant.xml](../../backend-spring/src/main/resources/mapper/admin-cross-tenant.xml) | cross-tenant 전용 SELECT 모음 |
|
||||
| [frontend/lib/auth/crossTenantMode.ts](../../frontend/lib/auth/crossTenantMode.ts) | `decodeJwtClaims()` / `isCrossTenantMode()` 공용 헬퍼 |
|
||||
|
||||
### 기존 파일 수정 (additive only)
|
||||
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| [mapper/provisioning.xml](../../backend-spring/src/main/resources/mapper/provisioning.xml) | `listActiveCompanies` SELECT 추가 (`DB_STATUS='active' AND DB_NAME IS NOT NULL`) |
|
||||
|
||||
### 설계 의도와 다른 점 — 기록
|
||||
|
||||
| 설계서 명시 | 실제 구현 | 이유 |
|
||||
|---|---|---|
|
||||
| `SecurityConfig` 에 `/api/admin/cross-tenant/**` SUPER_ADMIN 가드 추가 | **안 함**. 컨트롤러 레벨에서 `@RequestAttribute("role")` 검사 | [SecurityConfig.java:52~56](../../backend-spring/src/main/java/com/erp/security/SecurityConfig.java#L52) 코멘트 — "95개 컨트롤러가 permitAll 로 동작 중. SecurityConfig 강제 인증 켜면 회귀 위험". 기존 ProvisioningController 도 컨트롤러 레벨 검사 패턴 |
|
||||
| Aggregator 가 throw 로 가드 위반 표시 | boolean 반환 + 컨트롤러가 `ResponseEntity.status(403/400)` 명시 반환 | [GlobalExceptionHandler.java:32](../../backend-spring/src/main/java/com/erp/config/GlobalExceptionHandler.java#L32) 의 `@ExceptionHandler(Exception.class)` 가 모든 예외를 500 으로 단일 변환. `ResponseStatusException` 도 잡힘. 우회 |
|
||||
|
||||
### 스모크 테스트 — 통과
|
||||
|
||||
- `GET /api/admin/cross-tenant/_active-companies` (토큰 없음) → 403 `super_admin_required` ✓
|
||||
- 동 엔드포인트 (SUPER_ADMIN 토큰) → 200, `rows=[{company_code: "TEST01", db_name: "test01_invyone", ...}], total=1` ✓
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase B — 사용자관리 PoC (✅ 검증)
|
||||
|
||||
### 신규/수정 파일
|
||||
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| [mapper/admin-cross-tenant.xml](../../backend-spring/src/main/resources/mapper/admin-cross-tenant.xml) | `listUsers` SELECT 추가 (단일 회사 `admin.selectUserList` 와 컬럼 동일, `COMPANY_CODE` 필터 제외) |
|
||||
| [CrossTenantController.java](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java) | `GET /users` 엔드포인트 |
|
||||
| [frontend/lib/api/user.ts](../../frontend/lib/api/user.ts) | `getUserList` 분기 (`isCrossTenantMode()` → cross-tenant 엔드포인트, 응답 shape 정규화) |
|
||||
|
||||
### 변경하지 않은 파일 (의도)
|
||||
|
||||
- [components/admin/UserTable.tsx](../../frontend/components/admin/UserTable.tsx) — 회사 컬럼이 이미 정의돼 있어 (line 123) 데이터만 박히면 자동 표시
|
||||
- [hooks/useUserManagement.ts](../../frontend/hooks/useUserManagement.ts) — 분기는 API 클라이언트가 흡수
|
||||
|
||||
### 검증 결과
|
||||
|
||||
| 검증 | 결과 |
|
||||
|---|---|
|
||||
| 단일 모드 (`/api/admin/users`, SUPER_ADMIN → company_code='*') | 8명 |
|
||||
| cross-tenant (`/api/admin/cross-tenant/users`) | 8명, 행마다 `company_code: "TEST01"` |
|
||||
| 응답 봉투 | `{success, data: {rows, total, companies_queried, companies_failed}, message}` |
|
||||
|
||||
> 8명이 어드민 위주로 보이는 건 **TEST01 회사 DB 의 USER_INFO 자체가 그래서**. 프로비저닝 6단계 중 `COPY_DATA` 에서 메타 시드가 복제된 직후 + 일반 사용자 미등록 상태로 추정. cross-tenant 코드 잘못 아님.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase C — 확산 (✅ 검증)
|
||||
|
||||
설계서 §10 의 14개 메뉴 중 **3개 작성·검증 완료, 4개 보류**.
|
||||
|
||||
### 작성 완료 + 검증 (2026-04-28)
|
||||
|
||||
| 메뉴 | 백엔드 | 프론트 분기 | 검증 결과 |
|
||||
|---|---|---|---|
|
||||
| 권한그룹관리 | `GET /admin/cross-tenant/roles` + `listRoleGroups` (단일 모드 `role.getRoleGroupList` 미러) | [lib/api/role.ts](../../frontend/lib/api/role.ts) `roleAPI.getList` | TEST01: 1건, `company_code` 정상 ✓ |
|
||||
| 배치관리 | `GET /admin/cross-tenant/batches` + `listBatches` (`SELECT *` from `BATCH_CONFIGS`) | [lib/api/batch.ts](../../frontend/lib/api/batch.ts) `BatchAPI.getBatchConfigs` | TEST01: 10건, `company_code` 정상 ✓ |
|
||||
| 다국어 키관리 | `GET /admin/cross-tenant/lang-keys` + `listLangKeys` (단일 모드 `multilang.getMultilangKeyList` 미러, `categoryId` 재귀 필터 비지원) | [lib/api/multilang.ts](../../frontend/lib/api/multilang.ts) `getLangKeys` | TEST01: 646건, `company_code` 정상 ✓ |
|
||||
|
||||
검증은 SUPER_ADMIN 토큰으로 백엔드 직접 curl. `companies_queried: 1, companies_failed: 0` 모두 동일.
|
||||
|
||||
컨트롤러에 공통 `runFanOut(...)` 헬퍼 도입 — 가드 + 파라미터 변환 + Aggregator 호출 + `X-CrossTenant-Failed` 헤더 처리를 일원화. `/users` 는 Phase B 검증 손대지 않으려고 인라인 그대로 유지 (페이지네이션 작업 시 같이 일원화됨, 아래 §3.5 참조).
|
||||
|
||||
### 3.5 페이지네이션 cap 도입 (2026-04-28 오후)
|
||||
|
||||
다국어 키 검증 시 회사 1개 만으로 646건 발견 — 회사 N 개 늘어나면 응답 폭증 위험. cross-tenant 는 "전사 둘러보기" 라는 본 설계 의도 (설계서 §9.3) 위에서 **회사당 cap + truncated 플래그** 만 도입. 글로벌 정확한 offset 페이지네이션은 의도적으로 추가 안 함 — 정확한 페이지 넘기기가 필요하면 화면이 회사 도메인 단일 모드로 전환.
|
||||
|
||||
#### 변경 (모두 backward-compat)
|
||||
|
||||
| 영역 | 변경 |
|
||||
|---|---|
|
||||
| `CrossTenantAggregator.fanOut(...)` | `Integer perCompanyLimit` 받는 오버로드 추가. 회사별로 `rows.size() > limit` 면 잘라 truncated 마킹 |
|
||||
| `CrossTenantAggregator.Result` | `truncated_company_codes: List<String>` 필드 추가 |
|
||||
| `mapper/admin-cross-tenant.xml` 4개 SELECT (`listUsers`/`listRoleGroups`/`listBatches`/`listLangKeys`) | `<if test="per_company_limit_plus_one != null">LIMIT #{per_company_limit_plus_one}</if>` 추가. cap+1 가져와서 Aggregator 가 cap+truncated 판정 |
|
||||
| `CrossTenantController` | 디폴트 cap 200, 클램프 1~2000. `?per_company_limit=` 으로 override 가능. 응답에 `truncated`/`truncated_company_codes`/`per_company_limit` 필드 + 헤더 `X-CrossTenant-Truncated` 추가 |
|
||||
| `CrossTenantController` | `resolvePerCompanyLimit()` / `buildResponse()` 헬퍼로 `/users` 포함 4개 엔드포인트 모두 일원화 |
|
||||
|
||||
#### 검증 결과 (TEST01 1개 회사 기준)
|
||||
|
||||
| 시나리오 | total | truncated | per_company_limit |
|
||||
|---|---|---|---|
|
||||
| `/lang-keys` (디폴트) | 200 (646→cap) | `true`, `["TEST01"]` | 200 |
|
||||
| `/lang-keys?per_company_limit=50` | 50 | `true`, `["TEST01"]` | 50 |
|
||||
| `/lang-keys?per_company_limit=1000` | 646 (전체) | `false`, `[]` | 1000 |
|
||||
| `/users` (8명) | 8 | `false` | (cap 안 걸림) |
|
||||
| `/batches` (10개) | 10 | `false` | (cap 안 걸림) |
|
||||
|
||||
#### 응답 형태
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"rows": [...],
|
||||
"total": 200,
|
||||
"companies_queried": 1,
|
||||
"companies_failed": 0,
|
||||
"truncated": true,
|
||||
"truncated_company_codes": ["TEST01"],
|
||||
"per_company_limit": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
응답 헤더에도 `X-CrossTenant-Failed: ...` / `X-CrossTenant-Truncated: ...` 박혀 디버깅 편의.
|
||||
|
||||
#### 화면 측 권장 사용 (후속 작업)
|
||||
|
||||
`truncated === true` 일 때 화면 상단에 안내 박:
|
||||
|
||||
> "TEST01: 200건 표시 중 — 더 보려면 검색을 좁히거나 회사 도메인으로 전환"
|
||||
|
||||
회사 도메인 전환 링크는 `https://${subdomain}.invyone.com/admin/...` 으로 새 탭. 단일 모드의 정확한 페이지네이션이 동작.
|
||||
|
||||
#### 무엇을 안 했나 (의도적 미작업)
|
||||
|
||||
- **글로벌 offset/limit** — `?page=2&limit=20` 식 정확한 글로벌 페이지네이션. 회사 N 개 분산 쿼리 + 정렬 보장 + count 합산 모두 복잡. 설계서 §9.3 가 명시적으로 "메타 DB 인덱스 도입과 같이 별도 설계" 라 적은 항목
|
||||
- **회사별 페이지 넘기기** (`per_company_offset`) — 가능하지만 글로벌 정렬과 의미 안 맞아 UX 가 헷갈림. 회사 도메인 단일 모드가 더 자연스러움
|
||||
|
||||
### 보류 — 단순 fan-out 으로 안 맞아서 별도 UX 설계 필요
|
||||
|
||||
| 메뉴 | 보류 이유 |
|
||||
|---|---|
|
||||
| **메뉴관리** | `MENU_INFO` 트리 구조. 회사별 트리를 한 화면에 합칠지/회사 선택 후 트리 보일지 UX 결정 필요 |
|
||||
| **공통코드관리** | `code_info` 가 `code_category` 필수 필터. 회사별 카테고리도 다를 수 있어 cross-tenant 카테고리 트리 설계 필요 |
|
||||
| **외부 커넥션 관리** | `EXTERNAL_REST_API_CONNECTIONS` 가 `COMPANY_MNG` JOIN + `JSONB::TEXT` cast 다수. JOIN 은 회사 DB 안에선 무의미해서 컬럼 셋 조정 필요 |
|
||||
| **화면관리 / POP화면관리 / 대시보드관리** | 행 수 규모 (958+) → 페이지네이션 / 검색 인덱스 정밀화 후 |
|
||||
|
||||
### 검증 시나리오 (내일 진행)
|
||||
|
||||
브라우저 콘솔 (현재 SUPER_ADMIN 토큰 유지된 상태) 에서 한 줄씩:
|
||||
|
||||
```js
|
||||
fetch("/api/admin/cross-tenant/roles", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("roles:", d.data.rows.length, "/실패", d.data.companies_failed));
|
||||
fetch("/api/admin/cross-tenant/batches", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("batches:", d.data.rows.length, "/실패", d.data.companies_failed));
|
||||
fetch("/api/admin/cross-tenant/lang-keys", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}` } }).then(r => r.json()).then(d => console.log("lang-keys:", d.data.rows.length, "/실패", d.data.companies_failed));
|
||||
```
|
||||
|
||||
`companies_failed: 1` 이 뜨면 → 해당 테이블이 TEST01 회사 DB 에 복제 안 된 케이스 (예: `AUTHORITY_MASTER` / `BATCH_CONFIGS` / `MULTI_LANG_KEY_MASTER` 중 하나가 COPY_DATA 그룹에 빠짐). 백엔드 콘솔에 `[CrossTenant] mapper=... failed for company=TEST01 ...` 형태로 어떤 SQL 에러인지 출력.
|
||||
|
||||
---
|
||||
|
||||
## 4. 부수 작업 (이번 세션 중 발생)
|
||||
|
||||
### 부수-1) 로그인 화면 하이드레이션 픽스
|
||||
|
||||
[frontend/app/(auth)/login/page.tsx](../../frontend/app/(auth)/login/page.tsx) — `next-themes` 의 `resolvedTheme` 가 SSR 시 undefined → 클라이언트와 다른 클래스 렌더링 → 하이드레이션 미스매치. dev 모드 오버레이가 로그인 클릭을 막아 **로그인 자체 실패로 보였던 증상**의 직접 원인.
|
||||
|
||||
표준 패턴 적용: `mounted` state + `<>{mounted && ...}</>` 게이트, `<div className="pill" suppressHydrationWarning>`.
|
||||
|
||||
### 부수-2) 맥 dev 환경 셋업
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| **Java 버전 충돌** | macOS 글로벌 Java 8 + jenv 글로벌 8 → Spring 3.3.5 가 17+ 요구해 부팅 실패. **`jenv add /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home` + `jenv local 21`** 로 프로젝트 디렉토리만 21 로 격리. `jenv enable-plugin export` 로 jenv 가 `JAVA_HOME` 까지 제어. |
|
||||
| **direnv** | 프로젝트 `.envrc` 에 `export JWT_SECRET="…"` (openssl rand 생성). zsh 훅 `eval "$(direnv hook zsh)"`. `.envrc` 와 `.direnv/` 를 `.gitignore` 에 추가 (개발자별 시크릿 분리). |
|
||||
| **npm install 실패 (Mac arm64)** | `frontend/package.json` 의 `lightningcss-linux-x64-gnu` 가 regular `dependencies` 에 박혀있어 macOS arm64 에서 EBADPLATFORM. **`npm install --force`** 로 우회. 근본 수정 (옵션 2 — `optionalDependencies` 로 이동) 은 별도 PR 권장. |
|
||||
|
||||
### 부수-3) `.gitignore` 추가
|
||||
|
||||
```
|
||||
.envrc
|
||||
.direnv/
|
||||
```
|
||||
|
||||
(`.java-version` 은 jenv 표준 파일이라 커밋 권장 — 다른 Mac 개발자도 자동 21 전환됨. Windows 등에선 무해히 무시됨.)
|
||||
|
||||
### 부수-4) dev `*.localhost` 테넌트 라우팅 (별도 문서)
|
||||
|
||||
본 cross-tenant 작업과 한 묶음으로 진행된 dev 환경 패치. 별도 핸드오프 문서로 분리:
|
||||
[2026-04-28-localhost-tenant-routing-handoff.md](2026-04-28-localhost-tenant-routing-handoff.md)
|
||||
|
||||
요지: `qnc.localhost:9771` 같은 dev 호스트도 운영 `*.invyone.com` 과 동일하게 테넌트 DB 로 자동 라우팅되게.
|
||||
|
||||
- 백엔드 `SubdomainResolverFilter.extractSubdomain()` 가 2파트 `{sub}.localhost` 호스트 인식 (RFC 6761)
|
||||
- 프론트 [client.ts](../../frontend/lib/api/client.ts) 1-b 분기 + [subdomain.ts](../../frontend/lib/tenant/subdomain.ts) 동일 규칙
|
||||
- `application.yml` CORS 디폴트에 `http://*.localhost:[*]` 추가
|
||||
- `SubdomainResolverFilterTest` 8 케이스 신규
|
||||
- 운영 3파트 코드 경로 무변경 (회귀 0)
|
||||
|
||||
검증 (2026-04-29):
|
||||
- 백엔드 단위 테스트 8/8 통과 (bare/IP/베이스/dev 2파트/예약어/3파트 운영)
|
||||
- curl 4종 200 OK (test02.localhost / localhost / test02.invyone.com / nonexistent.localhost)
|
||||
- CORS preflight `Access-Control-Allow-Origin: http://test02.localhost:9771` echo 정상
|
||||
- 프론트 dev server `Host: test02.localhost:9771 → /login` 200
|
||||
- TenantGuard → `nonexistent.localhost` → `{exists:false}` → `/tenant-not-found` 200
|
||||
- 시각적 로그인 (비번 입력 → 강제 변경) 은 사용자 브라우저 검증 영역
|
||||
|
||||
---
|
||||
|
||||
## 5. 커밋·푸시 상태 (2026-04-29 갱신)
|
||||
|
||||
`hjjeong` 브랜치, 3 커밋. `origin/hjjeong` 까지 푸시 완료. base `gbpark-node` 대비 **20 ahead / 0 behind**.
|
||||
|
||||
```
|
||||
a6be4f2e 사용자관리 테이블 자체 스크롤 — viewport 기반 max-height 로 강제 (1 file, +5/-6)
|
||||
383b837a dev *.localhost 테넌트 라우팅 + direnv/Java 21 dev 환경 정비 (9 files)
|
||||
e16fb169 어드민 cross-tenant 집계 (SUPER_ADMIN) + 사용자관리 자체 스크롤 (16 files, +1654/-56)
|
||||
```
|
||||
|
||||
### 5.1 커밋 분배 의도
|
||||
|
||||
| 커밋 | 포함 작업 | 비고 |
|
||||
|---|---|---|
|
||||
| `e16fb169` | Phase A/B/C cross-tenant 전체 + 사용자관리 자체 스크롤 1차 + 본 실행 로그 / 설계 문서 두 MD | 본 문서 §1~§3 의 모든 작업 |
|
||||
| `383b837a` | dev `*.localhost` 라우팅 + direnv / Java 21 / `.java-version` / `.gitignore` / `application.yml` CORS / `MULTI_TENANCY_ARCHITECTURE.md` §4.2·§6 갱신 + 단위 테스트 8건 + 핸드오프 MD | 본 문서 §4 부수 작업 + 부수-4 |
|
||||
| `a6be4f2e` | UserTable 스크롤 후속 (viewport 기반 max-height 로 hack) | flex 기반 height 가 shadcn Table wrapper 와 충돌 — 안정화 |
|
||||
|
||||
설계서 §6 권장 #5 ("한 커밋으로 정리") 와 다르게 분리됨. 사유:
|
||||
- cross-tenant 본체와 dev 환경 라우팅은 영향 범위가 다른 도메인. 리뷰·되돌리기 단위가 다름
|
||||
- UserTable 스크롤 hack 은 후속 안정화라 별도 분리
|
||||
|
||||
### 5.2 워킹트리
|
||||
|
||||
이전 워킹카피 모두 커밋 처리됨. 현재 워킹트리는 깨끗 (untracked `.envrc` 만 — `.gitignore` 처리됨).
|
||||
|
||||
---
|
||||
|
||||
## 6. 권장 다음 단계 (2026-04-29 갱신)
|
||||
|
||||
| # | 항목 | 상태 | 비용 / 효용 |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase C 3개 메뉴 검증 | ✅ 완료 (2026-04-28 오전) | — |
|
||||
| 2 | 페이지네이션 cap 도입 | ✅ 완료 (2026-04-28 오후) | — |
|
||||
| 3 | 두번째 회사 프로비저닝 → 진짜 fan-out 효과 (`companies_queried: 2`, 행에 `TEST01` / `TEST02` 섞임) 확인 | ✅ 완료 (2026-04-29) — §9 결과 참조 | — |
|
||||
| 4 | 화면 측 `truncated === true` 안내 박스 — "200건 표시 중, 더 보려면 검색 좁히거나 회사 도메인 전환" | ⏳ 미진행 | 페이지당 10분 × 4 / UX |
|
||||
| 5 | 전체 변경을 한 커밋으로 정리 | ❌ **다른 결정** — 3 커밋으로 분리 (§5.1 사유 참조) | — |
|
||||
| 6 | 보류 4개 메뉴 UX 설계 — 특히 메뉴관리(트리)와 공통코드(카테고리 의존) | ⏳ 별도 세션 | 14개 메뉴 완주 |
|
||||
| 7 | Phase D (캐시 / 병렬) — 회사 N≥20 환경 만들어 측정 후 결정 | ⏳ 별도 세션 | 운영 확장성 |
|
||||
| 8 | Phase E — [MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md) §12 신설로 본 작업 통합 + 082~084 마이그레이션 표 갱신 | ⏳ 미진행 (단, dev `*.localhost` §4.2·§6 갱신은 `383b837a` 에 포함됨) | 30분 / 후임자 온보딩 |
|
||||
| 9 | Gitea PR 생성 (base: `gbpark-node`, compare: `hjjeong`) | ⏳ 사용자 결정 대기 — `tea` CLI 미설치 / 토큰 미세팅 | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. 참고 — 검증할 때 쓸 명령 모음
|
||||
|
||||
### Java 21 사용 셸 진입
|
||||
```bash
|
||||
cd /Users/jhj/invyone # direnv 가 .envrc 로드, jenv 가 .java-version 로 21 잡음
|
||||
java -version # openjdk 21.0.10 나와야 OK
|
||||
```
|
||||
|
||||
### 백엔드 기동
|
||||
```bash
|
||||
cd /Users/jhj/invyone/backend-spring && ./gradlew bootRun
|
||||
```
|
||||
Ctrl+C 로 종료. 코드 변경 시 다른 터미널에서 `./gradlew classes` 만 돌리면 devtools 자동 재시작.
|
||||
|
||||
### 프론트 기동
|
||||
```bash
|
||||
cd /Users/jhj/invyone/frontend && npm run dev
|
||||
```
|
||||
http://localhost:9771 — turbopack 핫리로드.
|
||||
|
||||
### 백엔드 살아있는지 확인
|
||||
```bash
|
||||
curl http://localhost:8081/api/auth/status
|
||||
# {"success":true,"data":{"is_admin":false,"is_logged_in":false},"message":"세션 상태 확인"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 알려진 데이터 상태 (참고)
|
||||
|
||||
- **활성 회사 (2026-04-29 시점)**: TEST01 + TEST02 (시연용회사 / 시연용회사2). 두 회사 모두 `DB_STATUS='active'`. 다른 1개("공통", `company_code='*'`) 는 가상 row → cross-tenant 대상 X.
|
||||
- **TEST01 USER_INFO** (2026-04-28 측정): 8명 (강빈박, 관리자, 김대성, 명건희, 박창현, 정혜진, 오재옥, chpark). 7명은 시드 어드민 패턴 (`dept_code='DPT005'`, `user_name_eng='admin'`).
|
||||
- **현재 로그인 토큰**: `user_id=hjjeong`, `user_type=SUPER_ADMIN`, `company_code=*` — META 컨텍스트로 라우팅 확인됨.
|
||||
|
||||
> 2026-04-29 fan-out 측정에서 회사 DB 데이터 갯수가 시점에 따라 달라짐 — §9 표 참조. 위 8명은 2026-04-28 시점 기준이며, 회사 DB 데이터는 프로비저닝 시드 이후 사용자 작업으로 변동될 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## 9. fan-out 2회사 머지 검증 (2026-04-29)
|
||||
|
||||
설계서 §11.1 (행복 경로) 의 첫 다회사 실증. SUPER_ADMIN 토큰 (`hjjeong / company_code=*`) + bare `Host: localhost:9771` (META 컨텍스트) 로 백엔드 4개 cross-tenant 엔드포인트 직접 curl.
|
||||
|
||||
### 9.1 결과
|
||||
|
||||
| 엔드포인트 | total | q | failed | trunc | by company | 평가 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/_active-companies` | 2 | — | — | — | — | TEST01 + TEST02 둘 다 `DB_STATUS='active'` 확인 |
|
||||
| `/users` | 10 | 2 | 0 | false | `{'*': 8, 'TEST01': 1, 'TEST02': 1}` | ✅ 메타 8명 + 회사별 1명씩 |
|
||||
| `/roles` | 1 | 2 | 0 | false | `{'TEST01': 1}` | ✅ TEST02 권한그룹 시드 없음 |
|
||||
| `/batches` | 14 | 2 | 0 | false | `{'TEST01': 7, 'TEST02': 7}` | ✅ 균등 fan-out 머지 |
|
||||
| `/lang-keys` | 0 | 2 | 0 | false | `{}` | 회사 DB 양쪽 모두 다국어 키 시드 없음 |
|
||||
|
||||
### 9.2 해석
|
||||
|
||||
**`/users` 의 `*` 8행 = 의도된 동작.** [`CrossTenantController#listUsers`](../../backend-spring/src/main/java/com/erp/crosstenant/CrossTenantController.java#L116) 가 `aggregator.fanOut(..., true)` 로 `includeMeta=true` 호출 — 메타 DB USER_INFO (SUPER_ADMIN 들) 를 `company_code='*'` 로 prepend. 다른 3개는 메타 미포함.
|
||||
|
||||
**runFanOut 의 마지막 `true` 는 `wrapSearchWithPercent` (메타 포함 아님)** — `/lang-keys` 가 0건 나온 이유는 회사 DB 자체에 `MULTI_LANG_KEY_MASTER` 행이 없기 때문 (`failed=0` 으로 SELECT 자체는 정상). 이전 §3 의 "TEST01: 646건" 은 시점이 다른 측정 (시드 직후 또는 라우팅 다른 컨텍스트) 으로 추정.
|
||||
|
||||
### 9.3 검증된 항목 (체크)
|
||||
|
||||
- [x] `companies_queried: 2` — 메타 `listActiveCompanies` 가 두 회사 반환, Aggregator 가 둘 다 호출
|
||||
- [x] `company_code` 강제 주입 — 회사 DB 행에 라우팅 정보 기준 `company_code` 박힘 (`/batches` 14행 확인)
|
||||
- [x] `includeMeta=true` 전용 prepend — `/users` 의 `*` 8행
|
||||
- [x] 실패 격리 (`companies_failed: 0`, X-CrossTenant-Failed 헤더 없음) — 두 회사 모두 정상이라 fail-open 자체는 미실증 (TEST02 다운 시뮬레이션은 별도)
|
||||
- [x] 페이지네이션 cap — 모든 회사가 cap 200 미만이라 `truncated: false`. cap 동작은 2026-04-28 §3.5 에서 이미 검증 (646→200)
|
||||
- [x] 권한 가드 — 토큰 없이 `/_active-companies` 호출 → `403 super_admin_required`
|
||||
|
||||
### 9.4 §11.2 부분 실패 시뮬레이션 (2026-04-29 추가)
|
||||
|
||||
테넌트 DB 만 만져도 된다는 사용자 OK 받고 진행. 메타 DB 무수정.
|
||||
|
||||
**플랜:** TEST02 의 `USER_INFO` 테이블을 임시 RENAME 해서 SELECT 실패 유도 → fan-out 호출 → 즉시 롤백.
|
||||
|
||||
```sql
|
||||
-- test02_invyone 만
|
||||
ALTER TABLE USER_INFO RENAME TO USER_INFO_HJTEST_BAK; -- 시뮬레이션 시작
|
||||
-- (curl /users)
|
||||
ALTER TABLE USER_INFO_HJTEST_BAK RENAME TO USER_INFO; -- 즉시 롤백
|
||||
```
|
||||
|
||||
**결과:**
|
||||
|
||||
| 단계 | 응답 |
|
||||
|---|---|
|
||||
| 1. RENAME 후 `/users` | HTTP 200, header `X-CrossTenant-Failed: TEST02`, body `total=9 q=2 failed=1 by={'*':8, 'TEST01':1}` |
|
||||
| 2. 롤백 후 `/users` | `total=10 failed=0 by={'*':8, 'TEST01':1, 'TEST02':1}` — 완전 복귀 |
|
||||
|
||||
**검증된 것:**
|
||||
- [x] **fail-open** — 한 회사 SELECT 실패해도 전체 응답 200, 다른 회사 + 메타 결과 그대로 반환
|
||||
- [x] **회사 격리** — TEST01·메타 행은 영향 없음 (`'*': 8, 'TEST01': 1` 그대로)
|
||||
- [x] **`companies_failed: 1`** + `failed_company_codes: ["TEST02"]`
|
||||
- [x] **응답 헤더 `X-CrossTenant-Failed: TEST02`** — 클라이언트가 토스트 띄우기에 충분한 정보
|
||||
- [x] 백엔드 로그에 `[CrossTenant] mapper=admin-cross-tenant.listUsers failed for company=TEST02 db=test02_invyone : ...` 형태 메시지 (Aggregator line 159-161)
|
||||
|
||||
설계서 §11.2 가 이 결과로 ✅ 처리됨.
|
||||
|
||||
### 9.5 여전히 미검증 (남은 시나리오)
|
||||
|
||||
- §11.4 락 비획득 — TEST02 풀 maxPool 점유 후 `connection-timeout`. Hikari 풀 점유 시뮬레이션 필요 (별도 부하 테스트 환경)
|
||||
- §11.5 캐시 무효화 — 캐시 자체가 Phase D 라 N/A
|
||||
|
||||
### 9.5 명령 (재현용)
|
||||
|
||||
```bash
|
||||
TOKEN='<SUPER_ADMIN JWT>'
|
||||
for ep in users roles batches lang-keys; do
|
||||
curl -s -D /tmp/h.$ep -o /tmp/b.$ep \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Host: localhost:9771" \
|
||||
"http://localhost:8081/api/admin/cross-tenant/$ep"
|
||||
done
|
||||
|
||||
python3 <<'PY'
|
||||
import json, collections
|
||||
for ep in ["users","roles","batches","lang-keys"]:
|
||||
d = json.load(open(f"/tmp/b.{ep}"))["data"]
|
||||
codes = collections.Counter(r.get("company_code") for r in d["rows"])
|
||||
print(f"[{ep}] total={d['total']} q={d['companies_queried']} failed={d['companies_failed']} by={dict(codes)}")
|
||||
PY
|
||||
```
|
||||
@@ -0,0 +1,292 @@
|
||||
# `*.localhost` 테넌트 라우팅 — dev 환경 패치 핸드오프
|
||||
|
||||
작성일: 2026-04-28
|
||||
작성자: hjjeong
|
||||
대상: 새 세션 (clean context) 에서 바로 실행
|
||||
관련 SoT: [docs/MULTI_TENANCY_ARCHITECTURE.md](../../docs/MULTI_TENANCY_ARCHITECTURE.md)
|
||||
선행 작업: [2026-04-27-cross-tenant-admin-aggregation.md](2026-04-27-cross-tenant-admin-aggregation.md), [2026-04-28-cross-tenant-execution-log.md](2026-04-28-cross-tenant-execution-log.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. 새 세션에서 1분 안에 컨텍스트 잡기
|
||||
|
||||
이 한 단락만 읽고 시작하면 됨:
|
||||
|
||||
> INVYONE 은 멀티테넌시 (회사별 PostgreSQL DB) 플랫폼. 운영에서는 `qnc.invyone.com` 같은 서브도메인으로 회사 DB 가 자동 라우팅된다. dev 환경에서도 같은 라우팅을 흉내내려면 `qnc.localhost:9771` 같은 호스트가 동작해야 하는데, **현재 백엔드/프론트가 `*.invyone.com` 만 서브도메인으로 인식**해서 dev 환경에선 단일 테넌트 모드 테스트가 막혀있다. 이 문서가 그 패치 작업.
|
||||
|
||||
핵심 사실:
|
||||
- 운영 배포 (`https://test02.invyone.com/login`) 는 정상 동작 — Porkbun 와일드카드 DNS + Traefik
|
||||
- localhost dev 에서 `http://test02.localhost:9771/login` 은 로그인 시 메타 DB 컨텍스트로 떨어져 실패
|
||||
- 4개 파일만 수정하면 해결. 하지만 운영 동작 깨뜨리지 않아야 함
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
dev 환경에서 다음이 동작하게:
|
||||
|
||||
```
|
||||
http://test02.localhost:9771/login
|
||||
↓
|
||||
TEST02 회사 단일 모드 로그인 화면
|
||||
↓
|
||||
test02_admin / 초기비번 으로 로그인 성공
|
||||
↓
|
||||
TEST02 회사 DB 컨텍스트로 어드민 14개 메뉴 사용
|
||||
```
|
||||
|
||||
운영(`*.invyone.com`) 동작은 **그대로 유지**.
|
||||
|
||||
---
|
||||
|
||||
## 2. 근본 원인 (이미 분석 완료)
|
||||
|
||||
### 원인 A — 백엔드: 2파트 호스트는 무조건 null
|
||||
|
||||
[backend-spring/.../tenant/SubdomainResolverFilter.java](../../backend-spring/src/main/java/com/erp/tenant/SubdomainResolverFilter.java) `extractSubdomain()` 메서드:
|
||||
|
||||
```java
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length < 3) return null; // invyone.com (2파트) → null
|
||||
```
|
||||
|
||||
`test02.localhost` 는 2파트 → null → `DbContextHolder.setMeta()` 박혀 META DB 로 라우팅. 로그인 시 메타 DB의 USER_INFO 에서 `test02_admin` 을 찾으니 인증 실패.
|
||||
|
||||
### 원인 B — 프론트: `.invyone.com` 만 직접 호출 분기
|
||||
|
||||
[frontend/lib/api/client.ts](../../frontend/lib/api/client.ts) (lines ~16~40 부근):
|
||||
|
||||
```ts
|
||||
if (currentHost.endsWith(".invyone.com")) {
|
||||
return `https://${currentHost}/api`; // 직접 호출 (Host 헤더 보존)
|
||||
}
|
||||
// fallthrough → /api 상대 경로 → Next.js rewrite → Host 헤더 변조됨
|
||||
```
|
||||
|
||||
`*.localhost` 는 분기에 안 걸리고 Next rewrite 로 빠지면, Host 헤더가 `localhost:8081` 로 변조되어 백엔드가 서브도메인을 못 봄.
|
||||
|
||||
### 그 외 영향 받을 수 있는 파일
|
||||
|
||||
- [frontend/lib/tenant/subdomain.ts](../../frontend/lib/tenant/subdomain.ts) — `extractTenantSubdomain(host)` 헬퍼
|
||||
- [frontend/components/TenantGuard.tsx](../../frontend/components/TenantGuard.tsx) — 미등록 서브도메인 차단
|
||||
|
||||
위 두 개도 동일하게 `*.localhost` 를 인정해야 일관성 유지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 해야 할 패치 (4~5개 파일)
|
||||
|
||||
### 3.1 백엔드 — `SubdomainResolverFilter.java`
|
||||
|
||||
`extractSubdomain()` 을 다음 로직으로 수정:
|
||||
|
||||
```java
|
||||
static String extractSubdomain(String host) {
|
||||
if (host == null || host.isBlank()) return null;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return null; // bare localhost → META
|
||||
if (IPV4.matcher(host).matches()) return null; // IP → META
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
|
||||
// 2파트 — "{sub}.localhost" 만 허용 (dev 전용)
|
||||
if (parts.length == 2) {
|
||||
if ("localhost".equals(parts[1])) {
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
return null; // invyone.com 같은 베이스 도메인
|
||||
}
|
||||
|
||||
// 3파트 이상 (운영) — 첫 번째 파트가 서브도메인
|
||||
if (parts.length < 3) return null;
|
||||
String first = parts[0];
|
||||
if (ReservedSubdomains.VALUES.contains(first)) return null;
|
||||
return first;
|
||||
}
|
||||
```
|
||||
|
||||
- 운영 (3파트, `qnc.invyone.com`) 동작 변경 없음
|
||||
- 새로 추가: 2파트 + 두번째가 `localhost` → 첫 파트를 서브도메인으로 인정
|
||||
- 기존 `bare localhost` / IP 분기 그대로
|
||||
|
||||
### 3.2 프론트 — `lib/api/client.ts`
|
||||
|
||||
`.invyone.com` 분기 옆에 `.localhost` 분기 하나 추가:
|
||||
|
||||
```ts
|
||||
// 운영 (https) — Host 보존하며 Traefik 이 백엔드로 프록시
|
||||
if (currentHost.endsWith(".invyone.com")) {
|
||||
return `https://${currentHost}/api`;
|
||||
}
|
||||
|
||||
// dev (http) — *.localhost 도 동일 패턴으로 직접 호출 (Host 보존)
|
||||
// 단, bare "localhost" 는 제외 — 그건 메타 컨텍스트 (admin)
|
||||
if (currentHost.endsWith(".localhost") && currentHost !== "localhost") {
|
||||
return `http://${currentHost}/api`;
|
||||
}
|
||||
```
|
||||
|
||||
> **주의**: `localhost` 자체는 제외해야 함. `localhost:9771` 은 admin (메타) 도메인 역할이라 기존 NEXT_PUBLIC_API_URL=/api 분기가 처리해야 cross-tenant 가 정상 동작함.
|
||||
|
||||
### 3.3 프론트 — `lib/tenant/subdomain.ts`
|
||||
|
||||
`extractTenantSubdomain()` 도 동일 로직 적용. 백엔드와 같은 규칙:
|
||||
- bare `localhost` → null (메타)
|
||||
- `{sub}.localhost` → 첫 파트 (예약어 제외)
|
||||
- 3파트 이상 → 첫 파트 (예약어 제외)
|
||||
|
||||
### 3.4 프론트 — `components/TenantGuard.tsx`
|
||||
|
||||
[3.3 의 `extractTenantSubdomain`] 결과가 null 이면 통과, 그 외엔 백엔드 `/api/tenant/check?subdomain=xxx` 호출. 로직 자체는 변경 없음 — 위 3.3 만 고치면 자연스럽게 동작.
|
||||
|
||||
### 3.5 (선택) `lib/auth/crossTenantMode.ts`
|
||||
|
||||
cross-tenant 모드 판정은 JWT 의 `company_code === "*"` 기반이라 호스트와 무관. 변경 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 시나리오
|
||||
|
||||
### 사전 — 환경 셋업 확인
|
||||
|
||||
```bash
|
||||
cd /Users/jhj/invyone
|
||||
direnv status # JWT_SECRET, PATH(postgresql@16) 로드 확인
|
||||
which pg_dump && pg_dump --version # 16.x 나와야 함
|
||||
java -version # 21.0.10 나와야 함
|
||||
|
||||
# 백엔드 (이전 세션 종료됐으면)
|
||||
cd backend-spring && ./gradlew bootRun
|
||||
|
||||
# 프론트 (이전 세션 종료됐으면)
|
||||
cd /Users/jhj/invyone/frontend && npm run dev
|
||||
```
|
||||
|
||||
### 시나리오 1 — dev 단일 테넌트 로그인 (이번 패치 핵심)
|
||||
|
||||
1. 브라우저 새 시크릿 창 (이전 세션 토큰 격리)
|
||||
2. http://test02.localhost:9771/login 접속
|
||||
3. 로그인 화면 정상 렌더 (`TenantGuard` 통과)
|
||||
4. 계정 입력:
|
||||
- user_id: `test02_admin`
|
||||
- password: `x7uouA7qoUEj` (TEST02 프로비저닝 시 발급된 초기 비번)
|
||||
5. 로그인 → 비밀번호 강제 변경 화면 (082 마이그레이션 `FORCE_PASSWORD_CHANGE` 동작)
|
||||
6. 새 비번 설정 → 메인 화면 진입
|
||||
7. 사이드바 → 어드민 메뉴 → **TEST02 회사 데이터만 보여야 함** (TEST01 X)
|
||||
|
||||
### 시나리오 2 — bare localhost 가 여전히 메타로 라우팅
|
||||
|
||||
1. http://localhost:9771/admin/userMng/userMngList (SUPER_ADMIN 토큰 보유 상태)
|
||||
2. 사용자관리 → cross-tenant 응답 (`companies_queried: 2`, 행에 TEST01/TEST02 섞임)
|
||||
|
||||
이 동작이 깨지면 `client.ts` 의 `.localhost !== "localhost"` 가드가 빠진 것.
|
||||
|
||||
### 시나리오 3 — 운영 도메인 회귀 없음
|
||||
|
||||
운영 배포는 별도 검증 필요. 본 패치는 운영 코드 경로 (3파트 호스트) 를 안 건드려서 회귀 위험 낮음.
|
||||
|
||||
### 시나리오 4 — 미등록 서브도메인 차단
|
||||
|
||||
http://nonexistent.localhost:9771 접속 → `/tenant-not-found` 로 리다이렉트 (`TenantGuard` 동작).
|
||||
|
||||
---
|
||||
|
||||
## 5. 함정 / 주의
|
||||
|
||||
| 함정 | 설명 |
|
||||
|---|---|
|
||||
| `localhost` 자체를 서브도메인으로 인식 | 2파트 분기에서 `bare localhost` 빠뜨리면 `localhost.com` 같은 호스트도 `localhost` 가 서브도메인이 됨. 위 코드는 첫 줄에 `if ("localhost".equals(host)) return null;` 으로 이미 차단 |
|
||||
| `admin.localhost` 가 메타로 안 가고 테넌트로 빠짐 | `ReservedSubdomains` 에 `admin` 이 이미 있어 자동으로 null 처리됨. 추가 작업 불필요 |
|
||||
| 운영의 3파트 동작 변경 | 위 패치는 3파트 분기를 그대로 둠. 운영 회귀 0 |
|
||||
| Next.js dev rewrite 캐시 | `next.config.mjs` 안 건드리면 영향 없음 |
|
||||
| CORS | dev 에서 `http://*.localhost:[*]` 패턴이 [`application.yml`의 `CORS_ALLOWED_ORIGINS`](../../backend-spring/src/main/resources/application.yml) 에 이미 포함돼 있는지 확인. 없으면 추가 |
|
||||
|
||||
CORS 디폴트 (`SecurityConfig.corsAllowedOrigins`) 확인:
|
||||
|
||||
```bash
|
||||
grep -A 1 "CORS_ALLOWED_ORIGINS\|allowed-origins" /Users/jhj/invyone/backend-spring/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
`http://*.localhost:[*]` 같은 패턴이 빠져있으면 `application.yml` 디폴트 또는 `.envrc` 의 `CORS_ALLOWED_ORIGINS` 환경변수에 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. 작업 단계 권장 순서
|
||||
|
||||
1. **백엔드 수정** — `SubdomainResolverFilter.extractSubdomain()` + 단위 테스트 작성 (호스트 5~6 종류 대해 기대 결과 확인)
|
||||
2. **재컴파일** — `./gradlew classes` (devtools 자동 재시작)
|
||||
3. **백엔드 단독 검증** — 다음 curl 로 헤더 라우팅만 확인:
|
||||
|
||||
```bash
|
||||
# bare localhost → META
|
||||
curl -s -H "Host: localhost:9771" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
# test02.localhost → 라우팅
|
||||
curl -s -H "Host: test02.localhost:9771" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
# test02.invyone.com (운영) — 회귀 없음 확인
|
||||
curl -s -H "Host: test02.invyone.com" http://localhost:8081/api/tenant/check?subdomain=test02
|
||||
```
|
||||
|
||||
4. **프론트 수정** — `client.ts` / `subdomain.ts`
|
||||
5. **브라우저 검증** — 위 §4 시나리오 1~4
|
||||
6. **`MULTI_TENANCY_ARCHITECTURE.md` §4.2 갱신** — "로컬 개발 도커 up" 섹션에 "*.localhost 직접 dev (npm run dev)" 케이스 추가
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 워킹트리 상태 (2026-04-28 17:xx)
|
||||
|
||||
```
|
||||
M .gitignore
|
||||
M backend-spring/src/main/resources/mapper/provisioning.xml
|
||||
M frontend/app/(auth)/login/page.tsx
|
||||
M frontend/components/admin/UserTable.tsx
|
||||
M frontend/lib/api/batch.ts
|
||||
M frontend/lib/api/multilang.ts
|
||||
M frontend/lib/api/role.ts
|
||||
M frontend/lib/api/user.ts
|
||||
?? .java-version
|
||||
?? backend-spring/src/main/java/com/erp/crosstenant/
|
||||
?? backend-spring/src/main/resources/mapper/admin-cross-tenant.xml
|
||||
?? frontend/lib/auth/crossTenantMode.ts
|
||||
?? notes/gbpark/2026-04-27-cross-tenant-admin-aggregation.md
|
||||
?? notes/gbpark/2026-04-28-cross-tenant-execution-log.md
|
||||
?? notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md ← 본 문서
|
||||
```
|
||||
|
||||
**커밋·푸시 모두 안 됨.** 이전 cross-tenant 작업 분의 워킹카피가 모두 살아있는 상태. 본 패치 작업은 이 위에서 진행해도 되고, 먼저 cross-tenant 분량을 커밋한 뒤 진행해도 됨. 권장 — **cross-tenant 분량을 먼저 커밋 후** 본 패치를 별도 커밋으로 분리.
|
||||
|
||||
---
|
||||
|
||||
## 8. 알려진 환경 정보 (참고)
|
||||
|
||||
- **현재 활성 회사**: TEST01 (시연용회사) + TEST02 (시연용회사2). 두 회사 모두 `DB_STATUS='active'`. 같은 시드 데이터 8명 사용자 / 1 권한그룹 / 10 배치 / 646 다국어 키.
|
||||
- **TEST02 초기 admin 계정**: `test02_admin` / `x7uouA7qoUEj` (FORCE_PW_CHANGE 활성)
|
||||
- **현재 SUPER_ADMIN 토큰**: `hjjeong` 계정. 이전 세션에서 발급된 24h 토큰. 만료 시 재로그인 필요.
|
||||
- **로컬 백엔드**: 8081 (Java 21, Spring Boot 3.3.5, devtools)
|
||||
- **로컬 프론트**: 9771 (Next.js 15, turbopack)
|
||||
- **메타 DB**: `183.99.177.40:5432/invyone` (postgres / invyone0909!!)
|
||||
- **테넌트 DB**: `test01_invyone`, `test02_invyone`
|
||||
|
||||
---
|
||||
|
||||
## 9. 새 세션 시작 시 첫 명령
|
||||
|
||||
```bash
|
||||
# 1. 환경 진단
|
||||
cd /Users/jhj/invyone
|
||||
git status --short
|
||||
direnv status
|
||||
java -version
|
||||
|
||||
# 2. 본 문서 다시 읽기 (이미 읽었지만 새 세션 LLM 입장)
|
||||
cat notes/gbpark/2026-04-28-localhost-tenant-routing-handoff.md | head -50
|
||||
|
||||
# 3. 본격 작업 — 위 §3.1 부터
|
||||
```
|
||||
|
||||
이 문서가 진실의 원천. 막히면 §5 (함정), §6 (작업 순서) 참고.
|
||||
Reference in New Issue
Block a user