a41f99c579
지금까지 cross-tenant 는 READ 전용. admin 도메인에서 사용자 등록하면
JWT.company_code='*' 가 그대로 박혀 메타 DB 에 INSERT 되던 버그 해결.
이제 SUPER_ADMIN 이 폼의 "회사" 드롭다운에서 TEST01/TEST02 등 선택하면
그 회사 DB 에 정확히 INSERT.
신규 백엔드
- crosstenant/CrossTenantExecutor.java — 회사 컨텍스트 임시 전환 헬퍼
(company_code → db_name → ensureTenantPool → set → run → restore)
- crosstenant/CrossTenantUserController.java — /api/admin/cross-tenant/users
9개 endpoint (POST/PUT/DELETE/PATCH/with-dept/check-duplicate/단건/이력)
- mapper/provisioning.xml — resolveDbNameByCompanyCode (active 회사만)
기존 단일 회사 모드 (POST /admin/users 등) 무수정 — 회사 도메인
컨텍스트에서 회귀 0.
프론트
- lib/api/user.ts — createUser/updateUser/updateUserStatus/checkDuplicateUserId/
saveUserWithDept 가 isCrossTenantMode() 면 새 endpoint + body.company_code 로 분기
- UserFormModal — checkDuplicateId 호출 시 formData.company_code 같이 전달
- useUserManagement — status toggle 시 row 의 company_code 같이 전달
검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- company_code 없음 → 400 "company_code 가 비어있음"
- 잘못된 company_code → 400 "등록되지 않았거나 비활성 회사"
- check-duplicate: TEST01.test02_admin → not_dup, TEST02.test02_admin → dup ✓
- POST 사용자 → TEST02 USER_INFO +1, TEST01·메타 격리 ✓
- /users fan-out: by={'*':8, 'TEST01':1, 'TEST02':2}, hjtest_ct_001 in TEST02만 ✓
- DELETE → status=inactive (soft) ✓
미구현 (Phase 1 후속):
- 부서 dropdown (cross-tenant department endpoint 별도 필요)
- 비밀번호 초기화 모달의 cross-tenant 분기 (UserPasswordResetModal)
- Phase 2 권한관리 (별도 커밋)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
12 KiB
Java
287 lines
12 KiB
Java
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;
|
|
}
|
|
}
|