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:
+37
@@ -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/
|
||||||
@@ -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` 기반 메뉴×권한 매트릭스
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ga-admin: 관리자 기능 (라이브러리)
|
||||||
|
bootJar.enabled = false
|
||||||
|
jar.enabled = true
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api project(':ga-core')
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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<...> 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 < 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 >= #{param.startDate}::timestamp
|
||||||
|
</if>
|
||||||
|
<if test="param.endDate != null and param.endDate != ''">
|
||||||
|
AND login_at <= #{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 >= #{param.startDate}::timestamp
|
||||||
|
</if>
|
||||||
|
<if test="param.endDate != null and param.endDate != ''">
|
||||||
|
AND created_at <= #{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 >= #{param.startDate}::timestamp
|
||||||
|
</if>
|
||||||
|
<if test="param.endDate != null and param.endDate != ''">
|
||||||
|
AND created_at <= #{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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ga-core: 도메인 코어 (VO/Mapper/SQL)
|
||||||
|
bootJar.enabled = false
|
||||||
|
jar.enabled = true
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api project(':ga-common')
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
rootProject.name = 'ga-commission-system'
|
||||||
|
|
||||||
|
include 'ga-common'
|
||||||
|
include 'ga-core'
|
||||||
|
include 'ga-api'
|
||||||
|
include 'ga-batch'
|
||||||
|
include 'ga-admin'
|
||||||
Reference in New Issue
Block a user