824a3100ce
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를 4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다. # 보안 (plane 격리) PR #A — controller/CompanyManagementController 인증 누락 패치 /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제 + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용. PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그 CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러 (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두 테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종 (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가. SuperAdminGuard.isTenantHost 가시성 public static 으로 승격. PR #B — 프론트 솔루션 전용 admin 페이지 가드 admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별: subdomainList / companyList / audit-log. 각 페이지에 isManagementHost useEffect 가드 + redirect 추가. 사이드바도 같이 숨김. PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터 V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹. admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus 가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS 하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지. StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용. # 부서관리 후속 (이전 PR #18/#19 follow-up) DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분. 이번 격리 작업과 무관하지만 같이 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
476 lines
19 KiB
Java
476 lines
19 KiB
Java
package com.erp.controller;
|
|
|
|
import com.erp.dto.ApiResponse;
|
|
import com.erp.provisioning.SuperAdminGuard;
|
|
import com.erp.service.AdminService;
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/admin")
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class AdminController {
|
|
|
|
private final AdminService adminService;
|
|
|
|
// ── 메뉴 관리 ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/menus
|
|
* 관리자 메뉴 목록 조회
|
|
*/
|
|
@GetMapping("/menus")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getAdminMenus(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestAttribute("role") String role,
|
|
@RequestAttribute("user_id") String userId,
|
|
@RequestParam Map<String, Object> params) {
|
|
params.put("company_code", companyCode);
|
|
params.put("user_type", role);
|
|
params.put("user_id", userId);
|
|
params.putIfAbsent("user_lang", "ko");
|
|
params.put("is_management_screen",
|
|
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/user-menus
|
|
* 사용자 메뉴 목록 조회 (사이드바)
|
|
*/
|
|
@GetMapping("/user-menus")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getUserMenus(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestAttribute("role") String role,
|
|
@RequestAttribute("user_id") String userId,
|
|
@RequestParam Map<String, Object> params,
|
|
HttpServletRequest request) {
|
|
params.put("company_code", companyCode);
|
|
params.put("user_type", role);
|
|
params.put("user_id", userId);
|
|
params.putIfAbsent("user_lang", "ko");
|
|
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
|
String host = request.getHeader("Host");
|
|
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/pop-menus
|
|
* POP 메뉴 목록 조회
|
|
*/
|
|
@GetMapping("/pop-menus")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getPopMenus(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestAttribute("role") String role) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("company_code", companyCode);
|
|
params.put("user_type", role);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getPopMenuList(params), "POP 메뉴 목록 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/menus/{menuId}
|
|
* 메뉴 단건 조회
|
|
*/
|
|
@GetMapping("/menus/{menuId}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getMenuInfo(@PathVariable String menuId) {
|
|
Map<String, Object> menu = adminService.getMenuInfo(menuId);
|
|
if (menu == null) {
|
|
return ResponseEntity.status(404).body(ApiResponse.error("메뉴를 찾을 수 없습니다."));
|
|
}
|
|
return ResponseEntity.ok(ApiResponse.success(menu, "메뉴 정보 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/menus
|
|
* 메뉴 등록
|
|
*/
|
|
@PostMapping("/menus")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> saveMenu(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestBody Map<String, Object> body) {
|
|
body.put("company_code", companyCode);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.saveMenu(body), "메뉴 등록 성공"));
|
|
}
|
|
|
|
/**
|
|
* PUT /api/admin/menus/{menuId}
|
|
* 메뉴 수정
|
|
*/
|
|
@PutMapping("/menus/{menuId}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> updateMenu(
|
|
@PathVariable String menuId,
|
|
@RequestBody Map<String, Object> body) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.updateMenu(menuId, body), "메뉴 수정 성공"));
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/menus/{menuId}
|
|
* 메뉴 삭제
|
|
*/
|
|
@DeleteMapping("/menus/{menuId}")
|
|
public ResponseEntity<ApiResponse<Void>> deleteMenu(@PathVariable String menuId) {
|
|
adminService.deleteMenu(menuId);
|
|
return ResponseEntity.ok(ApiResponse.success(null, "메뉴 삭제 성공"));
|
|
}
|
|
|
|
/**
|
|
* PUT /api/admin/menus/{menuId}/toggle
|
|
* 메뉴 상태 토글
|
|
*/
|
|
@PutMapping("/menus/{menuId}/toggle")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> toggleMenuStatus(@PathVariable String menuId) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.toggleMenuStatus(menuId), "메뉴 상태 변경 성공"));
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/menus/batch
|
|
* 메뉴 일괄 삭제
|
|
*/
|
|
@DeleteMapping("/menus/batch")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteMenusBatch(@RequestBody List<String> menuIds) {
|
|
int deletedCount = 0;
|
|
int failedCount = 0;
|
|
if (menuIds != null) {
|
|
for (String menuId : menuIds) {
|
|
try {
|
|
adminService.deleteMenu(menuId);
|
|
deletedCount++;
|
|
} catch (Exception e) {
|
|
failedCount++;
|
|
log.warn("메뉴 삭제 실패 menuId={} : {}", menuId, e.getMessage());
|
|
}
|
|
}
|
|
}
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("deletedCount", deletedCount);
|
|
result.put("failedCount", failedCount);
|
|
return ResponseEntity.ok(ApiResponse.success(result, "메뉴 일괄 삭제 성공"));
|
|
}
|
|
|
|
// ── 사용자 관리 ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/users
|
|
* 사용자 목록 조회
|
|
*/
|
|
@GetMapping("/users")
|
|
public ResponseEntity<?> getUserList(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestAttribute("role") String role,
|
|
@RequestParam Map<String, Object> params) {
|
|
// SUPER_ADMIN이 아닌 경우 자사 필터 적용
|
|
if (!"SUPER_ADMIN".equals(role)) {
|
|
params.put("company_code", companyCode);
|
|
} else {
|
|
params.put("company_code", "*");
|
|
}
|
|
params.putIfAbsent("page", "1");
|
|
params.putIfAbsent("limit", "20");
|
|
|
|
Map<String, Object> result = adminService.getUserList(params);
|
|
|
|
// Node.js 응답 형식: success, data, total, searchType, pagination, message
|
|
Map<String, Object> response = new HashMap<>(result);
|
|
response.put("success", true);
|
|
response.put("message", "사용자 목록 조회 성공");
|
|
return ResponseEntity.ok(response);
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/users/{userId}
|
|
* 사용자 단건 조회
|
|
*/
|
|
@GetMapping("/users/{userId}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserInfo(@PathVariable String userId) {
|
|
Map<String, Object> user = adminService.getUserInfo(userId);
|
|
if (user == null) {
|
|
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
|
}
|
|
return ResponseEntity.ok(ApiResponse.success(user, "사용자 정보 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/users/{userId}/history
|
|
* 사용자 변경이력 조회
|
|
*/
|
|
@GetMapping("/users/{userId}/history")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getUserHistory(
|
|
@PathVariable String userId,
|
|
@RequestParam Map<String, Object> params) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getUserHistory(userId, params), "사용자 변경이력 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/users/{userId}/with-dept
|
|
* 사원 + 부서 정보 조회 (수정 모달용)
|
|
*/
|
|
@GetMapping("/users/{userId}/with-dept")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserWithDept(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@PathVariable String userId) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("company_code", companyCode);
|
|
params.put("user_id", userId);
|
|
Map<String, Object> result = adminService.getUserWithDept(params);
|
|
if (result == null) {
|
|
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
|
}
|
|
return ResponseEntity.ok(ApiResponse.success(result));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/users/with-dept
|
|
* 사원 + 부서 통합 저장
|
|
*/
|
|
@PostMapping("/users/with-dept")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUserWithDept(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestBody Map<String, Object> body) {
|
|
body.put("company_code", companyCode);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.saveUserWithDept(body)));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/users
|
|
* 사용자 등록/수정
|
|
*/
|
|
@PostMapping("/users")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> saveUser(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestBody Map<String, Object> body) {
|
|
body.put("company_code", companyCode);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "사용자 저장 성공"));
|
|
}
|
|
|
|
/**
|
|
* PUT /api/admin/users/{userId}
|
|
* 사용자 수정 (REST)
|
|
*/
|
|
@PutMapping("/users/{userId}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> updateUser(
|
|
@PathVariable String userId,
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestBody Map<String, Object> body) {
|
|
body.put("user_id", userId);
|
|
body.put("company_code", companyCode);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "사용자 수정 성공"));
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/users/{userId}
|
|
* 사용자 삭제 (비활성화)
|
|
*/
|
|
@DeleteMapping("/users/{userId}")
|
|
public ResponseEntity<ApiResponse<Void>> deleteUser(
|
|
@PathVariable String userId) {
|
|
Map<String, Object> existing = adminService.getUserInfo(userId);
|
|
if (existing == null) {
|
|
return ResponseEntity.status(404).body(ApiResponse.error("사용자를 찾을 수 없습니다."));
|
|
}
|
|
adminService.changeUserStatus(userId, "inactive");
|
|
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/admin/users/{userId}/status
|
|
* 사용자 상태 변경
|
|
*/
|
|
@PatchMapping("/users/{userId}/status")
|
|
public ResponseEntity<ApiResponse<Void>> changeUserStatus(
|
|
@PathVariable String userId,
|
|
@RequestBody Map<String, Object> body) {
|
|
String status = (String) body.get("status");
|
|
adminService.changeUserStatus(userId, status);
|
|
return ResponseEntity.ok(ApiResponse.success(null, "사용자 상태 변경 성공"));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/users/reset-password
|
|
* 비밀번호 초기화
|
|
*/
|
|
@PostMapping("/users/reset-password")
|
|
public ResponseEntity<ApiResponse<Void>> resetUserPassword(@RequestBody Map<String, Object> body) {
|
|
String userId = (String) body.get("user_id");
|
|
String newPassword = (String) body.get("new_password");
|
|
adminService.resetUserPassword(userId, newPassword);
|
|
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/users/check-duplicate
|
|
* 사용자 ID 중복 확인
|
|
*/
|
|
@PostMapping("/users/check-duplicate")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> checkDuplicateUserId(
|
|
@RequestBody Map<String, Object> body) {
|
|
String userId = (String) body.get("user_id");
|
|
Map<String, Object> existing = adminService.getUserInfo(userId);
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("is_duplicate", existing != null);
|
|
return ResponseEntity.ok(ApiResponse.success(result, "아이디 중복 확인 완료"));
|
|
}
|
|
|
|
// ── 프로필 수정 ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* PUT /api/admin/profile
|
|
* 프로필 수정 (본인)
|
|
*/
|
|
@PutMapping("/profile")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> updateProfile(
|
|
@RequestAttribute("user_id") String userId,
|
|
@RequestBody Map<String, Object> body) {
|
|
body.put("user_id", userId);
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.saveUser(body), "프로필 수정 성공"));
|
|
}
|
|
|
|
// ── 부서 관리 ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/departments
|
|
* 부서 목록 조회
|
|
*/
|
|
@GetMapping("/departments")
|
|
public ResponseEntity<?> getDepartmentList(
|
|
@RequestAttribute("company_code") String companyCode,
|
|
@RequestParam Map<String, Object> params) {
|
|
params.put("company_code", companyCode);
|
|
Map<String, Object> serviceResult = adminService.getDepartmentList(params);
|
|
|
|
int total = ((Number) serviceResult.get("total")).intValue();
|
|
|
|
Map<String, Object> data = new HashMap<>();
|
|
data.put("departments", serviceResult.get("departments"));
|
|
data.put("flat_list", serviceResult.get("flat_list"));
|
|
|
|
Map<String, Object> response = new HashMap<>();
|
|
response.put("success", true);
|
|
response.put("data", data);
|
|
response.put("message", "부서 목록 조회 성공");
|
|
response.put("total", total);
|
|
response.put("total_count", total);
|
|
return ResponseEntity.ok(response);
|
|
}
|
|
|
|
// ── 회사 관리 ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/companies
|
|
* 회사 목록 조회
|
|
*/
|
|
@GetMapping("/companies")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCompanyList() {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getCompanyList(), "회사 목록 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/companies/db
|
|
* 실제 DB에서 회사 목록 조회
|
|
*/
|
|
@GetMapping("/companies/db")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getCompanyListFromDB() {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getCompanyList(), "회사 목록 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/companies/{companyCode}
|
|
* 회사 단건 조회
|
|
*/
|
|
@GetMapping("/companies/{companyCode}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyByCode(
|
|
@PathVariable String companyCode) {
|
|
Map<String, Object> company = adminService.getCompanyByCode(companyCode);
|
|
if (company == null) {
|
|
return ResponseEntity.status(404).body(ApiResponse.error("회사를 찾을 수 없습니다."));
|
|
}
|
|
return ResponseEntity.ok(ApiResponse.success(company, "회사 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/companies
|
|
* 회사 등록
|
|
*/
|
|
@PostMapping("/companies")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> createCompany(
|
|
@RequestBody Map<String, Object> body) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.createCompany(body), "회사 등록 성공"));
|
|
}
|
|
|
|
/**
|
|
* PUT /api/admin/companies/{companyCode}
|
|
* 회사 수정
|
|
*/
|
|
@PutMapping("/companies/{companyCode}")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> updateCompany(
|
|
@PathVariable String companyCode,
|
|
@RequestBody Map<String, Object> body) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.updateCompanyInfo(companyCode, body), "회사 수정 성공"));
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/companies/{companyCode}
|
|
* 회사 삭제 (비활성화)
|
|
*/
|
|
@DeleteMapping("/companies/{companyCode}")
|
|
public ResponseEntity<ApiResponse<Void>> deleteCompany(@PathVariable String companyCode) {
|
|
adminService.deleteCompany(companyCode);
|
|
return ResponseEntity.ok(ApiResponse.success(null, "회사 삭제 성공"));
|
|
}
|
|
|
|
// ── 로케일 관리 ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/user-locale
|
|
* 사용자 로케일 조회
|
|
*/
|
|
@GetMapping("/user-locale")
|
|
public ResponseEntity<ApiResponse<Object>> getUserLocale(
|
|
@RequestAttribute("user_id") String userId) {
|
|
Map<String, Object> row = adminService.getUserLocale(userId);
|
|
Object locale = row.getOrDefault("locale", "KR");
|
|
return ResponseEntity.ok(ApiResponse.success(locale, "사용자 로케일 조회 성공"));
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/user-locale
|
|
* 사용자 로케일 설정
|
|
*/
|
|
@PostMapping("/user-locale")
|
|
public ResponseEntity<ApiResponse<Object>> setUserLocale(
|
|
@RequestAttribute("user_id") String userId,
|
|
@RequestBody Map<String, Object> body) {
|
|
String locale = (String) body.get("locale");
|
|
if (locale == null || locale.isBlank()) {
|
|
return ResponseEntity.badRequest().body(ApiResponse.error("로케일이 필요합니다."));
|
|
}
|
|
try {
|
|
adminService.setUserLocale(userId, locale);
|
|
return ResponseEntity.ok(ApiResponse.success(locale, "사용자 로케일 설정 성공"));
|
|
} catch (IllegalArgumentException e) {
|
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
|
}
|
|
}
|
|
|
|
// ── 테이블 스키마 ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/admin/tables/{tableName}/schema
|
|
* 테이블 스키마 조회
|
|
*/
|
|
@GetMapping("/tables/{tableName}/schema")
|
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getTableSchema(
|
|
@PathVariable String tableName) {
|
|
return ResponseEntity.ok(ApiResponse.success(adminService.getTableSchema(tableName), "테이블 스키마 조회 성공"));
|
|
}
|
|
}
|