feat(cross-tenant): SUPER_ADMIN 의 회사별 사용자 WRITE (Phase 1)
지금까지 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>
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -218,4 +218,18 @@
|
||||
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>
|
||||
|
||||
@@ -272,7 +272,9 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -269,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) {
|
||||
|
||||
@@ -102,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) {
|
||||
@@ -127,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;
|
||||
@@ -137,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;
|
||||
}
|
||||
|
||||
@@ -219,8 +235,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;
|
||||
}
|
||||
@@ -296,9 +322,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) {
|
||||
|
||||
Reference in New Issue
Block a user