035175e895
00_PROMPTS.md — Claude Code 실행 프롬프트 1~6 01_DB_SCHEMA.md — V1~V17 Flyway 스키마 + 프로젝트 뼈대 02_COMMON_SPEC.md — ga-common 공통 프레임워크 (모델/예외/MyBatis/AOP/엑셀/공통컴포넌트) 03_CORE_SPEC.md — ga-core VO 설계 원칙 + Mapper 패턴 04_BATCH_SPEC.md — ga-batch 정산 8 Step + DataReceiver/MappingEngine 05_API_SPEC.md — ga-api Controller/Service 표준 패턴 + 엔드포인트 목록 06_FRONTEND_SPEC.md — ga-frontend 13개 화면 + 공통 컴포넌트 사용법 07_INFRA_SPEC.md — Docker/Nginx + README + DEVELOPER_GUIDE 08_DBA_SPEC.md — V13~V15 인덱스/파티셔닝/뷰 실제 구현된 ga-commission-system 기준으로 작성. 코드는 변경 없음. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
19 KiB
Markdown
504 lines
19 KiB
Markdown
# 02_COMMON_SPEC — ga-common 공통 프레임워크
|
||
|
||
> 모든 다른 모듈이 의존하는 핵심. 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선.
|
||
|
||
## 패키지 구조
|
||
|
||
```
|
||
ga-common/src/main/java/com/ga/common/
|
||
├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
|
||
├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
|
||
├── auth/ # JwtTokenProvider, JwtAuthFilter, LoginUser
|
||
├── code/ # CommonCodeService/Controller/Mapper (Redis 캐시)
|
||
├── config/ # RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
|
||
├── exception/ # ErrorCode (enum), BizException, GlobalExceptionHandler
|
||
├── excel/ # ExcelService (Export 100만, Import 70만)
|
||
├── file/ # FileService/Controller (multipart 업로드/다운로드)
|
||
├── grid/ # GridSearchParam, GridResponse (AG Grid 서버사이드)
|
||
├── menu/ # MenuService/Controller, MenuTreeBuilder, PermissionChecker
|
||
├── model/ # ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
|
||
├── mybatis/ # BaseMapper, JsonTypeHandler, EncryptTypeHandler(+Initializer), AuditInterceptor
|
||
├── notification/ # NotificationService/Controller
|
||
├── security/ # SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandlerImpl
|
||
├── system/ # SystemConfigService, DataChangeLogService, ApiAccessLogService, SystemLogController
|
||
└── util/ # DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil
|
||
|
||
ga-common/src/main/resources/
|
||
├── mybatis/mybatis-config.xml
|
||
└── mapper/common/ # CommonCodeMapper.xml, MenuMapper.xml, FileMapper.xml, SystemConfigMapper.xml
|
||
```
|
||
|
||
build.gradle 핵심: `api 'org.springframework.boot:spring-boot-starter-{web,security,aop,validation,data-redis,cache}'`,
|
||
`api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'`,
|
||
`api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'`,
|
||
`api 'io.jsonwebtoken:jjwt-api:0.12.5'`, `api 'org.apache.poi:poi-ooxml:5.2.5'`,
|
||
`api 'com.monitorjbl:xlsx-streamer:2.2.0'`, `api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'`
|
||
|
||
---
|
||
|
||
## 1. 공통 모델 (`model/`)
|
||
|
||
### `ApiResponse<T>` — 모든 API 응답 표준
|
||
```java
|
||
public class ApiResponse<T> {
|
||
private boolean success;
|
||
private String code; // "0000" 성공
|
||
private String message;
|
||
private T data;
|
||
private LocalDateTime timestamp;
|
||
|
||
public static <T> ApiResponse<T> ok(T data) { ... }
|
||
public static <T> ApiResponse<T> ok(T data, String msg) { ... }
|
||
public static ApiResponse<Void> ok() { ... }
|
||
public static <T> ApiResponse<T> fail(ErrorCode e) { ... }
|
||
public static <T> ApiResponse<T> fail(String code, String msg) { ... }
|
||
}
|
||
```
|
||
|
||
### `PageResponse<T>` — PageHelper 연동
|
||
```java
|
||
public class PageResponse<T> {
|
||
private List<T> list;
|
||
private int pageNum, pageSize, pages;
|
||
private long total;
|
||
public static <T> PageResponse<T> of(List<T> list) {
|
||
PageInfo<T> p = new PageInfo<>(list);
|
||
// ...
|
||
}
|
||
}
|
||
```
|
||
|
||
### `SearchParam` — 공통 검색 파라미터
|
||
```java
|
||
public class SearchParam {
|
||
private int pageNum = 1, pageSize = 20;
|
||
private String searchType, searchKeyword;
|
||
private String startDate, endDate;
|
||
private String status;
|
||
private String sortField, sortOrder; // ASC / DESC
|
||
|
||
public void startPage() {
|
||
PageHelper.startPage(pageNum, pageSize);
|
||
if (sortField != null) PageHelper.orderBy(sortField + " " + sortOrder);
|
||
}
|
||
}
|
||
```
|
||
|
||
### `TreeNode` — 트리 응답 공통 (메뉴/조직)
|
||
```java
|
||
public class TreeNode {
|
||
private Object id, parentId;
|
||
private String name, type;
|
||
private int level, sortOrder;
|
||
private List<TreeNode> children = new ArrayList<>();
|
||
}
|
||
```
|
||
|
||
### `GridSearchParam extends SearchParam` — AG Grid 서버사이드용
|
||
```java
|
||
public class GridSearchParam extends SearchParam {
|
||
private List<Map<String,Object>> filterModel; // AG Grid 필터
|
||
private List<Map<String,String>> sortModel; // AG Grid 정렬
|
||
public String buildFilterSql() { ... }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 예외 처리 (`exception/`)
|
||
|
||
### `ErrorCode` enum
|
||
```java
|
||
SUCCESS("0000", 200), BAD_REQUEST("E400", 400),
|
||
UNAUTHORIZED("E401", 401), FORBIDDEN("E403", 403),
|
||
NOT_FOUND("E404", 404), INVALID_PARAMETER("E411", 400),
|
||
INTERNAL_ERROR("E500", 500),
|
||
|
||
TOKEN_EXPIRED("A001", 401), LOGIN_FAIL("A003", 401), ACCOUNT_LOCKED("A004", 403),
|
||
|
||
ALREADY_CONFIRMED("B001", 400), RULE_VERSION_CONFLICT("B002", 409),
|
||
APPROVAL_REQUIRED("B003", 400), SYSTEM_CODE_LOCKED("B008", 400),
|
||
DUPLICATE_DATA("B009", 409), BATCH_RUNNING("B010", 409),
|
||
|
||
FILE_NOT_FOUND("F001", 404), FILE_UPLOAD_FAIL("F002", 500);
|
||
```
|
||
|
||
### `BizException`
|
||
```java
|
||
@Getter
|
||
public class BizException extends RuntimeException {
|
||
private final ErrorCode errorCode;
|
||
private final String customMessage;
|
||
public BizException(ErrorCode e) { ... }
|
||
public BizException(ErrorCode e, String msg) { ... }
|
||
}
|
||
```
|
||
|
||
### `GlobalExceptionHandler`
|
||
- `BizException` → `ApiResponse.fail(errorCode)`
|
||
- `MethodArgumentNotValidException` → 필드별 에러 목록
|
||
- `AccessDeniedException` → 403
|
||
- `DuplicateKeyException` → "중복된 데이터입니다"
|
||
- `Exception` → 500 + log.error
|
||
|
||
---
|
||
|
||
## 3. MyBatis 공통 (`mybatis/`)
|
||
|
||
### `BaseMapper<T>` (선택적 상속)
|
||
```java
|
||
public interface BaseMapper<T> {
|
||
T selectById(Long id);
|
||
List<T> selectList(SearchParam param);
|
||
int insert(T entity);
|
||
int update(T entity);
|
||
int deleteById(Long id);
|
||
int countByCondition(SearchParam param);
|
||
}
|
||
```
|
||
|
||
### `JsonTypeHandler` — JSONB ↔ JsonNode
|
||
```java
|
||
@MappedTypes(JsonNode.class)
|
||
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> { ... }
|
||
```
|
||
|
||
### `EncryptTypeHandler` — AES-256 컬럼 암호화
|
||
- **중요**: `@Component` 등록하면 mybatis-spring-boot-starter 가
|
||
자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠.
|
||
- 사용처: `<result property="residentNo" column="resident_no"
|
||
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>` 형태로 컬럼별 명시.
|
||
- `EncryptTypeHandlerInitializer` (`@Configuration`) 가 부팅 시
|
||
`EncryptUtil` 을 정적 주입.
|
||
- 적용 컬럼: `agent.resident_no`, `agent.account_no`, `payment.account_no`.
|
||
|
||
### `AuditInterceptor` (Plugin)
|
||
- `INSERT` 시 `created_at`, `created_by` 자동
|
||
- `UPDATE` 시 `updated_at`, `updated_by` 자동
|
||
- 현재 로그인 사용자: `SecurityUtil.currentUserId()`
|
||
|
||
### 공통 SQL 조각 (`mapper/common/common-sql.xml`)
|
||
```xml
|
||
<sql id="searchCondition">
|
||
<if test="searchKeyword != null and searchKeyword != ''">
|
||
<choose>
|
||
<when test="searchType == 'name'">AND name LIKE CONCAT('%', #{searchKeyword}, '%')</when>
|
||
<when test="searchType == 'code'">AND code = #{searchKeyword}</when>
|
||
</choose>
|
||
</if>
|
||
<if test="startDate != null">AND created_at >= #{startDate}::timestamp</if>
|
||
<if test="endDate != null">AND created_at <= #{endDate}::timestamp + interval '1 day'</if>
|
||
<if test="status != null and status != ''">AND status = #{status}</if>
|
||
</sql>
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 인증/권한 (`auth/`, `security/`, `aop/`)
|
||
|
||
### JWT
|
||
- `JwtTokenProvider`: HS256, 액세스 2 시간 / 리프레시 7 일
|
||
- `JwtAuthFilter`: `Authorization: Bearer <token>` → SecurityContext 에 `LoginUser` 주입
|
||
- `LoginUser`: `userId`, `loginId`, `userName`, `roleCodes`, `agentId`
|
||
|
||
### `SecurityConfig`
|
||
- 기본 `permitAll`: `/api/auth/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/api/health`
|
||
- 그 외 `authenticated()` + JwtAuthFilter 적용
|
||
- 세션 STATELESS, CSRF disable, CORS allow `*` (개발), method-level `@PreAuthorize` 미사용 — `@RequirePermission` 으로 통일
|
||
|
||
### 어노테이션 + AOP
|
||
|
||
**`@RequirePermission(menu = "ORG_AGENT", perm = "READ")`**
|
||
- `PermissionAspect` 가 `PermissionChecker.hasPermission(userId, menuCode, permCode)` 호출
|
||
- `false` → `BizException(ErrorCode.FORBIDDEN)`
|
||
|
||
**`@DataChangeLog(menu = "RULE", table = "payout_rule")`**
|
||
- `DataChangeLogAspect` 가 메서드 실행 전 PK 로 before 조회 → 실행 → after 조회
|
||
- `data_change_log` 에 `before_data`/`after_data` JSONB 저장 (비동기)
|
||
|
||
**`@Mask(MaskType.RRN)` (응답 필드용 어노테이션)**
|
||
- `JsonSerializer` 또는 `@JsonComponent` 로 직렬화 시 마스킹
|
||
|
||
**`ApiLogAspect`**
|
||
- 모든 `@RestController` 자동 적용
|
||
- `request_uri`, `params` (민감정보 마스킹), `response_code`, `response_time_ms`
|
||
- `@Async` 비동기 저장 (`api_access_log`)
|
||
|
||
---
|
||
|
||
## 5. 유틸리티 (`util/`)
|
||
|
||
### `DateUtil`
|
||
- `today()` → `"2026-05-10"`
|
||
- `getSettleMonth()` → `"202605"`, `getSettleMonth(LocalDate)` 동일
|
||
- `getMonthRange("202605")` → `["2026-05-01", "2026-05-31"]`
|
||
- `calcMonthDiff(from, to)`, `addMonth("202401", 3)` → `"202404"`
|
||
- `parseDate(s, "yyyy-MM-dd")`, `format(date, pattern)`
|
||
|
||
### `MoneyUtil` (BigDecimal HALF_UP, 소수점 2)
|
||
- `add/sub/mul/div`, null 안전
|
||
- `pct(amount, ratePct)` → `amount × ratePct / 100`
|
||
- `tax(amount)` → 기본 3.3%, `tax(amount, rate)` 사용자 지정
|
||
- `fmt`/`fmtDec`/`fmtSign` (콤마 포맷)
|
||
- `isZero/isPositive/isNegative`
|
||
|
||
### `MaskUtil`
|
||
- `mask("홍길동", NAME)` → `"홍*동"`
|
||
- `mask("010-1234-5678", PHONE)` → `"010-****-5678"` (11자리/10자리 분기, 9자리 이하 원본)
|
||
- `mask("880101-1234567", RRN)` → `"880101-*******"`
|
||
- `mask("110-123-456789", ACCOUNT)` → `"110-***-******"`
|
||
- `mask("aaa@bbb.com", EMAIL)` → `"a**@bbb.com"`
|
||
|
||
### `EncryptUtil` (`@Component`)
|
||
- AES-256/CBC/PKCS5Padding, IV = key 의 앞 16 byte (운영은 별도 관리 권장)
|
||
- 키 32 byte 미만이면 0 패딩
|
||
- `encrypt(plain)` / `decrypt(b64)` round-trip
|
||
- 실패 시 `BizException(ErrorCode.INTERNAL_ERROR)`
|
||
|
||
### `SecurityUtil`
|
||
- `currentUserId()`, `currentLoginUser()`, `hasRole(roleCode)`
|
||
|
||
---
|
||
|
||
## 6. 공통코드 (`code/`) — Redis 캐시
|
||
|
||
```java
|
||
@Service @RequiredArgsConstructor
|
||
public class CommonCodeService {
|
||
@Cacheable(value = "commonCode", key = "#groupCode")
|
||
public List<CommonCodeVO> getCodesByGroup(String groupCode) {...}
|
||
|
||
public String getCodeName(String groupCode, String code) {...}
|
||
public void validateCode(String groupCode, String code) // 미존재 시 INVALID_PARAMETER
|
||
|
||
@CacheEvict(value = "commonCode", key = "#groupCode")
|
||
public void refreshCache(String groupCode) {}
|
||
|
||
@CacheEvict(value = "commonCode", allEntries = true)
|
||
public void refreshAll() {}
|
||
|
||
// 그룹 CRUD: 시스템 그룹(is_system='Y')은 update/delete 시 SYSTEM_CODE_LOCKED
|
||
// 상세 CRUD: 동일 (group, code) 중복 시 DUPLICATE_DATA
|
||
// 시스템 그룹 코드 update 시 code/groupCode 변경 무시
|
||
}
|
||
```
|
||
|
||
REST: `GET /api/common/codes/{groupCode}` (캐시 적중), `POST /api/common/codes/groups`,
|
||
`PUT/DELETE /api/common/codes/groups/{groupCode}`, 상세 코드 동일.
|
||
|
||
---
|
||
|
||
## 7. 메뉴 / 권한 (`menu/`)
|
||
|
||
### `MenuTreeBuilder` (정적 유틸)
|
||
```java
|
||
public static List<MenuResp> build(List<MenuVO> flat,
|
||
Map<Long, List<String>> permissionsByMenuId)
|
||
// - parent_menu_id null → root
|
||
// - 부모 미존재 (고아) → root 로 끌어올림
|
||
// - sortOrder 로 재귀 정렬
|
||
// - permissionsByMenuId null 이면 권한 주입 스킵
|
||
```
|
||
|
||
### `PermissionChecker`
|
||
```java
|
||
@Component @RequiredArgsConstructor
|
||
public class PermissionChecker {
|
||
@Cacheable(value = "userPerm",
|
||
key = "T(java.lang.String).format('%d_%s_%s', #userId, #menuCode, #permCode)",
|
||
unless = "#result == false")
|
||
public boolean hasPermission(Long userId, String menuCode, String permCode) {
|
||
if (userId == null) return false;
|
||
return menuMapper.hasPermission(userId, menuCode, permCode) > 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
### `MenuService`
|
||
- `getMyMenus(userId)` → 사용자 권한 기반 메뉴 트리 (CTE 재귀)
|
||
- `getMenuTree()` → 전체 메뉴 트리 (관리자용)
|
||
|
||
REST: `GET /api/menus/my`, `GET /api/menus/tree`, 메뉴 CRUD + 권한 매트릭스.
|
||
|
||
---
|
||
|
||
## 8. 엑셀 엔진 (`excel/`)
|
||
|
||
성능 목표: **다운로드 100만건 < 2분 / 업로드 70만건 < 3분**
|
||
|
||
### `@ExcelColumn` 어노테이션
|
||
```java
|
||
@Target(FIELD) @Retention(RUNTIME)
|
||
public @interface ExcelColumn {
|
||
String header();
|
||
int order();
|
||
int width() default 15;
|
||
String format() default ""; // "#,##0", "yyyy-MM-dd"
|
||
String codeGroup() default ""; // 자동 코드명 변환
|
||
boolean masked() default false; // 다운로드 시 마스킹
|
||
String defaultValue() default "";
|
||
boolean required() default false;
|
||
}
|
||
```
|
||
|
||
### `ExcelService`
|
||
**Export — `SXSSFWorkbook(100)` + `MyBatis Cursor`**
|
||
```java
|
||
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
|
||
Supplier<Cursor<T>> cursorSupplier, HttpServletResponse response) {
|
||
// 1) SXSSFWorkbook (메모리 100행만 유지, gzip 임시파일)
|
||
// 2) ExcelColumnParser.parse(clazz) → 헤더 자동 생성
|
||
// 3) Cursor 로 1건씩 읽어 Row 생성
|
||
// 4) 10만건마다 progress 로그
|
||
// 5) workbook.write(response.getOutputStream()), workbook.dispose()
|
||
}
|
||
```
|
||
|
||
**Import — `xlsx-streamer (StreamingReader)` + 배치 INSERT**
|
||
```java
|
||
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
|
||
int batchSize, Consumer<List<T>> batchConsumer) {
|
||
// 1) StreamingReader (rowCacheSize 100, bufferSize 4KB)
|
||
// 2) 1행 헤더 스킵
|
||
// 3) ExcelRowParser.parse(row, clazz) → VO
|
||
// 4) Bean Validation → 실패 시 ExcelErrorRow 누적
|
||
// 5) batchSize(1000) 마다 batchConsumer.accept(batch)
|
||
// 6) 잔여분 flush
|
||
// 7) 에러 있으면 에러 엑셀 생성 → result.errorFileId
|
||
}
|
||
```
|
||
|
||
**`ExcelImportResult`**: `totalCount`, `successCount`, `errorCount`, `errorFileId`,
|
||
`List<ExcelErrorRow>` (처음 100건).
|
||
|
||
**Template Download**:
|
||
`exportTemplate(fileName, clazz, response)` — 빈 헤더 + `codeGroup` 컬럼에
|
||
드롭다운 유효성검사 + `required=true` 헤더 빨간색.
|
||
|
||
---
|
||
|
||
## 9. 파일 / 알림 / 시스템관리
|
||
|
||
### `FileService`
|
||
- 업로드: `MultipartFile` → `file_storage` 적재, `groupId` 로 그룹화, 최대 100MB
|
||
- 다운로드: `download_count` 증가, `Content-Disposition` 한글 파일명 인코딩
|
||
- 삭제: 논리 삭제 (`is_deleted='Y'`)
|
||
|
||
REST: `POST /api/files/upload` (`groupId` param), `GET /api/files/{fileId}`,
|
||
`DELETE /api/files/{fileId}`, `GET /api/files?groupId=...`
|
||
|
||
### `NotificationService`
|
||
- 생성/조회/읽음 처리, 미읽음 카운트 (`/api/notifications/unread-count`)
|
||
- 발송은 비동기 (Redis pub/sub 또는 `@Async`)
|
||
|
||
### `SystemConfigService`
|
||
- key-value 설정. `is_encrypted='Y'` 인 값은 응답 시 마스킹.
|
||
- Cache 적용 가능 (`commonCode` 와 동일 패턴)
|
||
|
||
### 로그 서비스 (비동기)
|
||
- `ApiAccessLogService.save(...)` — `api_access_log` 적재
|
||
- `DataChangeLogService.save(...)` — `data_change_log` JSONB 적재
|
||
- 둘 다 `@Async("taskExecutor")` (`AsyncConfig`)
|
||
|
||
REST: `/api/system/logs/login`, `/api/system/logs/api`, `/api/system/logs/changes`
|
||
(`/api/system/config`, `/api/system/config/{key}` PUT)
|
||
|
||
---
|
||
|
||
## 10. ga-frontend 공통 (함께 작성)
|
||
|
||
### 폴더 구조
|
||
```
|
||
ga-frontend/src/
|
||
├── api/ # request.ts (axios) + 도메인별 함수
|
||
├── components/
|
||
│ ├── common/ # 본 spec 의 공통 컴포넌트
|
||
│ └── biz/ # 업무 재사용
|
||
├── hooks/ # useCommonCodes, useMenuTree, usePermission, useSearchParam
|
||
├── layouts/ # MainLayout
|
||
├── pages/ # 화면 (06_FRONTEND_SPEC.md)
|
||
├── router/ # 동적 라우터
|
||
├── stores/ # Zustand (auth, menu, notification)
|
||
├── types/ # TS 타입
|
||
└── utils/ # formatMoney, formatDate
|
||
```
|
||
|
||
### `api/request.ts`
|
||
```typescript
|
||
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '' });
|
||
api.interceptors.request.use(c => {
|
||
const t = localStorage.getItem('accessToken');
|
||
if (t) c.headers.Authorization = `Bearer ${t}`;
|
||
return c;
|
||
});
|
||
api.interceptors.response.use(r => r, err => {
|
||
if (err.response?.status === 401) { localStorage.removeItem('accessToken'); location.href = '/login'; }
|
||
if (err.response?.status === 403) message.error('권한이 없습니다');
|
||
return Promise.reject(err);
|
||
});
|
||
export type ApiResponse<T> = { success: boolean; code: string; message: string; data: T; };
|
||
export type PageResponse<T> = { list: T[]; pageNum: number; pageSize: number; total: number; pages: number; };
|
||
```
|
||
|
||
### 공통 훅 (`hooks/`)
|
||
- `useCommonCodes(groupCode)` — React Query 캐시 (`staleTime: 10분`)
|
||
- `useMenuTree()` — 사용자 메뉴 트리
|
||
- `usePermission(menuCode)` — `{ canRead, canCreate, canUpdate, canDelete, canApprove, canExport }`
|
||
- `useSearchParam(defaults)` — URL 동기화 + 페이징
|
||
- `useGridApi()` — AG Grid 서버사이드 datasource
|
||
|
||
### 공통 컴포넌트 (`components/common/`)
|
||
|
||
**데이터 표시**
|
||
- `<CodeSelect groupCode="AGENT_STATUS" />` — Ant Select + 공통코드
|
||
- `<CodeRadio groupCode="PAY_CYCLE" />`
|
||
- `<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />` — 색 자동
|
||
- 초록: CONFIRMED/APPROVED/ACTIVE/PAID
|
||
- 파랑: PENDING/CALCULATED
|
||
- 빨강: HOLD/SUSPEND/REJECTED
|
||
- 회색: DRAFT/NEW
|
||
- `<MonthPicker />` — 정산월
|
||
- `<MoneyText value={amt} />` — 양수 검정 / 음수 빨강 / 콤마 / null → "-"
|
||
|
||
**검색 / 목록**
|
||
- `<SearchForm conditions={[ {type:'text'|'code'|'dateRange'|'month', name, label, ...} ]} onSearch onReset />`
|
||
- `<DataTable>` — Ant Design Table 래퍼 (단순 목록, 페이징, 행 클릭)
|
||
- `<DataGrid>` — AG Grid Community 래퍼 (서버사이드 페이징/정렬/필터, 셀 편집,
|
||
컬럼 고정, 합계행 `pinnedBottomRow`, 엑셀 내보내기)
|
||
|
||
**액션**
|
||
- `<PermissionButton permCode="CREATE">등록</PermissionButton>`
|
||
- `<ExcelExportButton url="/api/.../export" params fileName showProgress maxRows={1000000} />`
|
||
- `<ExcelImportButton url=... templateUrl=... onComplete maxSize={100} />`
|
||
- `<FileUpload groupId="settle-202605" maxCount={5} />`
|
||
- `<AuditTrail table="payout_rule" recordId={id} />` — 변경이력 타임라인
|
||
|
||
### 동적 라우터 (`router/`)
|
||
- 로그인 → `GET /api/menus/my` → 메뉴 트리
|
||
- `menu.component_path` 기반 `React.lazy()` 동적 import
|
||
- 좌측 Sider 자동 렌더링, 권한 없는 URL → 403 페이지
|
||
|
||
### 레이아웃 (`layouts/MainLayout.tsx`)
|
||
- Ant Design Layout (Sider + Header + Content)
|
||
- Header: 알림 벨 (숫자 뱃지), 사용자 드롭다운, 정산월 표시
|
||
- Sider: 동적 메뉴 트리 (접기/펼치기, 아이콘)
|
||
- Content: Breadcrumb + 페이지
|
||
|
||
---
|
||
|
||
## 11. 단위 테스트 가이드 (선택)
|
||
|
||
`spring-boot-starter-test` 가 JUnit5 + Mockito + AssertJ 모두 포함.
|
||
|
||
대상:
|
||
- `MoneyUtilTest` (10) — pct/tax/mul/div HALF_UP, null 안전
|
||
- `DateUtilTest` (10) — 정산월 범위, 윤년/평년, 월 차이/가산
|
||
- `MaskUtilTest` (7) — NAME/PHONE/RRN/ACCOUNT/EMAIL
|
||
- `EncryptUtilTest` (6) — round-trip, 한글, 짧은 키 패딩
|
||
- `MenuTreeBuilderTest` (6) — 트리 빌드, 정렬, 고아 노드
|
||
- `PermissionCheckerTest` (4) — userId null 단락, mapper 위임
|
||
- `CommonCodeServiceTest` (14) — 그룹·상세 CRUD, 시스템 코드 잠금
|
||
|
||
주의: `@Cacheable`/`@CacheEvict` 는 단위 테스트(직접 인스턴스 호출)에서 동작하지 않음 — 비즈니스 로직만 검증.
|
||
Mockito strict stubbing → `@BeforeEach` 의 stub 은 모든 테스트가 사용해야 함.
|