feat: project skeleton + DB migrations V1-V12 + ga-common framework

- Gradle multi-module: ga-common, ga-core, ga-api, ga-batch, ga-admin
- Flyway V1-V12: org/product/rule/receive/ledger/settle/batch/code/menu/system + seed
- ga-common (complete):
  - model: ApiResponse, PageResponse, SearchParam, TreeNode
  - exception: ErrorCode, BizException, GlobalExceptionHandler
  - annotation + aop: @RequirePermission, @DataChangeLog, ApiLog
  - auth + security: JWT, SecurityConfig
  - mybatis: BaseMapper, EncryptTypeHandler, JsonTypeHandler, AuditInterceptor
  - code (Redis cached) / menu (RBAC tree) / file / system / notification
  - excel: SXSSF+Cursor export, SAX+batch import (1M/700K rows)
  - util: Date/Money/Mask/Encrypt/Security
This commit is contained in:
GA Pro
2026-05-09 21:29:18 +09:00
parent c716f5eb70
commit cef4e48e27
100 changed files with 4914 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
# IDE
.idea/
*.iml
*.iws
.vscode/
.settings/
.project
.classpath
.factorypath
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Node
node_modules/
dist/
.pnpm-store/
# Env
.env
.env.local
*.local
# Build outputs
out/
target/
+113
View File
@@ -0,0 +1,113 @@
# GA 수수료 정산 솔루션
법인보험대리점(GA) 수수료 정산 시스템.
## 기술 스택
| 영역 | 기술 |
|------|------|
| Backend | Java 17, Spring Boot 3.2, Spring Security, Spring Batch |
| ORM | MyBatis 3 + PageHelper |
| DB | PostgreSQL 14+ |
| Cache | Redis 7 |
| Build | Gradle 멀티모듈 |
| Frontend | (예정) React 18 + Ant Design 5 + AG Grid |
## 모듈 구조
```
ga-commission-system/
├── ga-common/ # 공통 프레임워크 (인증/권한/로그/엑셀/공통코드/메뉴 등)
├── ga-core/ # 도메인 코어 (VO, Mapper, SQL) — 예정
├── ga-api/ # REST API (실행 모듈)
├── ga-batch/ # Spring Batch (실행 모듈) — 예정
└── ga-admin/ # 관리자 기능 — 예정
```
## 진행 상황
- [x] 1단계 — 프로젝트 뼈대 + DB 마이그레이션
- [x] Gradle 멀티모듈 + 5개 모듈
- [x] Flyway V1~V12 (조직/상품/규정/수신/원장/정산/배치/공통코드/메뉴/시스템/초기데이터)
- [x] **ga-common — 공통 프레임워크 (완성)**
- [x] `model/` — ApiResponse, PageResponse, SearchParam, TreeNode
- [x] `exception/` — ErrorCode, BizException, GlobalExceptionHandler
- [x] `annotation/`@RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
- [x] `aop/` — Permission/DataChangeLog/ApiLog Aspect
- [x] `auth/` — JwtTokenProvider, JwtAuthFilter, LoginUser
- [x] `security/` — SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandler
- [x] `config/` — MyBatis/Redis/Swagger/Web/Async Config
- [x] `mybatis/` — BaseMapper, EncryptTypeHandler, JsonTypeHandler, AuditInterceptor
- [x] `code/` — CommonCodeService(Redis 캐시) + Controller + Mapper
- [x] `menu/` — MenuService, PermissionChecker, MenuTreeBuilder + Controller
- [x] `excel/` — 대용량 Export(SXSSF+Cursor) / Import(SAX+배치)
- [x] `file/` — FileService + Controller
- [x] `system/` — SystemConfig, DataChangeLog, ApiAccessLog Service
- [x] `notification/` — NotificationService + Controller
- [x] `util/` — DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil
- [x] `grid/` — GridSearchParam, GridResponse (AG Grid 서버사이드)
- [ ] ga-core (예정) — 22개 테이블의 VO/Mapper/XML
- [ ] ga-api (예정) — Controller + Service
- [ ] ga-batch (예정) — MonthlySettlementJob 8 Step
- [ ] ga-frontend (예정) — React + AntD + AG Grid
## 실행 (DB만 우선 검증)
```bash
# PostgreSQL & Redis 기동 후 (예: docker compose)
psql -h localhost -U ga -c "CREATE DATABASE ga"
# Flyway 마이그레이션은 ga-api 부팅 시 자동 실행됨
# (ga-api의 spring.datasource 와 Flyway 설정에 따름)
./gradlew :ga-api:bootRun
```
## 핵심 사용 예시
### 1) 컨트롤러 표준 패턴
```java
@RestController @RequestMapping("/api/agents")
@RequiredArgsConstructor
public class AgentController {
private final AgentService service;
@GetMapping
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
}
```
### 2) 서비스 표준 패턴
```java
public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage(); // PageHelper
return PageResponse.of(mapper.selectList(param));
}
```
### 3) 공통코드 검증
```java
codeService.validateCode("AGENT_STATUS", req.getStatus()); // 없으면 예외
String name = codeService.getCodeName("AGENT_STATUS", "ACTIVE"); // "정상"
```
### 4) 대용량 엑셀 다운로드
```java
@GetMapping("/export")
@RequirePermission(menu = "LEDGER", perm = "EXPORT")
public void export(LedgerSearchParam param, HttpServletResponse res) {
excelService.exportLargeExcel("모집수수료원장",
RecruitLedgerExcelVO.class,
() -> ledgerMapper.selectCursor(param), res);
}
```
## 보안
- JWT (HS256) — 액세스 2시간, 리프레시 7일
- AES-256 CBC 암호화 (`resident_no`, `account_no`) — `EncryptTypeHandler` 자동 적용
- RBAC — `role_menu_permission` 기반 메뉴×권한 매트릭스
+60
View File
@@ -0,0 +1,60 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
}
allprojects {
group = 'com.ga'
version = '1.0.0-alpha'
repositories {
mavenCentral()
}
}
subprojects {
apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'io.spring.dependency-management'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
compileJava {
options.encoding = 'UTF-8'
options.compilerArgs << '-parameters'
}
compileTestJava {
options.encoding = 'UTF-8'
}
dependencyManagement {
imports {
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
}
}
dependencies {
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
// MapStruct
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
}
+7
View File
@@ -0,0 +1,7 @@
// ga-admin: 관리자 기능 (라이브러리)
bootJar.enabled = false
jar.enabled = true
dependencies {
api project(':ga-core')
}
+13
View File
@@ -0,0 +1,13 @@
// ga-api: REST API (실행 모듈)
apply plugin: 'org.springframework.boot'
bootJar {
enabled = true
archiveFileName = 'ga-api.jar'
mainClass = 'com.ga.api.GaApiApplication'
}
jar.enabled = false
dependencies {
implementation project(':ga-core')
}
@@ -0,0 +1,15 @@
package com.ga.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* GA 수수료 정산 API 서버.
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
*/
@SpringBootApplication(scanBasePackages = {"com.ga"})
public class GaApiApplication {
public static void main(String[] args) {
SpringApplication.run(GaApiApplication.class, args);
}
}
+56
View File
@@ -0,0 +1,56 @@
spring:
application:
name: ga-api
profiles:
active: local
datasource:
url: jdbc:postgresql://localhost:5432/ga
username: ga
password: ga
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
data:
redis:
host: localhost
port: 6379
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
server:
port: 8080
servlet:
encoding:
charset: UTF-8
force: true
mybatis:
config-location: classpath:mybatis/mybatis-config.xml
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.ga.core.vo,com.ga.common.model
pagehelper:
helper-dialect: postgresql
reasonable: true
support-methods-arguments: true
springdoc:
swagger-ui:
path: /swagger-ui.html
ga:
jwt:
secret: ${JWT_SECRET:change-me-please-this-must-be-at-least-256-bits-long-secret-key}
access-token-expire: 7200000
refresh-token-expire: 604800000
encrypt:
key: ${ENCRYPT_KEY:0123456789abcdef0123456789abcdef}
logging:
level:
com.ga: DEBUG
org.mybatis: INFO
+14
View File
@@ -0,0 +1,14 @@
// ga-batch: Spring Batch (실행 모듈)
apply plugin: 'org.springframework.boot'
bootJar {
enabled = true
archiveFileName = 'ga-batch.jar'
mainClass = 'com.ga.batch.GaBatchApplication'
}
jar.enabled = false
dependencies {
implementation project(':ga-core')
implementation 'org.springframework.boot:spring-boot-starter-batch'
}
+36
View File
@@ -0,0 +1,36 @@
// ga-common: 공통 프레임워크 (다른 모듈이 의존)
bootJar.enabled = false
jar.enabled = true
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-aop'
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-redis'
api 'org.springframework.boot:spring-boot-starter-cache'
// MyBatis
api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'
// PostgreSQL + Flyway
api 'org.postgresql:postgresql'
api 'org.flywaydb:flyway-core'
// JWT
api 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
// Excel
api 'org.apache.poi:poi-ooxml:5.2.5'
api 'com.monitorjbl:xlsx-streamer:2.2.0'
// OpenAPI / Swagger
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
// Commons
api 'org.apache.commons:commons-lang3'
api 'commons-io:commons-io:2.15.1'
}
@@ -0,0 +1,25 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 데이터 변경 이력 자동 기록.
*
* <pre>
* @DataChangeLog(menu = "RULE", table = "payout_rule")
* public void updatePayoutRule(PayoutRuleVO vo) { ... }
* </pre>
*
* 동작: 메서드 실행 전·후의 대상 데이터를 비교 → data_change_log JSONB 컬럼에 저장.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataChangeLog {
String menu();
String table();
/** 액션 유형. 자동 추정 안 될 때 명시 */
String action() default "";
}
@@ -0,0 +1,22 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 엑셀 컬럼 매핑 어노테이션. ExcelExporter / ExcelImporter 가 사용.
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
String header();
int order();
int width() default 15;
String format() default "";
String codeGroup() default "";
boolean masked() default false;
String defaultValue() default "";
boolean required() default false;
}
@@ -0,0 +1,17 @@
package com.ga.common.annotation;
import com.ga.common.util.MaskUtil.MaskType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 응답 시 자동 마스킹 어노테이션 (응답 직렬화 시 적용).
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Mask {
MaskType value() default MaskType.NAME;
}
@@ -0,0 +1,24 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 메뉴+권한 단위 권한 검증.
*
* <pre>
* @RequirePermission(menu = "ORG_AGENT", perm = "READ")
* public ApiResponse&lt;...&gt; list(...) { ... }
* </pre>
*
* 동작: 현재 사용자의 역할 → role_menu_permission 에 (menu, perm) 권한이 있는지 확인.
* 없으면 BizException(ErrorCode.FORBIDDEN).
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String menu();
String perm() default "READ";
}
@@ -0,0 +1,78 @@
package com.ga.common.aop;
import com.ga.common.system.ApiAccessLogService;
import com.ga.common.util.SecurityUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 모든 @RestController 의 요청을 비동기 로깅한다.
* - URL, Method, 응답시간, 사용자
* - 민감 파라미터(비밀번호 등)는 마스킹
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ApiLogAspect {
private final ApiAccessLogService apiAccessLogService;
@Around("within(@org.springframework.web.bind.annotation.RestController *)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
long start = System.currentTimeMillis();
Throwable error = null;
Object result = null;
try {
result = jp.proceed();
return result;
} catch (Throwable e) {
error = e;
throw e;
} finally {
try {
int elapsed = (int) (System.currentTimeMillis() - start);
HttpServletRequest req = currentRequest();
if (req != null) {
apiAccessLogService.saveAsync(
SecurityUtil.getCurrentUserId(),
req.getMethod(),
req.getRequestURI(),
queryString(req),
error == null ? "200" : "500",
elapsed,
ipOf(req),
error == null ? null : error.getMessage()
);
}
} catch (Exception ex) {
log.debug("ApiLog save failed: {}", ex.getMessage());
}
}
}
private HttpServletRequest currentRequest() {
var attr = RequestContextHolder.getRequestAttributes();
return attr instanceof ServletRequestAttributes sra ? sra.getRequest() : null;
}
private String queryString(HttpServletRequest req) {
String qs = req.getQueryString();
if (qs == null) return null;
// 민감 파라미터 마스킹
return qs.replaceAll("(password|pwd|token)=[^&]*", "$1=***");
}
private String ipOf(HttpServletRequest req) {
String xf = req.getHeader("X-Forwarded-For");
if (xf != null && !xf.isBlank()) return xf.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -0,0 +1,92 @@
package com.ga.common.aop;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.system.DataChangeLogService;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @DataChangeLog 처리.
*
* 정책:
* - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 직렬화 → before
* - 정상 종료 후 동일 인자를 직렬화 → after
* - DataChangeLogService 가 before/after 비교 후 changed_keys 산출하여 INSERT
*
* 단순화를 위해 본 구현은 메서드 인자 전체를 JSON 직렬화 후 저장한다.
* 실제 환경에서는 menu/table 별 조회 콜백을 등록하여 정확한 before-state 를 가져오는 것을 권장.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DataChangeLogAspect {
private final DataChangeLogService dataChangeLogService;
private final ObjectMapper objectMapper;
@Around("@annotation(com.ga.common.annotation.DataChangeLog)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
MethodSignature sig = (MethodSignature) jp.getSignature();
DataChangeLog ann = sig.getMethod().getAnnotation(DataChangeLog.class);
String beforeJson = "{}";
String afterJson;
Object[] args = jp.getArgs();
try {
beforeJson = args.length > 0 ? toJson(args[0]) : "{}";
} catch (Exception ignore) { }
Object result = jp.proceed();
try {
afterJson = args.length > 0 ? toJson(args[0]) : toJson(result);
dataChangeLogService.save(
SecurityUtil.getCurrentUserId(),
ann.menu(),
ann.table(),
extractRecordId(args),
inferAction(ann, sig.getMethod().getName()),
beforeJson,
afterJson
);
} catch (Exception e) {
log.warn("DataChangeLog save failed: {}", e.getMessage());
}
return result;
}
private String toJson(Object o) {
if (o == null) return "{}";
try {
return objectMapper.writeValueAsString(o);
} catch (Exception e) {
return "{}";
}
}
private String extractRecordId(Object[] args) {
for (Object arg : args) {
if (arg instanceof Long l) return String.valueOf(l);
if (arg instanceof Integer i) return String.valueOf(i);
if (arg instanceof String s) return s;
}
return null;
}
private String inferAction(DataChangeLog ann, String methodName) {
if (!ann.action().isBlank()) return ann.action();
String n = methodName.toLowerCase();
if (n.startsWith("create") || n.startsWith("insert") || n.startsWith("add")) return "INSERT";
if (n.startsWith("delete") || n.startsWith("remove")) return "DELETE";
return "UPDATE";
}
}
@@ -0,0 +1,40 @@
package com.ga.common.aop;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.menu.PermissionChecker;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @RequirePermission 처리. 현재 사용자의 (menu, perm) 권한을 검증한다.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionChecker permissionChecker;
@Before("@annotation(com.ga.common.annotation.RequirePermission)")
public void check(org.aspectj.lang.JoinPoint jp) {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
MethodSignature sig = (MethodSignature) jp.getSignature();
RequirePermission ann = sig.getMethod().getAnnotation(RequirePermission.class);
if (ann == null) return;
if (!permissionChecker.hasPermission(userId, ann.menu(), ann.perm())) {
log.warn("Permission denied: userId={}, menu={}, perm={}", userId, ann.menu(), ann.perm());
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -0,0 +1,78 @@
package com.ga.common.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* Authorization: Bearer xxx 헤더에서 JWT 추출 → SecurityContext 설정.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private final JwtTokenProvider tokenProvider;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HEADER);
if (header != null && header.startsWith(PREFIX)) {
String token = header.substring(PREFIX.length());
try {
Claims claims = tokenProvider.parse(token);
Long userId = claims.get("uid", Long.class);
String loginId = claims.getSubject();
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of());
LoginUser user = LoginUser.builder()
.userId(userId)
.loginId(loginId)
.roleCodes(roles)
.build();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (BizException e) {
writeError(response, e.getErrorCode());
return;
} catch (Exception e) {
log.warn("JWT processing error: {}", e.getMessage());
writeError(response, ErrorCode.TOKEN_INVALID);
return;
}
}
chain.doFilter(request, response);
}
private void writeError(HttpServletResponse response, ErrorCode ec) throws IOException {
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,93 @@
package com.ga.common.auth;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* JWT 발급/검증.
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long accessExpire;
private final long refreshExpire;
public JwtTokenProvider(
@Value("${ga.jwt.secret}") String secret,
@Value("${ga.jwt.access-token-expire:7200000}") long accessExpire,
@Value("${ga.jwt.refresh-token-expire:604800000}") long refreshExpire
) {
byte[] raw = secret.getBytes(StandardCharsets.UTF_8);
// 256비트 미만이면 안전하게 패딩
if (raw.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(raw, 0, padded, 0, raw.length);
raw = padded;
}
this.key = Keys.hmacShaKeyFor(raw);
this.accessExpire = accessExpire;
this.refreshExpire = refreshExpire;
}
public String createAccessToken(Long userId, String loginId, List<String> roles) {
return create(userId, loginId, roles, accessExpire, "ACCESS");
}
public String createRefreshToken(Long userId, String loginId) {
return create(userId, loginId, List.of(), refreshExpire, "REFRESH");
}
private String create(Long userId, String loginId, List<String> roles, long expireMs, String type) {
Date now = new Date();
return Jwts.builder()
.subject(loginId)
.claims(Map.of(
"uid", userId,
"roles", roles,
"type", type
))
.issuedAt(now)
.expiration(new Date(now.getTime() + expireMs))
.signWith(key)
.compact();
}
public Claims parse(String token) {
try {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new BizException(ErrorCode.TOKEN_EXPIRED);
} catch (JwtException | IllegalArgumentException e) {
throw new BizException(ErrorCode.TOKEN_INVALID);
}
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (Exception e) {
return false;
}
}
public long getAccessExpire() { return accessExpire; }
public long getRefreshExpire() { return refreshExpire; }
}
@@ -0,0 +1,36 @@
package com.ga.common.auth;
import lombok.Builder;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
/**
* SecurityContext 에 들어갈 인증 주체.
*/
@Getter
@Builder
public class LoginUser implements UserDetails {
private final Long userId;
private final String loginId;
private final String userName;
private final Long agentId;
private final List<String> roleCodes;
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return roleCodes == null ? List.of() :
roleCodes.stream().<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
}
@Override public String getPassword() { return null; }
@Override public String getUsername() { return loginId; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
}
@@ -0,0 +1,91 @@
package com.ga.common.code;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "공통코드")
@RestController
@RequestMapping("/api/common/codes")
@RequiredArgsConstructor
public class CommonCodeController {
private final CommonCodeService service;
/** 활성 코드 목록 조회 (드롭다운 용) — 인증된 사용자라면 모두 호출 가능 */
@GetMapping("/{groupCode}")
public ApiResponse<List<CommonCodeVO>> getCodes(@PathVariable String groupCode) {
return ApiResponse.ok(service.getCodesByGroup(groupCode));
}
// ----- 시스템관리 화면 -----
@GetMapping("/groups")
@RequirePermission(menu = "SYSTEM_CODE", perm = "READ")
public ApiResponse<List<CommonCodeGroupVO>> listGroups(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.listGroups(keyword));
}
@PostMapping("/groups")
@RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE")
public ApiResponse<Void> createGroup(@RequestBody CommonCodeGroupVO vo) {
service.createGroup(vo);
return ApiResponse.ok();
}
@PutMapping("/groups/{groupCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> updateGroup(@PathVariable String groupCode, @RequestBody CommonCodeGroupVO vo) {
vo.setGroupCode(groupCode);
service.updateGroup(vo);
return ApiResponse.ok();
}
@DeleteMapping("/groups/{groupCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE")
public ApiResponse<Void> deleteGroup(@PathVariable String groupCode) {
service.deleteGroup(groupCode);
return ApiResponse.ok();
}
@GetMapping("/groups/{groupCode}/codes")
@RequirePermission(menu = "SYSTEM_CODE", perm = "READ")
public ApiResponse<List<CommonCodeVO>> listCodes(@PathVariable String groupCode) {
return ApiResponse.ok(service.listCodes(groupCode));
}
@PostMapping("/groups/{groupCode}/codes")
@RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE")
public ApiResponse<Void> createCode(@PathVariable String groupCode, @RequestBody CommonCodeVO vo) {
vo.setGroupCode(groupCode);
service.createCode(vo);
return ApiResponse.ok();
}
@PutMapping("/codes/{codeId}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> updateCode(@PathVariable Long codeId, @RequestBody CommonCodeVO vo) {
vo.setCodeId(codeId);
service.updateCode(vo);
return ApiResponse.ok();
}
@DeleteMapping("/codes/{codeId}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE")
public ApiResponse<Void> deleteCode(@PathVariable Long codeId) {
service.deleteCode(codeId);
return ApiResponse.ok();
}
@PostMapping("/refresh")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> refresh(@RequestParam(required = false) String groupCode) {
if (groupCode == null || groupCode.isBlank()) service.refreshAll();
else service.refreshCache(groupCode);
return ApiResponse.ok();
}
}
@@ -0,0 +1,19 @@
package com.ga.common.code;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CommonCodeGroupVO {
private String groupCode;
private String groupName;
private String description;
private String isSystem;
private String isActive;
private Integer sortOrder;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,27 @@
package com.ga.common.code;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommonCodeMapper {
// 그룹
List<CommonCodeGroupVO> selectGroupList(@Param("keyword") String keyword);
CommonCodeGroupVO selectGroup(@Param("groupCode") String groupCode);
int insertGroup(CommonCodeGroupVO vo);
int updateGroup(CommonCodeGroupVO vo);
int deleteGroup(@Param("groupCode") String groupCode);
// 상세 코드
List<CommonCodeVO> selectCodes(@Param("groupCode") String groupCode);
List<CommonCodeVO> selectActiveCodes(@Param("groupCode") String groupCode);
CommonCodeVO selectCode(@Param("codeId") Long codeId);
CommonCodeVO selectByGroupAndCode(@Param("groupCode") String groupCode, @Param("code") String code);
int insertCode(CommonCodeVO vo);
int updateCode(CommonCodeVO vo);
int deleteCode(@Param("codeId") Long codeId);
int existsByGroupAndCode(@Param("groupCode") String groupCode, @Param("code") String code);
}
@@ -0,0 +1,148 @@
package com.ga.common.code;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* 공통코드 조회/관리. Redis 캐시 적용.
*
* 캐시 키: commonCode::{groupCode}
* 무효화: refreshCache(groupCode) 또는 코드 변경 시 자동.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommonCodeService {
private final CommonCodeMapper mapper;
/** 활성 코드 목록 (Redis 캐시) */
@Cacheable(value = "commonCode", key = "#groupCode")
public List<CommonCodeVO> getCodesByGroup(String groupCode) {
return mapper.selectActiveCodes(groupCode);
}
/** 코드 → 코드명 (없으면 null) */
public String getCodeName(String groupCode, String code) {
if (code == null) return null;
return getCodesByGroup(groupCode).stream()
.filter(c -> code.equals(c.getCode()))
.map(CommonCodeVO::getCodeName)
.findFirst().orElse(null);
}
/** 유효성 검증. 미존재 시 예외. */
public void validateCode(String groupCode, String code) {
boolean exists = getCodesByGroup(groupCode).stream()
.anyMatch(c -> c.getCode().equals(code));
if (!exists) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"[" + groupCode + "] 그룹의 [" + code + "] 코드가 없습니다");
}
}
@CacheEvict(value = "commonCode", key = "#groupCode")
public void refreshCache(String groupCode) {
log.info("Common code cache evicted: {}", groupCode);
}
@CacheEvict(value = "commonCode", allEntries = true)
public void refreshAll() {
log.info("Common code cache evicted: ALL");
}
// ========== 그룹 관리 ==========
public List<CommonCodeGroupVO> listGroups(String keyword) {
return mapper.selectGroupList(keyword);
}
public CommonCodeGroupVO getGroup(String groupCode) {
CommonCodeGroupVO g = mapper.selectGroup(groupCode);
if (g == null) throw new BizException(ErrorCode.NOT_FOUND);
return g;
}
@Transactional
public void createGroup(CommonCodeGroupVO vo) {
if (mapper.selectGroup(vo.getGroupCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insertGroup(vo);
}
@Transactional
public void updateGroup(CommonCodeGroupVO vo) {
CommonCodeGroupVO g = getGroup(vo.getGroupCode());
if ("Y".equals(g.getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.updateGroup(vo);
refreshCache(vo.getGroupCode());
}
@Transactional
@CacheEvict(value = "commonCode", key = "#groupCode")
public void deleteGroup(String groupCode) {
CommonCodeGroupVO g = getGroup(groupCode);
if ("Y".equals(g.getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.deleteGroup(groupCode);
}
// ========== 상세 코드 관리 ==========
public List<CommonCodeVO> listCodes(String groupCode) {
return mapper.selectCodes(groupCode);
}
public CommonCodeVO getCode(Long codeId) {
CommonCodeVO c = mapper.selectCode(codeId);
if (c == null) throw new BizException(ErrorCode.NOT_FOUND);
return c;
}
@Transactional
public void createCode(CommonCodeVO vo) {
if (mapper.existsByGroupAndCode(vo.getGroupCode(), vo.getCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insertCode(vo);
refreshCache(vo.getGroupCode());
}
@Transactional
public void updateCode(CommonCodeVO vo) {
CommonCodeVO old = getCode(vo.getCodeId());
// 시스템 그룹은 코드 수정 제한
Optional<CommonCodeGroupVO> g = Optional.ofNullable(mapper.selectGroup(old.getGroupCode()));
if (g.isPresent() && "Y".equals(g.get().getIsSystem())) {
// 코드 식별자(code) 자체는 변경 불가, 코드명/설명만 허용
vo.setCode(old.getCode());
vo.setGroupCode(old.getGroupCode());
}
mapper.updateCode(vo);
refreshCache(old.getGroupCode());
}
@Transactional
public void deleteCode(Long codeId) {
CommonCodeVO c = getCode(codeId);
Optional<CommonCodeGroupVO> g = Optional.ofNullable(mapper.selectGroup(c.getGroupCode()));
if (g.isPresent() && "Y".equals(g.get().getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.deleteCode(codeId);
refreshCache(c.getGroupCode());
}
}
@@ -0,0 +1,25 @@
package com.ga.common.code;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CommonCodeVO {
private Long codeId;
private String groupCode;
private String code;
private String codeName;
private String codeNameEn;
private String codeDesc;
private String attr1;
private String attr2;
private String attr3;
private String refCode;
private Integer sortOrder;
private String isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,24 @@
package com.ga.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("ga-async-");
ex.initialize();
return ex;
}
}
@@ -0,0 +1,16 @@
package com.ga.common.config;
import com.ga.common.mybatis.AuditInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackages = {"com.ga.common.mapper", "com.ga.core.mapper"})
public class MyBatisConfig {
@Bean
public AuditInterceptor auditInterceptor() {
return new AuditInterceptor();
}
}
@@ -0,0 +1,44 @@
package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisTemplate<String, Object> tpl = new RedisTemplate<>();
tpl.setConnectionFactory(cf);
tpl.setKeySerializer(new StringRedisSerializer());
tpl.setHashKeySerializer(new StringRedisSerializer());
GenericJackson2JsonRedisSerializer json = new GenericJackson2JsonRedisSerializer(objectMapper);
tpl.setValueSerializer(json);
tpl.setHashValueSerializer(json);
return tpl;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper)));
return RedisCacheManager.builder(cf).cacheDefaults(cfg).build();
}
}
@@ -0,0 +1,30 @@
package com.ga.common.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
private static final String SCHEME = "bearerAuth";
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("GA 수수료 정산 솔루션 API")
.version("1.0.0-alpha")
.description("법인보험대리점 수수료 정산 시스템 REST API"))
.components(new Components().addSecuritySchemes(SCHEME,
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList(SCHEME));
}
}
@@ -0,0 +1,17 @@
package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebMvcConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.findAndRegisterModules();
}
}
@@ -0,0 +1,36 @@
package com.ga.common.excel;
import com.ga.common.annotation.ExcelColumn;
import lombok.Builder;
import lombok.Getter;
import java.lang.reflect.Field;
@Getter
@Builder
public class ExcelColumnInfo {
private final Field field;
private final String header;
private final int order;
private final int width;
private final String format;
private final String codeGroup;
private final boolean masked;
private final String defaultValue;
private final boolean required;
public static ExcelColumnInfo of(Field field, ExcelColumn ann) {
field.setAccessible(true);
return ExcelColumnInfo.builder()
.field(field)
.header(ann.header())
.order(ann.order())
.width(ann.width())
.format(ann.format())
.codeGroup(ann.codeGroup())
.masked(ann.masked())
.defaultValue(ann.defaultValue())
.required(ann.required())
.build();
}
}
@@ -0,0 +1,26 @@
package com.ga.common.excel;
import com.ga.common.annotation.ExcelColumn;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* VO 클래스 @ExcelColumn 메타정보 추출.
*/
public final class ExcelColumnParser {
private ExcelColumnParser() {}
public static List<ExcelColumnInfo> parse(Class<?> clazz) {
List<ExcelColumnInfo> list = new ArrayList<>();
for (Field f : clazz.getDeclaredFields()) {
ExcelColumn ann = f.getAnnotation(ExcelColumn.class);
if (ann != null) list.add(ExcelColumnInfo.of(f, ann));
}
list.sort(Comparator.comparingInt(ExcelColumnInfo::getOrder));
return list;
}
}
@@ -0,0 +1,20 @@
package com.ga.common.excel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelErrorRow {
private int rowNumber;
private String fieldName;
private String value;
private String errorMessage;
public ExcelErrorRow(int rowNumber, String errorMessage) {
this.rowNumber = rowNumber;
this.errorMessage = errorMessage;
}
}
@@ -0,0 +1,23 @@
package com.ga.common.excel;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class ExcelImportResult {
private int totalCount;
private int successCount;
private int errorCount;
private int skipCount;
private String errorFileId;
private List<ExcelErrorRow> errors = new ArrayList<>();
public void addSuccess(int n) { successCount += n; totalCount += n; }
public void addError(ExcelErrorRow row) {
errors.add(row);
errorCount++;
totalCount++;
}
}
@@ -0,0 +1,257 @@
package com.ga.common.excel;
import com.ga.common.code.CommonCodeService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.MaskUtil;
import com.monitorjbl.xlsx.StreamingReader;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* 대용량 엑셀 처리.
* - exportLargeExcel: SXSSFWorkbook + MyBatis Cursor로 100만건 다운로드
* - importLargeExcel: xlsx-streamer(SAX) + 배치 INSERT로 70만건 업로드
* - exportTemplate: 업로드 양식 (헤더 + 코드그룹 드롭다운)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExcelService {
private final CommonCodeService codeService;
/* =========================================================
다운로드 (Export) SXSSF + Cursor 스트리밍
========================================================= */
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier,
HttpServletResponse response) {
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
wb.setCompressTempFiles(true);
SXSSFSheet sheet = wb.createSheet("Sheet1");
// 1. 헤더
createHeaderRow(wb, sheet, columns);
// 2. 데이터
int rowNum = 1;
try (Cursor<T> cursor = cursorSupplier.get()) {
for (T item : cursor) {
Row row = sheet.createRow(rowNum++);
writeRow(wb, row, item, columns);
if (rowNum % 100_000 == 0) {
log.info("Excel export progress: {} rows", rowNum);
}
}
}
// 3. 응답
setExcelResponseHeader(response, fileName);
wb.write(response.getOutputStream());
wb.dispose();
} catch (Exception e) {
log.error("Excel export error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "엑셀 다운로드 실패");
}
}
/** 빈 템플릿(헤더만) 다운로드 */
public <T> void exportTemplate(String fileName, Class<T> clazz, HttpServletResponse response) {
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
SXSSFSheet sheet = wb.createSheet("Sheet1");
createHeaderRow(wb, sheet, columns);
setExcelResponseHeader(response, fileName);
wb.write(response.getOutputStream());
wb.dispose();
} catch (Exception e) {
log.error("Excel template error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "템플릿 다운로드 실패");
}
}
/* =========================================================
업로드 (Import) SAX 스트리밍 + 배치 INSERT
========================================================= */
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
int batchSize, Consumer<List<T>> batchConsumer) {
ExcelImportResult result = new ExcelImportResult();
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
java.util.List<T> batch = new java.util.ArrayList<>(batchSize);
try (InputStream is = file.getInputStream();
org.apache.poi.ss.usermodel.Workbook wb = StreamingReader.builder()
.rowCacheSize(100)
.bufferSize(4096)
.open(is)) {
Sheet sheet = wb.getSheetAt(0);
int rowNum = 0;
for (Row row : sheet) {
rowNum++;
if (rowNum == 1) continue; // 헤더 스킵
try {
T item = parseRow(row, clazz, columns);
batch.add(item);
if (batch.size() >= batchSize) {
batchConsumer.accept(batch);
result.setSuccessCount(result.getSuccessCount() + batch.size());
result.setTotalCount(result.getTotalCount() + batch.size());
batch.clear();
if (rowNum % 100_000 == 0) {
log.info("Excel import progress: {} rows", rowNum);
}
}
} catch (Exception e) {
result.addError(new ExcelErrorRow(rowNum, null, null, e.getMessage()));
}
}
if (!batch.isEmpty()) {
batchConsumer.accept(batch);
result.setSuccessCount(result.getSuccessCount() + batch.size());
result.setTotalCount(result.getTotalCount() + batch.size());
}
} catch (Exception e) {
log.error("Excel import error", e);
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
}
return result;
}
/* =========================================================
내부 헬퍼
========================================================= */
private void createHeaderRow(SXSSFWorkbook wb, Sheet sheet, List<ExcelColumnInfo> columns) {
CellStyle headerStyle = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(true);
headerStyle.setFont(font);
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setBorderBottom(BorderStyle.THIN);
Row header = sheet.createRow(0);
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Cell c = header.createCell(i);
c.setCellValue(info.getHeader());
c.setCellStyle(headerStyle);
sheet.setColumnWidth(i, info.getWidth() * 256);
}
}
private <T> void writeRow(SXSSFWorkbook wb, Row row, T item, List<ExcelColumnInfo> columns) throws Exception {
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Object value = info.getField().get(item);
Cell cell = row.createCell(i);
writeCell(cell, value, info);
}
}
private void writeCell(Cell cell, Object value, ExcelColumnInfo info) {
if (value == null) { cell.setBlank(); return; }
// 코드그룹 변환 (코드 코드명)
if (!info.getCodeGroup().isBlank() && value instanceof String s) {
String name = codeService.getCodeName(info.getCodeGroup(), s);
cell.setCellValue(name != null ? name : s);
return;
}
// 마스킹
if (info.isMasked() && value instanceof String s) {
cell.setCellValue(MaskUtil.mask(s, MaskUtil.MaskType.RRN));
return;
}
if (value instanceof Number n) cell.setCellValue(n.doubleValue());
else if (value instanceof BigDecimal bd) cell.setCellValue(bd.doubleValue());
else if (value instanceof LocalDate d) cell.setCellValue(d.format(DateTimeFormatter.ISO_LOCAL_DATE));
else if (value instanceof LocalDateTime dt) cell.setCellValue(dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
else if (value instanceof Boolean b) cell.setCellValue(b);
else cell.setCellValue(value.toString());
}
private <T> T parseRow(Row row, Class<T> clazz, List<ExcelColumnInfo> columns) throws Exception {
T item = clazz.getDeclaredConstructor().newInstance();
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Cell cell = row.getCell(i);
Object value = readCell(cell, info);
if (value != null) info.getField().set(item, value);
else if (!info.getDefaultValue().isBlank()) {
info.getField().set(item, convertString(info.getDefaultValue(), info.getField().getType()));
} else if (info.isRequired()) {
throw new IllegalArgumentException("[" + info.getHeader() + "] 은(는) 필수입니다");
}
}
return item;
}
private Object readCell(Cell cell, ExcelColumnInfo info) {
if (cell == null) return null;
Class<?> type = info.getField().getType();
try {
return switch (cell.getCellType()) {
case STRING -> convertString(cell.getStringCellValue(), type);
case NUMERIC -> {
if (DateUtil.isCellDateFormatted(cell) && type == LocalDate.class) {
yield cell.getDateCellValue().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
}
double d = cell.getNumericCellValue();
if (type == BigDecimal.class) yield BigDecimal.valueOf(d);
if (type == Integer.class || type == int.class) yield (int) d;
if (type == Long.class || type == long.class) yield (long) d;
if (type == String.class) yield d == (long) d ? String.valueOf((long) d) : String.valueOf(d);
yield d;
}
case BOOLEAN -> cell.getBooleanCellValue();
case FORMULA -> cell.getStringCellValue();
default -> null;
};
} catch (Exception e) {
return null;
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
private Object convertString(String s, Class<?> type) {
if (s == null) return null;
s = s.trim();
if (s.isEmpty()) return null;
if (type == String.class) return s;
if (type == BigDecimal.class) return new BigDecimal(s.replace(",", ""));
if (type == Integer.class || type == int.class) return Integer.valueOf(s.replace(",", ""));
if (type == Long.class || type == long.class) return Long.valueOf(s.replace(",", ""));
if (type == LocalDate.class) return LocalDate.parse(s);
if (type.isEnum()) return Enum.valueOf((Class<Enum>) type, s);
return s;
}
private void setExcelResponseHeader(HttpServletResponse response, String fileName) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
String encoded = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
}
}
@@ -0,0 +1,35 @@
package com.ga.common.exception;
import lombok.Getter;
/**
* 업무 예외. 서비스에서 throw new BizException(ErrorCode.XXX) 형태로 사용.
*/
@Getter
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
private final String customMessage;
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.customMessage = null;
}
public BizException(ErrorCode errorCode, String customMessage) {
super(customMessage);
this.errorCode = errorCode;
this.customMessage = customMessage;
}
public BizException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode;
this.customMessage = null;
}
public String getDisplayMessage() {
return customMessage != null ? customMessage : errorCode.getMessage();
}
}
@@ -0,0 +1,66 @@
package com.ga.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 시스템 표준 에러 코드.
* - 0xxx: 성공
* - Exxx: 공통 에러
* - Axxx: 인증/권한
* - Bxxx: 업무 에러
* - Cxxx: 외부 연동
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
SUCCESS ("0000", 200, "성공"),
// 공통
BAD_REQUEST ("E400", 400, "잘못된 요청입니다"),
UNAUTHORIZED ("E401", 401, "인증이 필요합니다"),
FORBIDDEN ("E403", 403, "권한이 없습니다"),
NOT_FOUND ("E404", 404, "데이터를 찾을 수 없습니다"),
METHOD_NOT_ALLOWED("E405", 405, "허용되지 않는 요청 방식입니다"),
INTERNAL_ERROR ("E500", 500, "서버 오류가 발생했습니다"),
VALIDATION_FAIL ("E410", 400, "입력값 검증에 실패했습니다"),
INVALID_PARAMETER("E411", 400, "파라미터가 올바르지 않습니다"),
// 인증
TOKEN_EXPIRED ("A001", 401, "토큰이 만료되었습니다"),
TOKEN_INVALID ("A002", 401, "유효하지 않은 토큰입니다"),
LOGIN_FAIL ("A003", 401, "아이디 또는 비밀번호가 올바르지 않습니다"),
ACCOUNT_LOCKED ("A004", 403, "계정이 잠겨있습니다"),
ACCOUNT_RETIRED ("A005", 403, "퇴사한 계정입니다"),
PASSWORD_EXPIRED("A006", 403, "비밀번호가 만료되었습니다"),
PASSWORD_POLICY ("A007", 400, "비밀번호 정책에 맞지 않습니다"),
// 업무
ALREADY_CONFIRMED ("B001", 400, "이미 확정된 정산입니다"),
RULE_VERSION_CONFLICT("B002", 409, "규정 버전이 변경되었습니다. 다시 시도해주세요"),
APPROVAL_REQUIRED ("B003", 400, "승인이 필요합니다"),
INVALID_STATUS ("B004", 400, "상태가 올바르지 않습니다"),
INVALID_PERIOD ("B005", 400, "기간이 올바르지 않습니다"),
NOT_PERMITTED ("B006", 403, "해당 작업이 허용되지 않습니다"),
DEPENDENT_DATA ("B007", 409, "참조 중인 데이터가 있습니다"),
SYSTEM_CODE_LOCKED ("B008", 400, "시스템 공통코드는 수정할 수 없습니다"),
DUPLICATE_DATA ("B009", 409, "중복된 데이터입니다"),
BATCH_RUNNING ("B010", 409, "배치가 실행 중입니다"),
INSUFFICIENT_BALANCE("B011", 400, "잔액이 부족합니다"),
// 파일/엑셀
FILE_NOT_FOUND ("F001", 404, "파일을 찾을 수 없습니다"),
FILE_UPLOAD_FAIL ("F002", 500, "파일 업로드에 실패했습니다"),
FILE_TYPE_NOT_ALLOWED("F003",400, "허용되지 않는 파일 형식입니다"),
FILE_TOO_LARGE ("F004", 400, "파일 크기가 제한을 초과했습니다"),
EXCEL_PARSE_FAIL ("F005", 400, "엑셀 파싱에 실패했습니다"),
// 외부 연동
EXTERNAL_API_FAIL ("C001", 502, "외부 시스템 호출에 실패했습니다");
private final String code;
private final int httpStatus;
private final String message;
}
@@ -0,0 +1,86 @@
package com.ga.common.exception;
import com.ga.common.model.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 전역 예외 처리. 모든 예외를 ApiResponse 표준 응답으로 변환.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBiz(BizException e, HttpServletRequest req) {
log.warn("BizException at {} : {} - {}", req.getRequestURI(), e.getErrorCode(), e.getDisplayMessage());
return build(e.getErrorCode(), e.getDisplayMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
fieldErrors.put(fe.getField(), fe.getDefaultMessage());
}
ErrorCode ec = ErrorCode.VALIDATION_FAIL;
return ResponseEntity.status(ec.getHttpStatus())
.body(ApiResponse.<Map<String, String>>fail(ec.getCode(), ec.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return build(ErrorCode.INVALID_PARAMETER,
"파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethod(HttpRequestMethodNotSupportedException e) {
return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage());
}
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<ApiResponse<Void>> handleDup(DuplicateKeyException e) {
log.warn("DuplicateKeyException: {}", e.getMessage());
return build(ErrorCode.DUPLICATE_DATA, ErrorCode.DUPLICATE_DATA.getMessage());
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleForbidden(AccessDeniedException e) {
return build(ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuth(AuthenticationException e) {
return build(ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegal(IllegalArgumentException e) {
return build(ErrorCode.BAD_REQUEST, e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAll(Exception e, HttpServletRequest req) {
log.error("Unhandled exception at {}", req.getRequestURI(), e);
return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getMessage());
}
private ResponseEntity<ApiResponse<Void>> build(ErrorCode ec, String msg) {
return ResponseEntity.status(ec.getHttpStatus())
.body(ApiResponse.fail(ec.getCode(), msg));
}
}
@@ -0,0 +1,62 @@
package com.ga.common.file;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@Tag(name = "파일")
@Slf4j
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileService service;
@PostMapping
public ApiResponse<FileVO> upload(@RequestParam MultipartFile file,
@RequestParam(required = false, defaultValue = "default") String fileGroup) {
return ApiResponse.ok(service.upload(file, fileGroup));
}
@GetMapping("/{fileId}")
public void download(@PathVariable Long fileId, HttpServletResponse response) {
FileVO vo = service.download(fileId);
Path path = Paths.get(vo.getFilePath());
if (!Files.exists(path)) throw new BizException(ErrorCode.FILE_NOT_FOUND);
try {
response.setContentType(vo.getContentType() != null ? vo.getContentType() : "application/octet-stream");
String encoded = URLEncoder.encode(vo.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
Files.copy(path, response.getOutputStream());
} catch (IOException e) {
log.error("File download error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "파일 다운로드 실패");
}
}
@GetMapping("/groups/{fileGroup}")
public ApiResponse<List<FileVO>> listByGroup(@PathVariable String fileGroup) {
return ApiResponse.ok(service.listByGroup(fileGroup));
}
@DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@PathVariable Long fileId) {
service.delete(fileId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,15 @@
package com.ga.common.file;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FileMapper {
int insert(FileVO vo);
FileVO selectById(@Param("fileId") Long fileId);
List<FileVO> selectByGroup(@Param("fileGroup") String fileGroup);
int markDeleted(@Param("fileId") Long fileId);
int incrementDownloadCount(@Param("fileId") Long fileId);
}
@@ -0,0 +1,91 @@
package com.ga.common.file;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* 파일 업로드/다운로드. 로컬 파일시스템 저장 (S3 등으로 교체 가능한 구조).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
private final FileMapper mapper;
@Value("${ga.file.upload-dir:./uploads}")
private String uploadDir;
@Transactional
public FileVO upload(MultipartFile file, String fileGroup) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, "빈 파일입니다");
}
String original = file.getOriginalFilename();
if (original == null) original = "unknown";
String ext = extension(original);
String stored = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
String relPath = LocalDate.now().toString().replace("-", "/");
Path dir = Paths.get(uploadDir, relPath);
try {
Files.createDirectories(dir);
Path dest = dir.resolve(stored);
file.transferTo(dest.toFile());
FileVO vo = new FileVO();
vo.setFileGroup(fileGroup);
vo.setOriginalName(original);
vo.setStoredName(stored);
vo.setFilePath(dest.toAbsolutePath().toString());
vo.setFileSize(file.getSize());
vo.setContentType(file.getContentType());
vo.setExtension(ext);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
vo.setCreatedAt(java.time.LocalDateTime.now());
mapper.insert(vo);
return vo;
} catch (IOException e) {
log.error("File upload error", e);
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, e.getMessage());
}
}
public FileVO download(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
mapper.incrementDownloadCount(fileId);
return vo;
}
public List<FileVO> listByGroup(String fileGroup) {
return mapper.selectByGroup(fileGroup);
}
@Transactional
public void delete(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
mapper.markDeleted(fileId);
// 물리 삭제는 배치로 처리
}
private String extension(String name) {
int idx = name.lastIndexOf('.');
return idx >= 0 ? name.substring(idx + 1).toLowerCase() : "";
}
}
@@ -0,0 +1,21 @@
package com.ga.common.file;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FileVO {
private Long fileId;
private String fileGroup;
private String originalName;
private String storedName;
private String filePath;
private Long fileSize;
private String contentType;
private String extension;
private Integer downloadCount;
private String isDeleted;
private LocalDateTime createdAt;
private Long createdBy;
}
@@ -0,0 +1,22 @@
package com.ga.common.grid;
import lombok.Builder;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* AG Grid 서버사이드 응답.
* - rows: 현재 페이지 데이터
* - lastRow: 전체 (페이지 표시용; -1이면 없음)
* - pinnedBottomRow: 합계행 (선택)
*/
@Getter
@Builder
public class GridResponse<T> {
private List<T> rows;
private long lastRow;
private Map<String, BigDecimal> pinnedBottomRow;
}
@@ -0,0 +1,32 @@
package com.ga.common.grid;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
import java.util.Map;
/**
* AG Grid 서버사이드용 검색 파라미터.
*
* - filterModel: { fieldName: { type: "contains", filter: "abc" } }
* - sortModel: [ { colId: "agentName", sort: "asc" } ]
*
* MyBatis XML 에서 동적 WHERE 활용 가능.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class GridSearchParam extends SearchParam {
/** AG Grid filterModel — { field: { type, filter, filterTo } } */
private Map<String, Map<String, Object>> filterModel;
/** AG Grid sortModel — [ { colId, sort } ] */
private List<Map<String, String>> sortModel;
/** 시작 행(서버사이드 모드) */
private Integer startRow;
/** 종료 행 */
private Integer endRow;
}
@@ -0,0 +1,56 @@
package com.ga.common.menu;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "메뉴")
@RestController
@RequestMapping("/api/menus")
@RequiredArgsConstructor
public class MenuController {
private final MenuService service;
/** 로그인 사용자의 메뉴 (좌측 사이드바용) */
@GetMapping("/my")
public ApiResponse<List<MenuResp>> myMenus() {
return ApiResponse.ok(service.getMyMenus());
}
@GetMapping("/tree")
@RequirePermission(menu = "SYSTEM_MENU", perm = "READ")
public ApiResponse<List<MenuResp>> tree() {
return ApiResponse.ok(service.getMenuTree());
}
@GetMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "READ")
public ApiResponse<MenuVO> detail(@PathVariable Long menuId) {
return ApiResponse.ok(service.getById(menuId));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_MENU", perm = "CREATE")
public ApiResponse<Long> create(@RequestBody MenuVO vo) {
return ApiResponse.ok(service.create(vo));
}
@PutMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "UPDATE")
public ApiResponse<Void> update(@PathVariable Long menuId, @RequestBody MenuVO vo) {
service.update(menuId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "DELETE")
public ApiResponse<Void> delete(@PathVariable Long menuId) {
service.delete(menuId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,32 @@
package com.ga.common.menu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface MenuMapper {
/** 전체 활성 메뉴 (정렬됨) */
List<MenuVO> selectAllActive();
/** 사용자가 접근 가능한 메뉴 목록 */
List<MenuVO> selectAccessibleMenusByUserId(@Param("userId") Long userId);
/** 사용자별 (menuId → permCodes) 매핑 */
List<Map<String, Object>> selectUserPermissions(@Param("userId") Long userId);
/** 단일 (menuCode, permCode) 권한 보유 여부 */
int hasPermission(@Param("userId") Long userId,
@Param("menuCode") String menuCode,
@Param("permCode") String permCode);
MenuVO selectById(@Param("menuId") Long menuId);
MenuVO selectByCode(@Param("menuCode") String menuCode);
int insert(MenuVO vo);
int update(MenuVO vo);
int deleteById(@Param("menuId") Long menuId);
int countChildren(@Param("menuId") Long menuId);
}
@@ -0,0 +1,31 @@
package com.ga.common.menu;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 메뉴 응답 (트리 구조 + 사용자 권한 코드 포함).
*/
@Data
public class MenuResp {
private Long menuId;
private Long parentMenuId;
private String menuCode;
private String menuName;
private String menuType;
private String menuPath;
private String menuIcon;
private String componentPath;
private Integer menuLevel;
private Integer sortOrder;
private String isVisible;
private String isActive;
/** 현재 사용자가 보유한 perm code 목록 (READ/CREATE/UPDATE...) */
private List<String> permissions = new ArrayList<>();
/** 자식 메뉴 */
private List<MenuResp> children = new ArrayList<>();
}
@@ -0,0 +1,74 @@
package com.ga.common.menu;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuMapper mapper;
/** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함) */
public List<MenuResp> getMyMenus() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
List<MenuVO> flat = mapper.selectAccessibleMenusByUserId(userId);
Map<Long, List<String>> permissions = loadPermissionsByMenuId(userId);
return MenuTreeBuilder.build(flat, permissions);
}
/** 전체 메뉴 트리 (관리자) */
public List<MenuResp> getMenuTree() {
return MenuTreeBuilder.build(mapper.selectAllActive(), Map.of());
}
public MenuVO getById(Long menuId) {
MenuVO m = mapper.selectById(menuId);
if (m == null) throw new BizException(ErrorCode.NOT_FOUND);
return m;
}
@Transactional
public Long create(MenuVO vo) {
if (mapper.selectByCode(vo.getMenuCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insert(vo);
return vo.getMenuId();
}
@Transactional
public void update(Long menuId, MenuVO vo) {
getById(menuId);
vo.setMenuId(menuId);
mapper.update(vo);
}
@Transactional
public void delete(Long menuId) {
if (mapper.countChildren(menuId) > 0) {
throw new BizException(ErrorCode.DEPENDENT_DATA, "하위 메뉴가 존재합니다");
}
mapper.deleteById(menuId);
}
private Map<Long, List<String>> loadPermissionsByMenuId(Long userId) {
Map<Long, List<String>> result = new HashMap<>();
for (Map<String, Object> row : mapper.selectUserPermissions(userId)) {
Long menuId = ((Number) row.get("menuId")).longValue();
String perm = (String) row.get("permCode");
result.computeIfAbsent(menuId, k -> new java.util.ArrayList<>()).add(perm);
}
return result;
}
}
@@ -0,0 +1,54 @@
package com.ga.common.menu;
import java.util.*;
/**
* 평면 메뉴 목록 트리 구조 변환.
*/
public final class MenuTreeBuilder {
private MenuTreeBuilder() {}
public static List<MenuResp> build(List<MenuVO> flat, Map<Long, List<String>> permissionsByMenuId) {
Map<Long, MenuResp> map = new LinkedHashMap<>();
// VO Resp 변환
for (MenuVO vo : flat) {
MenuResp r = new MenuResp();
r.setMenuId(vo.getMenuId());
r.setParentMenuId(vo.getParentMenuId());
r.setMenuCode(vo.getMenuCode());
r.setMenuName(vo.getMenuName());
r.setMenuType(vo.getMenuType());
r.setMenuPath(vo.getMenuPath());
r.setMenuIcon(vo.getMenuIcon());
r.setComponentPath(vo.getComponentPath());
r.setMenuLevel(vo.getMenuLevel());
r.setSortOrder(vo.getSortOrder());
r.setIsVisible(vo.getIsVisible());
r.setIsActive(vo.getIsActive());
if (permissionsByMenuId != null) {
r.setPermissions(permissionsByMenuId.getOrDefault(vo.getMenuId(), List.of()));
}
map.put(vo.getMenuId(), r);
}
// 부모 자식 연결
List<MenuResp> roots = new ArrayList<>();
for (MenuResp r : map.values()) {
if (r.getParentMenuId() == null) {
roots.add(r);
} else {
MenuResp parent = map.get(r.getParentMenuId());
if (parent != null) parent.getChildren().add(r);
else roots.add(r);
}
}
sortRecursive(roots);
return roots;
}
private static void sortRecursive(List<MenuResp> nodes) {
nodes.sort(Comparator.comparing(MenuResp::getSortOrder, Comparator.nullsLast(Integer::compareTo)));
for (MenuResp n : nodes) sortRecursive(n.getChildren());
}
}
@@ -0,0 +1,20 @@
package com.ga.common.menu;
import lombok.Data;
@Data
public class MenuVO {
private Long menuId;
private Long parentMenuId;
private String menuCode;
private String menuName;
private String menuType; // DIRECTORY / PAGE / LINK
private String menuPath;
private String menuIcon;
private String componentPath;
private Integer menuLevel;
private Integer sortOrder;
private String isVisible;
private String isActive;
private String description;
}
@@ -0,0 +1,21 @@
package com.ga.common.menu;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class PermissionChecker {
private final MenuMapper menuMapper;
/** 사용자 + 메뉴 + 권한 조합을 체크. 캐시: userPerm::userId_menuCode_permCode */
@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;
}
}
@@ -0,0 +1,51 @@
package com.ga.common.model;
import com.ga.common.exception.ErrorCode;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 모든 REST API의 표준 응답.
* 신입 개발자는 ApiResponse.ok(...) / ApiResponse.fail(...) 사용하면 된다.
*/
@Getter
public class ApiResponse<T> {
private final boolean success;
private final String code;
private final String message;
private final T data;
private final LocalDateTime timestamp = LocalDateTime.now();
private ApiResponse(boolean success, String code, String message, T data) {
this.success = success;
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
}
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), message, data);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
}
public static <T> ApiResponse<T> fail(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
public static <T> ApiResponse<T> fail(ErrorCode errorCode) {
return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
}
public static <T> ApiResponse<T> fail(ErrorCode errorCode, String message) {
return new ApiResponse<>(false, errorCode.getCode(), message, null);
}
}
@@ -0,0 +1,49 @@
package com.ga.common.model;
import com.github.pagehelper.PageInfo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Collections;
import java.util.List;
/**
* 페이징 응답 (PageHelper 연동).
* 사용 : return PageResponse.of(mapper.selectList(param));
*/
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PageResponse<T> {
private List<T> list;
private int pageNum;
private int pageSize;
private long total;
private int pages;
public static <T> PageResponse<T> of(List<T> list) {
if (list == null) {
return PageResponse.<T>builder()
.list(Collections.emptyList())
.pageNum(1).pageSize(0).total(0).pages(0).build();
}
PageInfo<T> info = new PageInfo<>(list);
return PageResponse.<T>builder()
.list(info.getList())
.pageNum(info.getPageNum())
.pageSize(info.getPageSize())
.total(info.getTotal())
.pages(info.getPages())
.build();
}
public static <T> PageResponse<T> empty() {
return PageResponse.<T>builder()
.list(Collections.emptyList())
.pageNum(1).pageSize(0).total(0).pages(0).build();
}
}
@@ -0,0 +1,45 @@
package com.ga.common.model;
import com.github.pagehelper.PageHelper;
import lombok.Data;
/**
* 공통 검색 파라미터. 모든 *SearchParam 부모.
*
* 사용:
* public PageResponse<X> list(XSearchParam p) {
* p.startPage();
* return PageResponse.of(mapper.selectList(p));
* }
*/
@Data
public class SearchParam {
private int pageNum = 1;
private int pageSize = 20;
private String searchType;
private String searchKeyword;
private String startDate;
private String endDate;
private String status;
private String sortField;
private String sortOrder = "DESC";
/** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */
public void startPage() {
PageHelper.startPage(pageNum, pageSize);
if (sortField != null && !sortField.isBlank()) {
String order = "ASC".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
PageHelper.orderBy(sanitizeSortField(sortField) + " " + order);
}
}
/** SQL Injection 방지: 영문/숫자/_ 만 허용 */
private String sanitizeSortField(String s) {
return s.replaceAll("[^A-Za-z0-9_]", "");
}
}
@@ -0,0 +1,22 @@
package com.ga.common.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 트리 응답 공통 모델 (메뉴, 조직 ).
*/
@Data
public class TreeNode<T> {
private Long id;
private Long parentId;
private String label;
private T data;
private List<TreeNode<T>> children = new ArrayList<>();
public void addChild(TreeNode<T> child) {
children.add(child);
}
}
@@ -0,0 +1,92 @@
package com.ga.common.mybatis;
import com.ga.common.util.SecurityUtil;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Map;
/**
* INSERT / UPDATE 감사 필드(created_at, created_by, updated_at, updated_by) 자동 채운다.
*
* VO 다음 필드가 있을 때만 작동:
* - createdAt, createdBy, updatedAt, updatedBy
*
* Map 또는 Collection 파라미터의 경우 내부 객체 각각에 대해 적용한다.
*/
@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class AuditInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
SqlCommandType type = ms.getSqlCommandType();
if (type == SqlCommandType.INSERT || type == SqlCommandType.UPDATE) {
apply(parameter, type);
}
return invocation.proceed();
}
private void apply(Object parameter, SqlCommandType type) {
if (parameter == null) return;
if (parameter instanceof Collection<?> col) {
col.forEach(o -> applyOne(o, type));
return;
}
if (parameter instanceof Map<?, ?> map) {
for (Object v : map.values()) {
if (v instanceof Collection<?> col) {
col.forEach(o -> applyOne(o, type));
} else {
applyOne(v, type);
}
}
return;
}
applyOne(parameter, type);
}
private void applyOne(Object obj, SqlCommandType type) {
if (obj == null) return;
try {
MetaObject meta = SystemMetaObject.forObject(obj);
LocalDateTime now = LocalDateTime.now();
Long userId = SecurityUtil.getCurrentUserId();
if (type == SqlCommandType.INSERT) {
if (meta.hasSetter("createdAt") && meta.getValue("createdAt") == null) {
meta.setValue("createdAt", now);
}
if (meta.hasSetter("createdBy") && meta.getValue("createdBy") == null && userId != null) {
meta.setValue("createdBy", userId);
}
}
if (type == SqlCommandType.UPDATE) {
if (meta.hasSetter("updatedAt")) {
meta.setValue("updatedAt", now);
}
if (meta.hasSetter("updatedBy") && userId != null) {
meta.setValue("updatedBy", userId);
}
}
} catch (Exception ignore) {
// VO 아닌 단순 파라미터(Long, String ) 무시
}
}
}
@@ -0,0 +1,18 @@
package com.ga.common.mybatis;
import com.ga.common.model.SearchParam;
import java.util.List;
/**
* 공통 CRUD Mapper. 단순한 단일 테이블에 대해 선택적으로 상속하여 사용.
* 복잡한 조인이 필요하면 일반 Mapper Interface 정의해도 된다.
*/
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);
}
@@ -0,0 +1,66 @@
package com.ga.common.mybatis;
import com.ga.common.util.EncryptUtil;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* AES 암호화 컬럼용 TypeHandler.
* Mapper XML 에서 typeHandler="com.ga.common.mybatis.EncryptTypeHandler" 지정.
*/
@Component
@MappedTypes(String.class)
public class EncryptTypeHandler extends BaseTypeHandler<String> implements ApplicationContextAware {
private static EncryptUtil encryptUtil;
@Override
public void setApplicationContext(ApplicationContext ctx) {
EncryptTypeHandler.encryptUtil = ctx.getBean(EncryptUtil.class);
}
@Autowired(required = false)
public void setEncryptUtil(EncryptUtil util) {
EncryptTypeHandler.encryptUtil = util;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, encryptUtil != null ? encryptUtil.encrypt(parameter) : parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return decrypt(rs.getString(columnName));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return decrypt(rs.getString(columnIndex));
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return decrypt(cs.getString(columnIndex));
}
private String decrypt(String v) {
if (v == null || v.isBlank()) return v;
try {
return encryptUtil != null ? encryptUtil.decrypt(v) : v;
} catch (Exception e) {
// 암호화 기존 값일 있음 원본 반환
return v;
}
}
}
@@ -0,0 +1,47 @@
package com.ga.common.mybatis;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.postgresql.util.PGobject;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* PostgreSQL JSONB Jackson JsonNode 변환 TypeHandler.
*/
@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> {
private static final ObjectMapper M = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException {
PGobject pg = new PGobject();
pg.setType("jsonb");
try {
pg.setValue(M.writeValueAsString(parameter));
} catch (Exception e) {
throw new SQLException(e);
}
ps.setObject(i, pg);
}
@Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); }
@Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); }
@Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); }
private JsonNode parse(String json) throws SQLException {
if (json == null || json.isBlank()) return null;
try {
return M.readTree(json);
} catch (Exception e) {
throw new SQLException(e);
}
}
}
@@ -0,0 +1,39 @@
package com.ga.common.notification;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "알림")
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService service;
@GetMapping
public ApiResponse<List<NotificationVO>> list() {
return ApiResponse.ok(service.myNotifications());
}
@GetMapping("/unread-count")
public ApiResponse<Integer> unreadCount() {
return ApiResponse.ok(service.unreadCount());
}
@PutMapping("/{notiId}/read")
public ApiResponse<Void> markRead(@PathVariable Long notiId) {
service.markRead(notiId);
return ApiResponse.ok();
}
@PutMapping("/read-all")
public ApiResponse<Void> markAllRead() {
service.markAllRead();
return ApiResponse.ok();
}
}
@@ -0,0 +1,17 @@
package com.ga.common.notification;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface NotificationMapper {
int insert(NotificationVO vo);
int insertBatch(@Param("list") List<NotificationVO> list);
List<NotificationVO> selectByUser(@Param("userId") Long userId);
int countUnread(@Param("userId") Long userId);
int markRead(@Param("notiId") Long notiId, @Param("userId") Long userId);
int markAllRead(@Param("userId") Long userId);
int deleteOld(@Param("days") int days);
}
@@ -0,0 +1,60 @@
package com.ga.common.notification;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationMapper mapper;
public List<NotificationVO> myNotifications() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
return mapper.selectByUser(userId);
}
public int unreadCount() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) return 0;
return mapper.countUnread(userId);
}
@Transactional
public void markRead(Long notiId) {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
mapper.markRead(notiId, userId);
}
@Transactional
public void markAllRead() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
mapper.markAllRead(userId);
}
@Transactional
public void send(Long userId, String type, String title, String content, String linkUrl) {
NotificationVO vo = new NotificationVO();
vo.setUserId(userId);
vo.setNotiType(type);
vo.setTitle(title);
vo.setContent(content);
vo.setLinkUrl(linkUrl);
mapper.insert(vo);
}
@Transactional
public void sendBatch(List<NotificationVO> list) {
if (list == null || list.isEmpty()) return;
mapper.insertBatch(list);
}
}
@@ -0,0 +1,18 @@
package com.ga.common.notification;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class NotificationVO {
private Long notiId;
private Long userId;
private String notiType; // INFO / WARN / ERROR
private String title;
private String content;
private String linkUrl;
private String isRead;
private LocalDateTime readAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,30 @@
package com.ga.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ErrorCode ec = ErrorCode.FORBIDDEN;
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,30 @@
package com.ga.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorCode ec = ErrorCode.UNAUTHORIZED;
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,76 @@
package com.ga.common.security;
import com.ga.common.auth.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final JwtAuthEntryPoint jwtAuthEntryPoint;
private final AccessDeniedHandlerImpl accessDeniedHandler;
@Bean
public SecurityFilterChain filter(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.exceptionHandling(eh -> eh
.authenticationEntryPoint(jwtAuthEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOriginPatterns(List.of("*"));
c.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
c.setAllowedHeaders(List.of("*"));
c.setExposedHeaders(List.of("Authorization", "Content-Disposition"));
c.setAllowCredentials(true);
UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/**", c);
return src;
}
}
@@ -0,0 +1,25 @@
package com.ga.common.system;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiAccessLogService {
private final SystemLogMapper logMapper;
@Async("asyncExecutor")
public void saveAsync(Long userId, String method, String url, String requestParam,
String responseCode, int responseTime, String ipAddress, String errorMessage) {
try {
logMapper.insertApiAccessLog(userId, method, url, requestParam, responseCode,
responseTime, ipAddress, errorMessage);
} catch (Exception e) {
log.warn("ApiAccessLog save fail: {}", e.getMessage());
}
}
}
@@ -0,0 +1,55 @@
package com.ga.common.system;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* 데이터 변경 이력 저장 (비동기).
* before/after JSON 비교 changed_keys 산출 JSONB INSERT.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataChangeLogService {
private final SystemLogMapper logMapper;
private final ObjectMapper objectMapper;
@Async("asyncExecutor")
public void save(Long userId, String menuCode, String tableName, String recordId,
String actionType, String beforeJson, String afterJson) {
try {
String changedKeys = diffKeys(beforeJson, afterJson);
logMapper.insertDataChangeLog(userId, menuCode, tableName, recordId, actionType,
beforeJson, afterJson, changedKeys);
} catch (Exception e) {
log.warn("DataChangeLog save fail: {}", e.getMessage());
}
}
private String diffKeys(String beforeJson, String afterJson) {
try {
JsonNode b = objectMapper.readTree(beforeJson == null ? "{}" : beforeJson);
JsonNode a = objectMapper.readTree(afterJson == null ? "{}" : afterJson);
List<String> changed = new ArrayList<>();
Iterator<String> names = a.fieldNames();
while (names.hasNext()) {
String n = names.next();
JsonNode bv = b.get(n);
JsonNode av = a.get(n);
if (bv == null || !bv.equals(av)) changed.add(n);
}
return String.join(",", changed);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,32 @@
package com.ga.common.system;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "시스템설정")
@RestController
@RequestMapping("/api/system/config")
@RequiredArgsConstructor
public class SystemConfigController {
private final SystemConfigService service;
@GetMapping
@RequirePermission(menu = "SYSTEM_CONFIG", perm = "READ")
public ApiResponse<List<SystemConfigVO>> list(@RequestParam(required = false) String group) {
return ApiResponse.ok(group == null || group.isBlank() ? service.listAll() : service.listByGroup(group));
}
@PutMapping("/{configKey}")
@RequirePermission(menu = "SYSTEM_CONFIG", perm = "UPDATE")
public ApiResponse<Void> update(@PathVariable String configKey, @RequestBody SystemConfigVO vo) {
vo.setConfigKey(configKey);
service.update(vo);
return ApiResponse.ok();
}
}
@@ -0,0 +1,14 @@
package com.ga.common.system;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SystemConfigMapper {
List<SystemConfigVO> selectAll();
List<SystemConfigVO> selectByGroup(@Param("configGroup") String configGroup);
SystemConfigVO selectByKey(@Param("configKey") String configKey);
int update(SystemConfigVO vo);
}
@@ -0,0 +1,65 @@
package com.ga.common.system;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class SystemConfigService {
private final SystemConfigMapper mapper;
public List<SystemConfigVO> listAll() {
return mapper.selectAll();
}
public List<SystemConfigVO> listByGroup(String group) {
return mapper.selectByGroup(group);
}
@Cacheable(value = "systemConfig", key = "#key")
public Optional<String> getValue(String key) {
SystemConfigVO vo = mapper.selectByKey(key);
return Optional.ofNullable(vo).map(SystemConfigVO::getConfigValue);
}
public String getString(String key, String defaultValue) {
return getValue(key).orElse(defaultValue);
}
public int getInt(String key, int defaultValue) {
return getValue(key).map(Integer::parseInt).orElse(defaultValue);
}
public BigDecimal getDecimal(String key, BigDecimal defaultValue) {
return getValue(key).map(BigDecimal::new).orElse(defaultValue);
}
public boolean getBoolean(String key, boolean defaultValue) {
return getValue(key).map(s -> "true".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s)).orElse(defaultValue);
}
@Transactional
@CacheEvict(value = "systemConfig", key = "#vo.configKey")
public void update(SystemConfigVO vo) {
SystemConfigVO existing = mapper.selectByKey(vo.getConfigKey());
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"Y".equals(existing.getIsEditable())) {
throw new BizException(ErrorCode.NOT_PERMITTED, "수정할 수 없는 설정입니다");
}
vo.setUpdatedAt(LocalDateTime.now());
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
mapper.update(vo);
}
}
@@ -0,0 +1,18 @@
package com.ga.common.system;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SystemConfigVO {
private String configKey;
private String configValue;
private String configGroup;
private String valueType;
private String isEncrypted;
private String isEditable;
private String description;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,65 @@
package com.ga.common.system;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.common.model.SearchParam;
import com.github.pagehelper.PageHelper;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "시스템로그")
@RestController
@RequestMapping("/api/system/logs")
@RequiredArgsConstructor
public class SystemLogController {
private final SystemLogMapper mapper;
@GetMapping("/login")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> loginLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId) {
param.startPage();
Map<String, Object> p = toMap(param);
p.put("userId", userId);
return ApiResponse.ok(PageResponse.of(mapper.selectLoginLogs(p)));
}
@GetMapping("/api")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> apiLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String url) {
PageHelper.startPage(param.getPageNum(), param.getPageSize());
Map<String, Object> p = toMap(param);
p.put("userId", userId);
p.put("url", url);
return ApiResponse.ok(PageResponse.of(mapper.selectApiAccessLogs(p)));
}
@GetMapping("/data-change")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> dataChangeLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String tableName,
@RequestParam(required = false) String recordId) {
PageHelper.startPage(param.getPageNum(), param.getPageSize());
Map<String, Object> p = toMap(param);
p.put("userId", userId);
p.put("tableName", tableName);
p.put("recordId", recordId);
return ApiResponse.ok(PageResponse.of(mapper.selectDataChangeLogs(p)));
}
private Map<String, Object> toMap(SearchParam p) {
Map<String, Object> m = new HashMap<>();
m.put("startDate", p.getStartDate());
m.put("endDate", p.getEndDate());
return m;
}
}
@@ -0,0 +1,40 @@
package com.ga.common.system;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface SystemLogMapper {
int insertLoginLog(@Param("userId") Long userId,
@Param("loginId") String loginId,
@Param("loginType") String loginType,
@Param("failReason") String failReason,
@Param("ipAddress") String ipAddress,
@Param("userAgent") String userAgent);
int insertApiAccessLog(@Param("userId") Long userId,
@Param("method") String method,
@Param("url") String url,
@Param("requestParam") String requestParam,
@Param("responseCode") String responseCode,
@Param("responseTime") Integer responseTime,
@Param("ipAddress") String ipAddress,
@Param("errorMessage") String errorMessage);
int insertDataChangeLog(@Param("userId") Long userId,
@Param("menuCode") String menuCode,
@Param("tableName") String tableName,
@Param("recordId") String recordId,
@Param("actionType") String actionType,
@Param("beforeJson") String beforeJson,
@Param("afterJson") String afterJson,
@Param("changedKeys") String changedKeys);
List<Map<String, Object>> selectLoginLogs(@Param("param") Map<String, Object> param);
List<Map<String, Object>> selectApiAccessLogs(@Param("param") Map<String, Object> param);
List<Map<String, Object>> selectDataChangeLogs(@Param("param") Map<String, Object> param);
}
@@ -0,0 +1,68 @@
package com.ga.common.util;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
/**
* 날짜/정산월 유틸. 정산월 포맷은 "yyyyMM" 6자리.
*/
public final class DateUtil {
private DateUtil() {}
public static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static final DateTimeFormatter FMT_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static final DateTimeFormatter FMT_MONTH = DateTimeFormatter.ofPattern("yyyyMM");
public static final DateTimeFormatter FMT_DATE_KOR = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
public static String today() {
return LocalDate.now().format(FMT_DATE);
}
public static String getSettleMonth() {
return YearMonth.now().format(FMT_MONTH);
}
public static String getSettleMonth(LocalDate date) {
return YearMonth.from(date).format(FMT_MONTH);
}
/** "202605" → ["2026-05-01", "2026-05-31"] */
public static String[] getMonthRange(String settleMonth) {
YearMonth ym = YearMonth.parse(settleMonth, FMT_MONTH);
return new String[] {
ym.atDay(1).format(FMT_DATE),
ym.atEndOfMonth().format(FMT_DATE)
};
}
public static int calcMonthDiff(String fromYyyymm, String toYyyymm) {
YearMonth from = YearMonth.parse(fromYyyymm, FMT_MONTH);
YearMonth to = YearMonth.parse(toYyyymm, FMT_MONTH);
return (int) ChronoUnit.MONTHS.between(from, to);
}
public static int calcMonthDiff(LocalDate from, LocalDate to) {
return (int) ChronoUnit.MONTHS.between(YearMonth.from(from), YearMonth.from(to));
}
public static LocalDate parseDate(String s, String pattern) {
if (s == null || s.isBlank()) return null;
try {
return LocalDate.parse(s, DateTimeFormatter.ofPattern(pattern));
} catch (DateTimeParseException e) {
return null;
}
}
public static String format(LocalDate date, String pattern) {
return date == null ? null : date.format(DateTimeFormatter.ofPattern(pattern));
}
public static String addMonth(String yyyymm, int months) {
return YearMonth.parse(yyyymm, FMT_MONTH).plusMonths(months).format(FMT_MONTH);
}
}
@@ -0,0 +1,62 @@
package com.ga.common.util;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* AES-256 CBC 암호화 (resident_no, account_no 등에 사용).
* 키는 application.yml: ga.encrypt.key (32바이트)
*/
@Component
public class EncryptUtil {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final String AES = "AES";
private final byte[] keyBytes;
private final byte[] iv;
public EncryptUtil(@Value("${ga.encrypt.key}") String key) {
byte[] raw = key.getBytes(StandardCharsets.UTF_8);
// 키를 32바이트로 정규화
this.keyBytes = new byte[32];
for (int i = 0; i < 32; i++) {
keyBytes[i] = i < raw.length ? raw[i] : (byte) 0;
}
// IV는 키의 16바이트 (운영에서는 별도 관리 권장)
this.iv = new byte[16];
System.arraycopy(keyBytes, 0, this.iv, 0, 16);
}
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) return plainText;
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, AES), new IvParameterSpec(iv));
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "암호화 실패");
}
}
public String decrypt(String encryptedBase64) {
if (encryptedBase64 == null || encryptedBase64.isEmpty()) return encryptedBase64;
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, AES), new IvParameterSpec(iv));
byte[] decoded = Base64.getDecoder().decode(encryptedBase64);
return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "복호화 실패");
}
}
}
@@ -0,0 +1,73 @@
package com.ga.common.util;
/**
* 개인정보 마스킹 유틸.
* - NAME : 홍길동 *
* - PHONE: 010-1234-5678 010-****-5678
* - RRN : 880101-1234567 880101-*******
* - ACCT : 110-123-456789 110-***-******
* - EMAIL: aaa@bbb.com a**@bbb.com
*/
public final class MaskUtil {
private MaskUtil() {}
public enum MaskType { NAME, PHONE, RRN, ACCOUNT, EMAIL }
public static String mask(String value, MaskType type) {
if (value == null || value.isBlank()) return value;
return switch (type) {
case NAME -> maskName(value);
case PHONE -> maskPhone(value);
case RRN -> maskRrn(value);
case ACCOUNT -> maskAccount(value);
case EMAIL -> maskEmail(value);
};
}
public static String maskName(String name) {
int len = name.length();
if (len <= 1) return name;
if (len == 2) return name.charAt(0) + "*";
return name.charAt(0) + "*".repeat(len - 2) + name.charAt(len - 1);
}
public static String maskPhone(String phone) {
String digits = phone.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
return digits.substring(0, 3) + "-****-" + digits.substring(7);
}
if (digits.length() == 10) {
return digits.substring(0, 3) + "-***-" + digits.substring(6);
}
return phone;
}
public static String maskRrn(String rrn) {
String s = rrn.replaceAll("[^0-9]", "");
if (s.length() != 13) return rrn;
return s.substring(0, 6) + "-*******";
}
public static String maskAccount(String account) {
if (account == null) return null;
String[] parts = account.split("-");
if (parts.length >= 2) {
StringBuilder sb = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
sb.append('-').append("*".repeat(parts[i].length()));
}
return sb.toString();
}
if (account.length() <= 4) return account;
return account.substring(0, 4) + "*".repeat(account.length() - 4);
}
public static String maskEmail(String email) {
int at = email.indexOf('@');
if (at <= 1) return email;
String id = email.substring(0, at);
String domain = email.substring(at);
return id.charAt(0) + "*".repeat(id.length() - 1) + domain;
}
}
@@ -0,0 +1,73 @@
package com.ga.common.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
/**
* 금액 계산 유틸. 모든 금액은 BigDecimal, 반올림은 HALF_UP, 소수점 2자리.
*/
public final class MoneyUtil {
private MoneyUtil() {}
public static final int SCALE = 2;
public static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
public static final BigDecimal DEFAULT_TAX_RATE = new BigDecimal("3.3");
private static final DecimalFormat FMT = new DecimalFormat("#,##0");
private static final DecimalFormat FMT_DEC = new DecimalFormat("#,##0.00");
private static final DecimalFormat FMT_SIGN = new DecimalFormat("+#,##0;-#,##0");
public static BigDecimal zero(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
public static BigDecimal add(BigDecimal a, BigDecimal b) {
return zero(a).add(zero(b));
}
public static BigDecimal sub(BigDecimal a, BigDecimal b) {
return zero(a).subtract(zero(b));
}
public static BigDecimal mul(BigDecimal a, BigDecimal b) {
return zero(a).multiply(zero(b)).setScale(SCALE, RoundingMode.HALF_UP);
}
public static BigDecimal div(BigDecimal a, BigDecimal b) {
if (b == null || b.signum() == 0) return BigDecimal.ZERO;
return zero(a).divide(b, SCALE, RoundingMode.HALF_UP);
}
/** amount × rate / 100 (HALF_UP, 2자리) */
public static BigDecimal pct(BigDecimal amount, BigDecimal ratePct) {
if (amount == null || ratePct == null) return BigDecimal.ZERO;
return amount.multiply(ratePct).divide(HUNDRED, SCALE, RoundingMode.HALF_UP);
}
/** 세액 계산 (기본 3.3%) */
public static BigDecimal tax(BigDecimal amount) {
return pct(amount, DEFAULT_TAX_RATE);
}
public static BigDecimal tax(BigDecimal amount, BigDecimal ratePct) {
return pct(amount, ratePct);
}
public static String fmt(BigDecimal v) {
return v == null ? "" : FMT.format(v);
}
public static String fmtDec(BigDecimal v) {
return v == null ? "" : FMT_DEC.format(v);
}
public static String fmtSign(BigDecimal v) {
return v == null ? "" : FMT_SIGN.format(v);
}
public static boolean isZero(BigDecimal v) { return v == null || v.signum() == 0; }
public static boolean isPositive(BigDecimal v) { return v != null && v.signum() > 0; }
public static boolean isNegative(BigDecimal v) { return v != null && v.signum() < 0; }
}
@@ -0,0 +1,31 @@
package com.ga.common.util;
import com.ga.common.auth.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
/**
* 현재 로그인 사용자 조회.
*/
public final class SecurityUtil {
private SecurityUtil() {}
public static Optional<LoginUser> getLoginUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
Object principal = auth.getPrincipal();
if (principal instanceof LoginUser lu) return Optional.of(lu);
return Optional.empty();
}
public static Long getCurrentUserId() {
return getLoginUser().map(LoginUser::getUserId).orElse(null);
}
public static String getCurrentLoginId() {
return getLoginUser().map(LoginUser::getLoginId).orElse(null);
}
}
@@ -0,0 +1,54 @@
-- V10: 메뉴 / 권한 / 역할
CREATE TABLE menu (
menu_id SERIAL PRIMARY KEY,
parent_menu_id BIGINT REFERENCES menu(menu_id),
menu_code VARCHAR(50) NOT NULL UNIQUE,
menu_name VARCHAR(100) NOT NULL,
menu_type VARCHAR(20) NOT NULL, -- DIRECTORY / PAGE / LINK
menu_path VARCHAR(200),
menu_icon VARCHAR(50),
component_path VARCHAR(200),
menu_level INT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
is_visible CHAR(1) NOT NULL DEFAULT 'Y',
is_active CHAR(1) NOT NULL DEFAULT 'Y',
description VARCHAR(300)
);
CREATE INDEX idx_menu_parent ON menu(parent_menu_id, sort_order);
CREATE TABLE menu_permission (
perm_id SERIAL PRIMARY KEY,
menu_id BIGINT NOT NULL REFERENCES menu(menu_id),
perm_code VARCHAR(20) NOT NULL, -- READ / CREATE / UPDATE / DELETE / APPROVE / EXPORT / PRINT
perm_name VARCHAR(50) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
UNIQUE (menu_id, perm_code)
);
CREATE TABLE role (
role_id SERIAL PRIMARY KEY,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(100) NOT NULL,
role_desc VARCHAR(300),
role_level INT NOT NULL DEFAULT 0,
is_system CHAR(1) NOT NULL DEFAULT 'N',
is_active CHAR(1) NOT NULL DEFAULT 'Y'
);
CREATE TABLE role_menu_permission (
rmp_id SERIAL PRIMARY KEY,
role_id BIGINT NOT NULL REFERENCES role(role_id),
menu_id BIGINT NOT NULL REFERENCES menu(menu_id),
perm_code VARCHAR(20) NOT NULL,
is_granted CHAR(1) NOT NULL DEFAULT 'Y',
UNIQUE (role_id, menu_id, perm_code)
);
CREATE INDEX idx_rmp_role ON role_menu_permission(role_id);
CREATE TABLE user_role (
ur_id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
role_id BIGINT NOT NULL REFERENCES role(role_id),
UNIQUE (user_id, role_id)
);
@@ -0,0 +1,83 @@
-- V11: 시스템관리 (로그인로그/API로그/변경이력/파일/알림/시스템설정)
CREATE TABLE login_log (
log_id SERIAL PRIMARY KEY,
user_id BIGINT,
login_id VARCHAR(50),
login_type VARCHAR(20), -- SUCCESS / FAIL / LOGOUT
fail_reason VARCHAR(200),
ip_address VARCHAR(50),
user_agent VARCHAR(500),
login_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ll_user ON login_log(user_id, login_at);
CREATE TABLE api_access_log (
log_id SERIAL PRIMARY KEY,
user_id BIGINT,
method VARCHAR(10),
url VARCHAR(500),
request_param TEXT,
response_code VARCHAR(10),
response_time INT,
ip_address VARCHAR(50),
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_aal_user ON api_access_log(user_id, created_at);
CREATE TABLE data_change_log (
log_id SERIAL PRIMARY KEY,
user_id BIGINT,
menu_code VARCHAR(50),
table_name VARCHAR(50),
record_id VARCHAR(100),
action_type VARCHAR(20), -- INSERT / UPDATE / DELETE
before_data JSONB,
after_data JSONB,
changed_keys VARCHAR(1000),
ip_address VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_dcl_table ON data_change_log(table_name, record_id, created_at);
CREATE TABLE file_storage (
file_id SERIAL PRIMARY KEY,
file_group VARCHAR(50),
original_name VARCHAR(300) NOT NULL,
stored_name VARCHAR(300) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
content_type VARCHAR(100),
extension VARCHAR(20),
download_count INT NOT NULL DEFAULT 0,
is_deleted CHAR(1) NOT NULL DEFAULT 'N',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_fs_group ON file_storage(file_group, is_deleted);
CREATE TABLE notification (
noti_id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
noti_type VARCHAR(30),
title VARCHAR(200) NOT NULL,
content VARCHAR(1000),
link_url VARCHAR(300),
is_read CHAR(1) NOT NULL DEFAULT 'N',
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_noti_user ON notification(user_id, is_read, created_at);
CREATE TABLE system_config (
config_key VARCHAR(100) PRIMARY KEY,
config_value VARCHAR(2000),
config_group VARCHAR(50),
value_type VARCHAR(20), -- STRING / NUMBER / BOOLEAN / JSON
is_encrypted CHAR(1) NOT NULL DEFAULT 'N',
is_editable CHAR(1) NOT NULL DEFAULT 'Y',
description VARCHAR(300),
updated_at TIMESTAMP,
updated_by BIGINT
);
@@ -0,0 +1,103 @@
-- V12: 공통코드 / 역할 / 메뉴 / 시스템설정 초기 데이터
-- 1. 공통코드 그룹 18개
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('AGENT_STATUS', '설계사상태', 'ACTIVE/LEAVE/SUSPEND', 'Y', 10),
('CONTRACT_STATUS', '계약상태', 'ACTIVE/LAPSE/...', 'Y', 20),
('SETTLE_STATUS', '정산상태', 'CALCULATED/CONFIRMED/..', 'Y', 30),
('PAYMENT_STATUS', '지급상태', 'PENDING/SENT/DONE/FAIL', 'Y', 40),
('LEDGER_STATUS', '원장상태', 'CALCULATED/...', 'Y', 50),
('PAY_CYCLE', '납입주기', 'MONTHLY/QUARTERLY/...', 'Y', 60),
('PAY_METHOD', '납입방법', 'CARD/AUTO/CASH', 'Y', 70),
('INSURANCE_TYPE', '보험종류', 'WHOLE_LIFE/...', 'Y', 80),
('ORG_TYPE', '조직유형', 'HQ/BR/TM', 'Y', 90),
('GRADE_GROUP', '직급그룹', 'NORMAL/MANAGER/EXEC', 'Y',100),
('BANK_CODE', '은행코드', '주요 은행', 'N',110),
('YN', 'Y/N', '예/아니오', 'Y',120),
('APPROVE_STATUS', '승인상태', 'PENDING/APPROVED/...', 'Y',130),
('RECON_STATUS', '대사상태', 'PENDING/MATCHED/DIFF', 'Y',140),
('EXCEPTION_DIRECTION','예외방향', 'PLUS/MINUS', 'Y',150),
('CHANGE_TYPE', '인사변동유형', 'JOIN/LEAVE/TRANSFER...', 'Y',160),
('NOTI_TYPE', '알림유형', 'INFO/WARN/ERROR', 'Y',170),
('MENU_TYPE', '메뉴유형', 'DIRECTORY/PAGE/LINK', 'Y',180);
-- 2. 공통코드 상세 (대표 40건)
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('AGENT_STATUS','ACTIVE','정상',10),
('AGENT_STATUS','LEAVE','퇴사',20),
('AGENT_STATUS','SUSPEND','정지',30),
('CONTRACT_STATUS','ACTIVE','유효',10),
('CONTRACT_STATUS','LAPSE','실효',20),
('CONTRACT_STATUS','REVIVE','부활',30),
('CONTRACT_STATUS','TERMINATE','해지',40),
('SETTLE_STATUS','CALCULATED','계산완료',10),
('SETTLE_STATUS','CONFIRMED','확정',20),
('SETTLE_STATUS','HOLD','보류',30),
('SETTLE_STATUS','PAID','지급완료',40),
('PAYMENT_STATUS','PENDING','대기',10),
('PAYMENT_STATUS','SENT','이체요청',20),
('PAYMENT_STATUS','DONE','완료',30),
('PAYMENT_STATUS','FAIL','실패',40),
('PAY_CYCLE','MONTHLY','월납',10),
('PAY_CYCLE','QUARTERLY','분기납',20),
('PAY_CYCLE','ANNUAL','연납',30),
('PAY_CYCLE','LUMP','일시납',40),
('PAY_METHOD','CARD','카드',10),
('PAY_METHOD','AUTO','자동이체',20),
('PAY_METHOD','CASH','현금',30),
('INSURANCE_TYPE','WHOLE_LIFE','종신',10),
('INSURANCE_TYPE','TERM','정기',20),
('INSURANCE_TYPE','SAVINGS','저축',30),
('INSURANCE_TYPE','HEALTH','건강',40),
('ORG_TYPE','HQ','본부',10),
('ORG_TYPE','BR','지점',20),
('ORG_TYPE','TM','',30),
('YN','Y','',10),
('YN','N','아니오',20),
('APPROVE_STATUS','PENDING','대기',10),
('APPROVE_STATUS','APPROVED','승인',20),
('APPROVE_STATUS','REJECTED','반려',30),
('RECON_STATUS','PENDING','대기',10),
('RECON_STATUS','MATCHED','일치',20),
('RECON_STATUS','DIFF','차이',30),
('EXCEPTION_DIRECTION','PLUS','지급',10),
('EXCEPTION_DIRECTION','MINUS','공제',20),
('NOTI_TYPE','INFO','정보',10),
('NOTI_TYPE','WARN','경고',20),
('NOTI_TYPE','ERROR','오류',30);
-- 3. 역할 4건
INSERT INTO role (role_code, role_name, role_desc, role_level, is_system) VALUES
('SUPER_ADMIN','슈퍼관리자','전체 권한', 100,'Y'),
('ADMIN', '관리자', '시스템 운영', 80, 'Y'),
('MANAGER', '매니저', '업무 담당', 50, 'Y'),
('AGENT', '설계사', '본인 정보 조회', 10, 'Y');
-- 4. 메뉴 (대분류 10건 + 세부 일부)
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, menu_icon, menu_level, sort_order) VALUES
('DASHBOARD','대시보드','PAGE','/','DashboardOutlined',1,10),
('ORG','조직관리','DIRECTORY',NULL,'TeamOutlined',1,20),
('CONTRACT','계약관리','DIRECTORY',NULL,'FileTextOutlined',1,30),
('RULE','수수료규정','DIRECTORY',NULL,'SettingOutlined',1,40),
('RECEIVE','데이터수신','DIRECTORY',NULL,'CloudUploadOutlined',1,50),
('LEDGER','원장관리','DIRECTORY',NULL,'BookOutlined',1,60),
('SETTLE','정산관리','DIRECTORY',NULL,'CalculatorOutlined',1,70),
('PAYMENT','지급관리','DIRECTORY',NULL,'BankOutlined',1,80),
('REPORT','리포트','DIRECTORY',NULL,'BarChartOutlined',1,90),
('SYSTEM','시스템관리','DIRECTORY',NULL,'ToolOutlined',1,100);
-- 5. 시스템 설정 13건
INSERT INTO system_config (config_key, config_value, config_group, value_type, description) VALUES
('TAX_RATE', '3.3', 'TAX', 'NUMBER', '원천세율(%)'),
('SETTLE_DAY', '5', 'SETTLE', 'NUMBER', '정산 기준일'),
('PAY_DAY', '10', 'SETTLE', 'NUMBER', '지급일'),
('PASSWORD_MIN_LEN', '8', 'SECURITY','NUMBER', '비밀번호 최소 길이'),
('PASSWORD_EXPIRE', '90', 'SECURITY','NUMBER', '비밀번호 만료일(일)'),
('LOGIN_FAIL_LIMIT', '5', 'SECURITY','NUMBER', '로그인 실패 잠금 횟수'),
('TOKEN_EXPIRE', '7200', 'SECURITY','NUMBER', '토큰 만료(초)'),
('UPLOAD_MAX_MB', '100', 'COMMON', 'NUMBER', '업로드 최대 용량(MB)'),
('ALLOW_EXTENSIONS', 'xlsx,xls,csv,pdf,jpg,png','COMMON','STRING','허용 확장자'),
('SMTP_HOST', '', 'MAIL', 'STRING', 'SMTP 서버'),
('SMTP_PORT', '587', 'MAIL', 'NUMBER', 'SMTP 포트'),
('BATCH_RETRY', '3', 'BATCH', 'NUMBER', '배치 재시도 횟수'),
('BATCH_TIMEOUT', '7200', 'BATCH', 'NUMBER', '배치 타임아웃(초)');
@@ -0,0 +1,67 @@
-- V1: 조직 / 인사 (직급, 조직, 설계사, 이력)
CREATE TABLE grade (
grade_id SERIAL PRIMARY KEY,
grade_name VARCHAR(50) NOT NULL,
grade_level INT NOT NULL,
grade_group VARCHAR(20),
description VARCHAR(200),
is_active CHAR(1) NOT NULL DEFAULT 'Y',
sort_order INT NOT NULL DEFAULT 0
);
COMMENT ON TABLE grade IS '직급';
CREATE TABLE organization (
org_id SERIAL PRIMARY KEY,
parent_org_id BIGINT REFERENCES organization(org_id),
org_name VARCHAR(100) NOT NULL,
org_type VARCHAR(10) NOT NULL, -- HQ / BR / TM
org_level INT NOT NULL,
region VARCHAR(50),
effective_from DATE NOT NULL,
effective_to DATE,
is_active CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
created_by BIGINT
);
COMMENT ON TABLE organization IS '조직';
CREATE INDEX idx_org_parent ON organization(parent_org_id);
CREATE INDEX idx_org_type ON organization(org_type, is_active);
CREATE TABLE agent (
agent_id SERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organization(org_id),
grade_id BIGINT NOT NULL REFERENCES grade(grade_id),
agent_name VARCHAR(50) NOT NULL,
resident_no VARCHAR(200), -- AES 암호화
license_no VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
bank_code VARCHAR(10),
account_no VARCHAR(200), -- AES 암호화
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / LEAVE / SUSPEND
join_date DATE,
leave_date DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
COMMENT ON TABLE agent IS '설계사';
CREATE INDEX idx_agent_org ON agent(org_id, status);
CREATE INDEX idx_agent_lic ON agent(license_no);
CREATE INDEX idx_agent_status ON agent(status);
CREATE TABLE agent_org_history (
history_id SERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
from_org_id BIGINT,
to_org_id BIGINT,
from_grade_id BIGINT,
to_grade_id BIGINT,
change_type VARCHAR(20) NOT NULL, -- TRANSFER / PROMOTE / DEMOTE / JOIN / LEAVE
change_date DATE NOT NULL,
change_reason VARCHAR(200),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_aoh_agent ON agent_org_history(agent_id, change_date);
@@ -0,0 +1,55 @@
-- V2: 보험사 / 상품 / 계약
CREATE TABLE insurance_company (
company_id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL UNIQUE,
company_name VARCHAR(100) NOT NULL,
company_type VARCHAR(20), -- LIFE / NONLIFE / GA
biz_no VARCHAR(20),
contact_name VARCHAR(50),
contact_phone VARCHAR(20),
contact_email VARCHAR(100),
is_active CHAR(1) NOT NULL DEFAULT 'Y'
);
COMMENT ON TABLE insurance_company IS '보험사';
CREATE TABLE product (
product_id SERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES insurance_company(company_id),
product_code VARCHAR(50) NOT NULL,
product_name VARCHAR(200) NOT NULL,
insurance_type VARCHAR(30) NOT NULL, -- WHOLE_LIFE / TERM / SAVINGS / HEALTH ...
product_group VARCHAR(30),
pay_period_type VARCHAR(20), -- MONTHLY / QUARTERLY / ANNUAL / LUMP
is_active CHAR(1) NOT NULL DEFAULT 'Y',
launch_date DATE,
end_date DATE,
UNIQUE (company_id, product_code)
);
COMMENT ON TABLE product IS '상품';
CREATE INDEX idx_product_company ON product(company_id, is_active);
CREATE INDEX idx_product_type ON product(insurance_type);
CREATE TABLE contract (
contract_id SERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
product_id BIGINT NOT NULL REFERENCES product(product_id),
policy_no VARCHAR(50) NOT NULL UNIQUE,
contractor_name VARCHAR(100),
insured_name VARCHAR(100),
premium DECIMAL(15,2) NOT NULL,
pay_cycle VARCHAR(10), -- MONTHLY / QUARTERLY / ANNUAL / LUMP
pay_period INT,
coverage_period INT,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / LAPSE / REVIVE / TERMINATE
contract_date DATE NOT NULL,
effective_date DATE,
lapse_date DATE,
revival_date DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
COMMENT ON TABLE contract IS '계약';
CREATE INDEX idx_contract_agent ON contract(agent_id, status);
CREATE INDEX idx_contract_policy ON contract(policy_no);
CREATE INDEX idx_contract_date ON contract(contract_date);
@@ -0,0 +1,72 @@
-- V3: 수수료 규정 (보험사율, 지급율, 오버라이드, 환수, 예외코드)
CREATE TABLE commission_rate (
rate_id SERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(product_id),
commission_year INT NOT NULL,
rate_pct DECIMAL(7,4) NOT NULL,
pay_method_cond VARCHAR(50),
min_premium DECIMAL(15,2),
effective_from DATE NOT NULL,
effective_to DATE,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_cr_product ON commission_rate(product_id, effective_from);
CREATE TABLE payout_rule (
rule_id SERIAL PRIMARY KEY,
grade_id BIGINT NOT NULL REFERENCES grade(grade_id),
insurance_type VARCHAR(30) NOT NULL,
commission_year INT NOT NULL,
payout_pct DECIMAL(7,4) NOT NULL,
pay_method_cond VARCHAR(50),
performance_grade VARCHAR(20),
effective_from DATE NOT NULL,
effective_to DATE,
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_pr_grade ON payout_rule(grade_id, insurance_type, effective_from);
CREATE TABLE override_rule (
override_id SERIAL PRIMARY KEY,
from_grade BIGINT NOT NULL,
to_grade BIGINT NOT NULL,
override_pct DECIMAL(7,4) NOT NULL,
calc_type VARCHAR(20) NOT NULL, -- DIRECT / TREE
insurance_type_cond VARCHAR(30),
cap_amount DECIMAL(15,2),
effective_from DATE NOT NULL,
effective_to DATE
);
CREATE INDEX idx_or_grade ON override_rule(from_grade, to_grade, effective_from);
CREATE TABLE chargeback_rule (
cb_rule_id SERIAL PRIMARY KEY,
company_code VARCHAR(20),
insurance_type VARCHAR(30),
lapse_month_from INT NOT NULL,
lapse_month_to INT NOT NULL,
cb_rate DECIMAL(7,4) NOT NULL,
include_override CHAR(1) NOT NULL DEFAULT 'N',
installment_allowed CHAR(1) NOT NULL DEFAULT 'N',
max_installments INT,
effective_from DATE NOT NULL,
effective_to DATE
);
CREATE INDEX idx_cb_lookup ON chargeback_rule(company_code, insurance_type, effective_from);
CREATE TABLE exception_type_code (
exception_code VARCHAR(20) PRIMARY KEY,
exception_name VARCHAR(100) NOT NULL,
category VARCHAR(30),
direction VARCHAR(10) NOT NULL, -- PLUS / MINUS
auto_calc_yn CHAR(1) NOT NULL DEFAULT 'N',
approval_required CHAR(1) NOT NULL DEFAULT 'N',
tax_included CHAR(1) NOT NULL DEFAULT 'N',
description VARCHAR(200),
is_active CHAR(1) NOT NULL DEFAULT 'Y'
);
@@ -0,0 +1,81 @@
-- V4: 데이터 수신 (보험사 프로파일 / 필드매핑 / 코드매핑 / 원본데이터 / 파싱에러)
CREATE TABLE company_profile (
company_code VARCHAR(20) PRIMARY KEY REFERENCES insurance_company(company_code),
data_format VARCHAR(20), -- EXCEL / CSV / EDI / API
receive_method VARCHAR(20), -- MANUAL / FTP / API / SFTP
file_encoding VARCHAR(20) DEFAULT 'UTF-8',
delimiter VARCHAR(5),
header_row INT DEFAULT 1,
data_start_row INT DEFAULT 2,
sheet_name VARCHAR(50),
date_format VARCHAR(30),
amount_format VARCHAR(30),
api_url VARCHAR(300),
ftp_host VARCHAR(100),
ftp_path VARCHAR(300),
receive_schedule VARCHAR(50),
is_active CHAR(1) NOT NULL DEFAULT 'Y'
);
CREATE TABLE company_field_mapping (
mapping_id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL REFERENCES company_profile(company_code),
source_field VARCHAR(100) NOT NULL,
source_col_index INT,
target_table VARCHAR(50) NOT NULL,
target_field VARCHAR(50) NOT NULL,
data_type VARCHAR(20),
transform_rule VARCHAR(50),
default_value VARCHAR(100),
is_required CHAR(1) NOT NULL DEFAULT 'N',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
effective_from DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE INDEX idx_cfm_company ON company_field_mapping(company_code, target_table);
CREATE TABLE code_mapping (
code_id SERIAL PRIMARY KEY,
company_code VARCHAR(20) NOT NULL,
code_type VARCHAR(30) NOT NULL, -- INSURANCE_TYPE / PAY_METHOD ...
source_code VARCHAR(50) NOT NULL,
source_name VARCHAR(100),
target_code VARCHAR(50) NOT NULL,
target_name VARCHAR(100),
is_active CHAR(1) NOT NULL DEFAULT 'Y',
UNIQUE (company_code, code_type, source_code)
);
CREATE TABLE raw_commission_data (
raw_id SERIAL PRIMARY KEY,
company_code VARCHAR(20),
batch_id VARCHAR(50),
settle_month CHAR(6),
data_type VARCHAR(20),
raw_content TEXT,
parse_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING / OK / ERROR
mapped_ledger_id BIGINT,
mapped_table VARCHAR(50),
file_name VARCHAR(300),
row_number INT,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
parsed_at TIMESTAMP
);
CREATE INDEX idx_raw_lookup ON raw_commission_data(company_code, settle_month, parse_status);
CREATE TABLE parse_error_log (
error_id SERIAL PRIMARY KEY,
raw_id BIGINT REFERENCES raw_commission_data(raw_id),
company_code VARCHAR(20),
error_type VARCHAR(30),
error_field VARCHAR(50),
error_value VARCHAR(500),
error_message VARCHAR(500),
resolve_status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
resolved_by BIGINT,
resolve_note VARCHAR(500),
resolved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_pel_status ON parse_error_log(resolve_status, created_at);
@@ -0,0 +1,88 @@
-- V5: 원장 관리 (모집수수료 / 유지수수료 / 예외금액)
CREATE TABLE recruit_ledger (
ledger_id SERIAL PRIMARY KEY,
contract_id BIGINT REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
policy_no VARCHAR(50),
company_code VARCHAR(20),
product_code VARCHAR(50),
insurance_type VARCHAR(30),
commission_year INT,
premium DECIMAL(15,2),
pay_method VARCHAR(20),
company_rate DECIMAL(7,4),
company_amount DECIMAL(15,2),
payout_rate DECIMAL(7,4),
agent_amount DECIMAL(15,2),
tax_amount DECIMAL(15,2),
reconcile_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
reconcile_diff DECIMAL(15,2),
advance_yn CHAR(1) NOT NULL DEFAULT 'N',
advance_amount DECIMAL(15,2),
chargeback_yn CHAR(1) NOT NULL DEFAULT 'N',
settle_month CHAR(6) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_rl_settle ON recruit_ledger(settle_month, agent_id);
CREATE INDEX idx_rl_policy ON recruit_ledger(policy_no);
CREATE TABLE maintain_ledger (
ledger_id SERIAL PRIMARY KEY,
contract_id BIGINT REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
policy_no VARCHAR(50),
company_code VARCHAR(20),
product_code VARCHAR(50),
insurance_type VARCHAR(30),
commission_year INT,
premium DECIMAL(15,2),
pay_method VARCHAR(20),
company_rate DECIMAL(7,4),
company_amount DECIMAL(15,2),
payout_rate DECIMAL(7,4),
agent_amount DECIMAL(15,2),
tax_amount DECIMAL(15,2),
reconcile_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
reconcile_diff DECIMAL(15,2),
advance_yn CHAR(1) NOT NULL DEFAULT 'N',
advance_amount DECIMAL(15,2),
chargeback_yn CHAR(1) NOT NULL DEFAULT 'N',
settle_month CHAR(6) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_ml_settle ON maintain_ledger(settle_month, agent_id);
CREATE INDEX idx_ml_policy ON maintain_ledger(policy_no);
CREATE TABLE exception_ledger (
ledger_id SERIAL PRIMARY KEY,
contract_id BIGINT REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
exception_code VARCHAR(20) NOT NULL REFERENCES exception_type_code(exception_code),
policy_no VARCHAR(50),
company_code VARCHAR(20),
insurance_type VARCHAR(30),
commission_year INT,
amount DECIMAL(15,2) NOT NULL,
tax_amount DECIMAL(15,2),
settle_month CHAR(6) NOT NULL,
description VARCHAR(500),
approve_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING / APPROVED / REJECTED
approver_id BIGINT,
approve_date TIMESTAMP,
recurring_yn CHAR(1) NOT NULL DEFAULT 'N',
recurring_months INT,
recurring_left INT,
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
version INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_el_settle ON exception_ledger(settle_month, agent_id);
CREATE INDEX idx_el_status ON exception_ledger(approve_status);
@@ -0,0 +1,72 @@
-- V6: 정산 마스터 / 오버라이드 / 환수 / 지급
CREATE TABLE settle_master (
settle_id SERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
recruit_total DECIMAL(15,2) NOT NULL DEFAULT 0,
maintain_total DECIMAL(15,2) NOT NULL DEFAULT 0,
exception_plus DECIMAL(15,2) NOT NULL DEFAULT 0,
exception_minus DECIMAL(15,2) NOT NULL DEFAULT 0,
override_total DECIMAL(15,2) NOT NULL DEFAULT 0,
chargeback_total DECIMAL(15,2) NOT NULL DEFAULT 0,
gross_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
net_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED', -- CALCULATED / CONFIRMED / HOLD / PAID
calc_date TIMESTAMP,
confirmed_by BIGINT,
confirmed_at TIMESTAMP,
hold_reason VARCHAR(500),
UNIQUE (agent_id, settle_month)
);
CREATE INDEX idx_sm_month ON settle_master(settle_month, status);
CREATE TABLE override_settle (
os_id SERIAL PRIMARY KEY,
settle_id BIGINT NOT NULL REFERENCES settle_master(settle_id),
upper_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
lower_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
base_amount DECIMAL(15,2) NOT NULL,
override_pct DECIMAL(7,4) NOT NULL,
override_amount DECIMAL(15,2) NOT NULL,
calc_date TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_os_settle ON override_settle(settle_id);
CREATE INDEX idx_os_upper ON override_settle(upper_agent_id, settle_month);
CREATE TABLE chargeback (
cb_id SERIAL PRIMARY KEY,
contract_id BIGINT REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
policy_no VARCHAR(50),
lapse_month INT,
cb_rate DECIMAL(7,4),
cb_amount DECIMAL(15,2) NOT NULL,
paid_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
remain_amount DECIMAL(15,2) NOT NULL,
settle_month CHAR(6) NOT NULL,
installment_no INT,
max_installments INT,
status VARCHAR(20) NOT NULL DEFAULT 'NEW',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cb_agent ON chargeback(agent_id, settle_month);
CREATE TABLE payment (
payment_id SERIAL PRIMARY KEY,
settle_id BIGINT NOT NULL REFERENCES settle_master(settle_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
pay_amount DECIMAL(15,2) NOT NULL,
bank_code VARCHAR(10),
account_no VARCHAR(200),
pay_date DATE,
pay_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING / SENT / DONE / FAIL
transfer_file_id BIGINT,
fail_reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
CREATE INDEX idx_pay_status ON payment(pay_status, created_at);
CREATE INDEX idx_pay_agent ON payment(agent_id, pay_date);
@@ -0,0 +1,67 @@
-- V7: 배치 / 대사 / 감사 / 사용자
CREATE TABLE batch_job_log (
job_id SERIAL PRIMARY KEY,
job_name VARCHAR(100) NOT NULL,
job_params TEXT,
settle_month CHAR(6),
status VARCHAR(20) NOT NULL DEFAULT 'STARTED',
current_step VARCHAR(50),
progress_pct INT DEFAULT 0,
total_count INT,
processed_count INT,
error_count INT DEFAULT 0,
error_message TEXT,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
finished_at TIMESTAMP,
duration_sec INT,
triggered_by BIGINT
);
CREATE INDEX idx_bjl_lookup ON batch_job_log(job_name, started_at);
CREATE TABLE reconciliation (
recon_id SERIAL PRIMARY KEY,
company_code VARCHAR(20),
settle_month CHAR(6) NOT NULL,
company_total DECIMAL(15,2),
system_total DECIMAL(15,2),
diff_amount DECIMAL(15,2),
diff_count INT,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
resolve_note VARCHAR(500),
resolved_by BIGINT,
resolved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_recon ON reconciliation(company_code, settle_month);
CREATE TABLE audit_log (
audit_id SERIAL PRIMARY KEY,
user_id BIGINT,
action_type VARCHAR(30) NOT NULL,
target_type VARCHAR(50),
target_id VARCHAR(100),
description VARCHAR(500),
ip_address VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_user ON audit_log(user_id, created_at);
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
login_id VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(200) NOT NULL,
user_name VARCHAR(50) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
agent_id BIGINT REFERENCES agent(agent_id),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / LOCKED / RETIRED
fail_count INT NOT NULL DEFAULT 0,
last_login_at TIMESTAMP,
pwd_changed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT
);
CREATE INDEX idx_users_status ON users(status);
@@ -0,0 +1,21 @@
-- V8: 초기 데이터 (직급, 예외코드)
INSERT INTO grade (grade_name, grade_level, grade_group, description, sort_order) VALUES
('FC', 1, 'NORMAL', '일반 설계사', 10),
('SFC', 2, 'NORMAL', '시니어 설계사', 20),
('MGR', 3, 'MANAGER','매니저', 30),
('SMGR', 4, 'MANAGER','시니어 매니저', 40),
('DM', 5, 'EXEC', '본부장', 50),
('GM', 6, 'EXEC', '지사장', 60);
INSERT INTO exception_type_code (exception_code, exception_name, category, direction, auto_calc_yn, approval_required, tax_included, description) VALUES
('ADV', '선지급', 'ADVANCE', 'PLUS', 'N','Y','N','선지급 수수료'),
('PERF', '실적격려금', 'BONUS', 'PLUS', 'N','Y','Y','실적 격려금 지급'),
('AWARD', '시상금', 'BONUS', 'PLUS', 'N','Y','Y','시상 인센티브'),
('SUPPORT', '운영지원금', 'SUPPORT', 'PLUS', 'N','Y','Y','운영 지원 보조'),
('REFUND', '환급금', 'REFUND', 'PLUS', 'Y','N','N','자동 환급'),
('PEN', '제재금', 'PENALTY', 'MINUS', 'N','Y','N','징계/벌과금'),
('PRECOL', '선차감', 'OFFSET', 'MINUS', 'N','Y','N','전월 미공제분'),
('LOAN', '대여금공제', 'LOAN', 'MINUS', 'Y','N','N','대여금 자동 공제'),
('TAX_ADJ', '세금조정', 'TAX', 'MINUS', 'Y','N','N','세금 조정'),
('ETC', '기타', 'ETC', 'PLUS', 'N','Y','Y','기타 항목');
@@ -0,0 +1,35 @@
-- V9: 공통 코드
CREATE TABLE common_code_group (
group_code VARCHAR(20) PRIMARY KEY,
group_name VARCHAR(100) NOT NULL,
description VARCHAR(300),
is_system CHAR(1) NOT NULL DEFAULT 'N',
is_active CHAR(1) NOT NULL DEFAULT 'Y',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT
);
CREATE TABLE common_code (
code_id SERIAL PRIMARY KEY,
group_code VARCHAR(20) NOT NULL REFERENCES common_code_group(group_code),
code VARCHAR(30) NOT NULL,
code_name VARCHAR(100) NOT NULL,
code_name_en VARCHAR(100),
code_desc VARCHAR(300),
attr1 VARCHAR(100),
attr2 VARCHAR(100),
attr3 VARCHAR(100),
ref_code VARCHAR(50),
sort_order INT NOT NULL DEFAULT 0,
is_active CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (group_code, code)
);
CREATE INDEX idx_cc_group_active ON common_code(group_code, is_active, sort_order);
@@ -0,0 +1,124 @@
<?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">
<mapper namespace="com.ga.common.code.CommonCodeMapper">
<resultMap id="GroupMap" type="com.ga.common.code.CommonCodeGroupVO"/>
<resultMap id="CodeMap" type="com.ga.common.code.CommonCodeVO"/>
<sql id="GroupColumns">
group_code, group_name, description, is_system, is_active, sort_order,
created_at, created_by, updated_at, updated_by
</sql>
<sql id="CodeColumns">
code_id, group_code, code, code_name, code_name_en, code_desc,
attr1, attr2, attr3, ref_code, sort_order, is_active,
created_at, created_by, updated_at, updated_by
</sql>
<!-- ========== 그룹 ========== -->
<select id="selectGroupList" resultMap="GroupMap">
SELECT <include refid="GroupColumns"/>
FROM common_code_group
<where>
<if test="keyword != null and keyword != ''">
AND (group_code LIKE CONCAT('%', #{keyword}, '%')
OR group_name LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY sort_order, group_code
</select>
<select id="selectGroup" resultMap="GroupMap">
SELECT <include refid="GroupColumns"/>
FROM common_code_group WHERE group_code = #{groupCode}
</select>
<insert id="insertGroup" parameterType="com.ga.common.code.CommonCodeGroupVO">
INSERT INTO common_code_group (
group_code, group_name, description, is_system, is_active, sort_order,
created_at, created_by
) VALUES (
#{groupCode}, #{groupName}, #{description},
COALESCE(#{isSystem},'N'), COALESCE(#{isActive},'Y'),
COALESCE(#{sortOrder},0), #{createdAt}, #{createdBy}
)
</insert>
<update id="updateGroup" parameterType="com.ga.common.code.CommonCodeGroupVO">
UPDATE common_code_group SET
group_name = #{groupName},
description = #{description},
is_active = #{isActive},
sort_order = #{sortOrder},
updated_at = #{updatedAt},
updated_by = #{updatedBy}
WHERE group_code = #{groupCode}
</update>
<delete id="deleteGroup">
DELETE FROM common_code_group WHERE group_code = #{groupCode} AND is_system = 'N'
</delete>
<!-- ========== 상세 코드 ========== -->
<select id="selectCodes" resultMap="CodeMap">
SELECT <include refid="CodeColumns"/>
FROM common_code
WHERE group_code = #{groupCode}
ORDER BY sort_order, code
</select>
<select id="selectActiveCodes" resultMap="CodeMap">
SELECT <include refid="CodeColumns"/>
FROM common_code
WHERE group_code = #{groupCode} AND is_active = 'Y'
ORDER BY sort_order, code
</select>
<select id="selectCode" resultMap="CodeMap">
SELECT <include refid="CodeColumns"/>
FROM common_code WHERE code_id = #{codeId}
</select>
<select id="selectByGroupAndCode" resultMap="CodeMap">
SELECT <include refid="CodeColumns"/>
FROM common_code WHERE group_code = #{groupCode} AND code = #{code}
</select>
<insert id="insertCode" parameterType="com.ga.common.code.CommonCodeVO" useGeneratedKeys="true" keyProperty="codeId">
INSERT INTO common_code (
group_code, code, code_name, code_name_en, code_desc,
attr1, attr2, attr3, ref_code, sort_order, is_active,
created_at, created_by
) VALUES (
#{groupCode}, #{code}, #{codeName}, #{codeNameEn}, #{codeDesc},
#{attr1}, #{attr2}, #{attr3}, #{refCode},
COALESCE(#{sortOrder},0), COALESCE(#{isActive},'Y'),
#{createdAt}, #{createdBy}
)
</insert>
<update id="updateCode" parameterType="com.ga.common.code.CommonCodeVO">
UPDATE common_code SET
code_name = #{codeName},
code_name_en = #{codeNameEn},
code_desc = #{codeDesc},
attr1 = #{attr1}, attr2 = #{attr2}, attr3 = #{attr3},
ref_code = #{refCode},
sort_order = #{sortOrder},
is_active = #{isActive},
updated_at = #{updatedAt},
updated_by = #{updatedBy}
WHERE code_id = #{codeId}
</update>
<delete id="deleteCode">
DELETE FROM common_code WHERE code_id = #{codeId}
</delete>
<select id="existsByGroupAndCode" resultType="int">
SELECT COUNT(*) FROM common_code WHERE group_code = #{groupCode} AND code = #{code}
</select>
</mapper>
@@ -0,0 +1,35 @@
<?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">
<mapper namespace="com.ga.common.file.FileMapper">
<resultMap id="FileMap" type="com.ga.common.file.FileVO"/>
<insert id="insert" parameterType="com.ga.common.file.FileVO" useGeneratedKeys="true" keyProperty="fileId">
INSERT INTO file_storage (
file_group, original_name, stored_name, file_path, file_size,
content_type, extension, download_count, is_deleted, created_at, created_by
) VALUES (
#{fileGroup}, #{originalName}, #{storedName}, #{filePath}, #{fileSize},
#{contentType}, #{extension}, 0, 'N', #{createdAt}, #{createdBy}
)
</insert>
<select id="selectById" resultMap="FileMap">
SELECT * FROM file_storage WHERE file_id = #{fileId} AND is_deleted = 'N'
</select>
<select id="selectByGroup" resultMap="FileMap">
SELECT * FROM file_storage WHERE file_group = #{fileGroup} AND is_deleted = 'N'
ORDER BY created_at DESC
</select>
<update id="markDeleted">
UPDATE file_storage SET is_deleted = 'Y' WHERE file_id = #{fileId}
</update>
<update id="incrementDownloadCount">
UPDATE file_storage SET download_count = download_count + 1 WHERE file_id = #{fileId}
</update>
</mapper>
@@ -0,0 +1,99 @@
<?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">
<mapper namespace="com.ga.common.menu.MenuMapper">
<resultMap id="MenuMap" type="com.ga.common.menu.MenuVO"/>
<sql id="MenuColumns">
menu_id, parent_menu_id, menu_code, menu_name, menu_type, menu_path, menu_icon,
component_path, menu_level, sort_order, is_visible, is_active, description
</sql>
<select id="selectAllActive" resultMap="MenuMap">
SELECT <include refid="MenuColumns"/>
FROM menu
WHERE is_active = 'Y' AND is_visible = 'Y'
ORDER BY menu_level, sort_order, menu_id
</select>
<!-- 사용자가 접근 가능한 메뉴: 사용자 → 역할 → role_menu_permission → 메뉴 -->
<select id="selectAccessibleMenusByUserId" resultMap="MenuMap">
SELECT DISTINCT
m.menu_id, m.parent_menu_id, m.menu_code, m.menu_name, m.menu_type,
m.menu_path, m.menu_icon, m.component_path, m.menu_level, m.sort_order,
m.is_visible, m.is_active, m.description
FROM menu m
JOIN role_menu_permission rmp ON rmp.menu_id = m.menu_id AND rmp.is_granted = 'Y'
JOIN user_role ur ON ur.role_id = rmp.role_id
WHERE ur.user_id = #{userId}
AND m.is_active = 'Y'
AND m.is_visible = 'Y'
AND rmp.perm_code = 'READ'
ORDER BY m.menu_level, m.sort_order, m.menu_id
</select>
<!-- 사용자가 보유한 모든 (menu_id, perm_code) -->
<select id="selectUserPermissions" resultType="map">
SELECT rmp.menu_id AS "menuId",
rmp.perm_code AS "permCode"
FROM role_menu_permission rmp
JOIN user_role ur ON ur.role_id = rmp.role_id
WHERE ur.user_id = #{userId} AND rmp.is_granted = 'Y'
</select>
<select id="hasPermission" resultType="int">
SELECT COUNT(*)
FROM role_menu_permission rmp
JOIN user_role ur ON ur.role_id = rmp.role_id
JOIN menu m ON m.menu_id = rmp.menu_id
WHERE ur.user_id = #{userId}
AND m.menu_code = #{menuCode}
AND rmp.perm_code = #{permCode}
AND rmp.is_granted = 'Y'
</select>
<select id="selectById" resultMap="MenuMap">
SELECT <include refid="MenuColumns"/> FROM menu WHERE menu_id = #{menuId}
</select>
<select id="selectByCode" resultMap="MenuMap">
SELECT <include refid="MenuColumns"/> FROM menu WHERE menu_code = #{menuCode}
</select>
<insert id="insert" parameterType="com.ga.common.menu.MenuVO" useGeneratedKeys="true" keyProperty="menuId">
INSERT INTO menu (
parent_menu_id, menu_code, menu_name, menu_type, menu_path, menu_icon,
component_path, menu_level, sort_order, is_visible, is_active, description
) VALUES (
#{parentMenuId}, #{menuCode}, #{menuName}, #{menuType}, #{menuPath}, #{menuIcon},
#{componentPath}, #{menuLevel}, COALESCE(#{sortOrder},0),
COALESCE(#{isVisible},'Y'), COALESCE(#{isActive},'Y'), #{description}
)
</insert>
<update id="update" parameterType="com.ga.common.menu.MenuVO">
UPDATE menu SET
parent_menu_id = #{parentMenuId},
menu_name = #{menuName},
menu_type = #{menuType},
menu_path = #{menuPath},
menu_icon = #{menuIcon},
component_path = #{componentPath},
menu_level = #{menuLevel},
sort_order = #{sortOrder},
is_visible = #{isVisible},
is_active = #{isActive},
description = #{description}
WHERE menu_id = #{menuId}
</update>
<delete id="deleteById">
DELETE FROM menu WHERE menu_id = #{menuId}
</delete>
<select id="countChildren" resultType="int">
SELECT COUNT(*) FROM menu WHERE parent_menu_id = #{menuId}
</select>
</mapper>
@@ -0,0 +1,44 @@
<?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">
<mapper namespace="com.ga.common.notification.NotificationMapper">
<resultMap id="NotiMap" type="com.ga.common.notification.NotificationVO"/>
<insert id="insert" parameterType="com.ga.common.notification.NotificationVO" useGeneratedKeys="true" keyProperty="notiId">
INSERT INTO notification (user_id, noti_type, title, content, link_url, is_read, created_at)
VALUES (#{userId}, #{notiType}, #{title}, #{content}, #{linkUrl}, 'N', NOW())
</insert>
<insert id="insertBatch">
INSERT INTO notification (user_id, noti_type, title, content, link_url, is_read, created_at)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.notiType}, #{item.title}, #{item.content}, #{item.linkUrl}, 'N', NOW())
</foreach>
</insert>
<select id="selectByUser" resultMap="NotiMap">
SELECT * FROM notification WHERE user_id = #{userId}
ORDER BY created_at DESC
</select>
<select id="countUnread" resultType="int">
SELECT COUNT(*) FROM notification WHERE user_id = #{userId} AND is_read = 'N'
</select>
<update id="markRead">
UPDATE notification SET is_read = 'Y', read_at = NOW()
WHERE noti_id = #{notiId} AND user_id = #{userId}
</update>
<update id="markAllRead">
UPDATE notification SET is_read = 'Y', read_at = NOW()
WHERE user_id = #{userId} AND is_read = 'N'
</update>
<delete id="deleteOld">
DELETE FROM notification WHERE created_at &lt; NOW() - (#{days} || ' days')::interval
</delete>
</mapper>
@@ -0,0 +1,28 @@
<?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">
<mapper namespace="com.ga.common.system.SystemConfigMapper">
<resultMap id="ConfigMap" type="com.ga.common.system.SystemConfigVO"/>
<select id="selectAll" resultMap="ConfigMap">
SELECT * FROM system_config ORDER BY config_group, config_key
</select>
<select id="selectByGroup" resultMap="ConfigMap">
SELECT * FROM system_config WHERE config_group = #{configGroup} ORDER BY config_key
</select>
<select id="selectByKey" resultMap="ConfigMap">
SELECT * FROM system_config WHERE config_key = #{configKey}
</select>
<update id="update" parameterType="com.ga.common.system.SystemConfigVO">
UPDATE system_config SET
config_value = #{configValue},
updated_at = #{updatedAt},
updated_by = #{updatedBy}
WHERE config_key = #{configKey} AND is_editable = 'Y'
</update>
</mapper>
@@ -0,0 +1,93 @@
<?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">
<mapper namespace="com.ga.common.system.SystemLogMapper">
<insert id="insertLoginLog">
INSERT INTO login_log (user_id, login_id, login_type, fail_reason, ip_address, user_agent, login_at)
VALUES (#{userId}, #{loginId}, #{loginType}, #{failReason}, #{ipAddress}, #{userAgent}, NOW())
</insert>
<insert id="insertApiAccessLog">
INSERT INTO api_access_log (user_id, method, url, request_param, response_code,
response_time, ip_address, error_message, created_at)
VALUES (#{userId}, #{method}, #{url}, #{requestParam}, #{responseCode},
#{responseTime}, #{ipAddress}, #{errorMessage}, NOW())
</insert>
<insert id="insertDataChangeLog">
INSERT INTO data_change_log (user_id, menu_code, table_name, record_id, action_type,
before_data, after_data, changed_keys, created_at)
VALUES (#{userId}, #{menuCode}, #{tableName}, #{recordId}, #{actionType},
CAST(#{beforeJson} AS JSONB), CAST(#{afterJson} AS JSONB), #{changedKeys}, NOW())
</insert>
<select id="selectLoginLogs" resultType="map">
SELECT log_id AS "logId",
user_id AS "userId",
login_id AS "loginId",
login_type AS "loginType",
fail_reason AS "failReason",
ip_address AS "ipAddress",
login_at AS "loginAt"
FROM login_log
<where>
<if test="param.userId != null">AND user_id = #{param.userId}</if>
<if test="param.startDate != null and param.startDate != ''">
AND login_at &gt;= #{param.startDate}::timestamp
</if>
<if test="param.endDate != null and param.endDate != ''">
AND login_at &lt;= #{param.endDate}::timestamp + interval '1 day'
</if>
</where>
ORDER BY login_at DESC
</select>
<select id="selectApiAccessLogs" resultType="map">
SELECT log_id AS "logId",
user_id AS "userId",
method AS "method",
url AS "url",
response_code AS "responseCode",
response_time AS "responseTime",
ip_address AS "ipAddress",
created_at AS "createdAt"
FROM api_access_log
<where>
<if test="param.userId != null">AND user_id = #{param.userId}</if>
<if test="param.url != null and param.url != ''">AND url LIKE CONCAT('%', #{param.url}, '%')</if>
<if test="param.startDate != null and param.startDate != ''">
AND created_at &gt;= #{param.startDate}::timestamp
</if>
<if test="param.endDate != null and param.endDate != ''">
AND created_at &lt;= #{param.endDate}::timestamp + interval '1 day'
</if>
</where>
ORDER BY created_at DESC
</select>
<select id="selectDataChangeLogs" resultType="map">
SELECT log_id AS "logId",
user_id AS "userId",
menu_code AS "menuCode",
table_name AS "tableName",
record_id AS "recordId",
action_type AS "actionType",
changed_keys AS "changedKeys",
created_at AS "createdAt"
FROM data_change_log
<where>
<if test="param.userId != null">AND user_id = #{param.userId}</if>
<if test="param.tableName != null and param.tableName != ''">AND table_name = #{param.tableName}</if>
<if test="param.recordId != null and param.recordId != ''">AND record_id = #{param.recordId}</if>
<if test="param.startDate != null and param.startDate != ''">
AND created_at &gt;= #{param.startDate}::timestamp
</if>
<if test="param.endDate != null and param.endDate != ''">
AND created_at &lt;= #{param.endDate}::timestamp + interval '1 day'
</if>
</where>
ORDER BY created_at DESC
</select>
</mapper>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="cacheEnabled" value="false"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="defaultStatementTimeout" value="60"/>
<setting name="jdbcTypeForNull" value="NULL"/>
</settings>
<typeHandlers>
<typeHandler handler="com.ga.common.mybatis.EncryptTypeHandler"/>
<typeHandler handler="com.ga.common.mybatis.JsonTypeHandler"
javaType="com.fasterxml.jackson.databind.JsonNode"/>
</typeHandlers>
</configuration>
+7
View File
@@ -0,0 +1,7 @@
// ga-core: (VO/Mapper/SQL)
bootJar.enabled = false
jar.enabled = true
dependencies {
api project(':ga-common')
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
+7
View File
@@ -0,0 +1,7 @@
rootProject.name = 'ga-commission-system'
include 'ga-common'
include 'ga-core'
include 'ga-api'
include 'ga-batch'
include 'ga-admin'