diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e9d2c5 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fdbcc1 --- /dev/null +++ b/README.md @@ -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> list(AgentSearchParam param) { + return ApiResponse.ok(service.list(param)); + } +} +``` + +### 2) 서비스 표준 패턴 +```java +public PageResponse 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` 기반 메뉴×권한 매트릭스 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0d1b31e --- /dev/null +++ b/build.gradle @@ -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() + } +} diff --git a/ga-admin/build.gradle b/ga-admin/build.gradle new file mode 100644 index 0000000..006d74f --- /dev/null +++ b/ga-admin/build.gradle @@ -0,0 +1,7 @@ +// ga-admin: 관리자 기능 (라이브러리) +bootJar.enabled = false +jar.enabled = true + +dependencies { + api project(':ga-core') +} diff --git a/ga-api/build.gradle b/ga-api/build.gradle new file mode 100644 index 0000000..d8cb1b5 --- /dev/null +++ b/ga-api/build.gradle @@ -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') +} diff --git a/ga-api/src/main/java/com/ga/api/GaApiApplication.java b/ga-api/src/main/java/com/ga/api/GaApiApplication.java new file mode 100644 index 0000000..395e0ed --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/GaApiApplication.java @@ -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); + } +} diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml new file mode 100644 index 0000000..5e40afe --- /dev/null +++ b/ga-api/src/main/resources/application.yml @@ -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 diff --git a/ga-batch/build.gradle b/ga-batch/build.gradle new file mode 100644 index 0000000..f1841a5 --- /dev/null +++ b/ga-batch/build.gradle @@ -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' +} diff --git a/ga-common/build.gradle b/ga-common/build.gradle new file mode 100644 index 0000000..eb1f29d --- /dev/null +++ b/ga-common/build.gradle @@ -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' +} diff --git a/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java b/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java new file mode 100644 index 0000000..768ae21 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java @@ -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; + +/** + * 데이터 변경 이력 자동 기록. + * + *
+ *   @DataChangeLog(menu = "RULE", table = "payout_rule")
+ *   public void updatePayoutRule(PayoutRuleVO vo) { ... }
+ * 
+ * + * 동작: 메서드 실행 전·후의 대상 데이터를 비교 → data_change_log JSONB 컬럼에 저장. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DataChangeLog { + String menu(); + String table(); + /** 액션 유형. 자동 추정 안 될 때 명시 */ + String action() default ""; +} diff --git a/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java b/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java new file mode 100644 index 0000000..5cc1471 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/annotation/Mask.java b/ga-common/src/main/java/com/ga/common/annotation/Mask.java new file mode 100644 index 0000000..8338397 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/annotation/Mask.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java b/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java new file mode 100644 index 0000000..6070f14 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java @@ -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; + +/** + * 메뉴+권한 단위 권한 검증. + * + *
+ *   @RequirePermission(menu = "ORG_AGENT", perm = "READ")
+ *   public ApiResponse<...> list(...) { ... }
+ * 
+ * + * 동작: 현재 사용자의 역할 → role_menu_permission 에 (menu, perm) 권한이 있는지 확인. + * 없으면 BizException(ErrorCode.FORBIDDEN). + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequirePermission { + String menu(); + String perm() default "READ"; +} diff --git a/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java b/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java new file mode 100644 index 0000000..0d6324b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java @@ -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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java b/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java new file mode 100644 index 0000000..f0494e7 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java @@ -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"; + } +} diff --git a/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java b/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java new file mode 100644 index 0000000..8d2e790 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java @@ -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); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java b/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java new file mode 100644 index 0000000..6a0bdae --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java @@ -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 roles = (List) 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())); + } +} diff --git a/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java b/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java new file mode 100644 index 0000000..c3faed6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java @@ -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 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 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; } +} diff --git a/ga-common/src/main/java/com/ga/common/auth/LoginUser.java b/ga-common/src/main/java/com/ga/common/auth/LoginUser.java new file mode 100644 index 0000000..000964e --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/auth/LoginUser.java @@ -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 roleCodes; + + @Override public Collection getAuthorities() { + return roleCodes == null ? List.of() : + roleCodes.stream().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; } +} diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java new file mode 100644 index 0000000..5c5bc8f --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java @@ -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> getCodes(@PathVariable String groupCode) { + return ApiResponse.ok(service.getCodesByGroup(groupCode)); + } + + // ----- 시스템관리 화면 ----- + + @GetMapping("/groups") + @RequirePermission(menu = "SYSTEM_CODE", perm = "READ") + public ApiResponse> listGroups(@RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.listGroups(keyword)); + } + + @PostMapping("/groups") + @RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE") + public ApiResponse createGroup(@RequestBody CommonCodeGroupVO vo) { + service.createGroup(vo); + return ApiResponse.ok(); + } + + @PutMapping("/groups/{groupCode}") + @RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE") + public ApiResponse 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 deleteGroup(@PathVariable String groupCode) { + service.deleteGroup(groupCode); + return ApiResponse.ok(); + } + + @GetMapping("/groups/{groupCode}/codes") + @RequirePermission(menu = "SYSTEM_CODE", perm = "READ") + public ApiResponse> listCodes(@PathVariable String groupCode) { + return ApiResponse.ok(service.listCodes(groupCode)); + } + + @PostMapping("/groups/{groupCode}/codes") + @RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE") + public ApiResponse 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 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 deleteCode(@PathVariable Long codeId) { + service.deleteCode(codeId); + return ApiResponse.ok(); + } + + @PostMapping("/refresh") + @RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE") + public ApiResponse refresh(@RequestParam(required = false) String groupCode) { + if (groupCode == null || groupCode.isBlank()) service.refreshAll(); + else service.refreshCache(groupCode); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java new file mode 100644 index 0000000..e4ff764 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java new file mode 100644 index 0000000..e288ffd --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java @@ -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 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 selectCodes(@Param("groupCode") String groupCode); + List 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); +} diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java new file mode 100644 index 0000000..90e4fb8 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java @@ -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 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 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 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 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 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()); + } +} diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java new file mode 100644 index 0000000..527fddc --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java b/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java new file mode 100644 index 0000000..4ada2e6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java @@ -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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java b/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java new file mode 100644 index 0000000..092918a --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java @@ -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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/config/RedisConfig.java b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java new file mode 100644 index 0000000..54f9e1b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java @@ -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 redisTemplate(RedisConnectionFactory cf, ObjectMapper objectMapper) { + RedisTemplate 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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java b/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java new file mode 100644 index 0000000..d9104f8 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java @@ -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)); + } +} diff --git a/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java b/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java new file mode 100644 index 0000000..d858a4d --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java @@ -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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java new file mode 100644 index 0000000..3f78072 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java @@ -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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java new file mode 100644 index 0000000..0cee3ec --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java @@ -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 parse(Class clazz) { + List 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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java b/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java new file mode 100644 index 0000000..94fe783 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java @@ -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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java b/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java new file mode 100644 index 0000000..6a67d3b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java @@ -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 errors = new ArrayList<>(); + + public void addSuccess(int n) { successCount += n; totalCount += n; } + public void addError(ExcelErrorRow row) { + errors.add(row); + errorCount++; + totalCount++; + } +} diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java new file mode 100644 index 0000000..8a2f8cf --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java @@ -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 void exportLargeExcel(String fileName, Class clazz, + Supplier> cursorSupplier, + HttpServletResponse response) { + List 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 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 void exportTemplate(String fileName, Class clazz, HttpServletResponse response) { + List 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 ExcelImportResult importLargeExcel(MultipartFile file, Class clazz, + int batchSize, Consumer> batchConsumer) { + ExcelImportResult result = new ExcelImportResult(); + List columns = ExcelColumnParser.parse(clazz); + java.util.List 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 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 void writeRow(SXSSFWorkbook wb, Row row, T item, List 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 parseRow(Row row, Class clazz, List 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) 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); + } +} diff --git a/ga-common/src/main/java/com/ga/common/exception/BizException.java b/ga-common/src/main/java/com/ga/common/exception/BizException.java new file mode 100644 index 0000000..4c406fb --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/exception/BizException.java @@ -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(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java b/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java new file mode 100644 index 0000000..2f07e22 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a954a7c --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java @@ -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> 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>> handleValidation(MethodArgumentNotValidException e) { + Map 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.>fail(ec.getCode(), ec.getMessage())); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { + return build(ErrorCode.INVALID_PARAMETER, + "파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다"); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e) { + return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage()); + } + + @ExceptionHandler(DuplicateKeyException.class) + public ResponseEntity> handleDup(DuplicateKeyException e) { + log.warn("DuplicateKeyException: {}", e.getMessage()); + return build(ErrorCode.DUPLICATE_DATA, ErrorCode.DUPLICATE_DATA.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleForbidden(AccessDeniedException e) { + return build(ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuth(AuthenticationException e) { + return build(ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegal(IllegalArgumentException e) { + return build(ErrorCode.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception e, HttpServletRequest req) { + log.error("Unhandled exception at {}", req.getRequestURI(), e); + return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getMessage()); + } + + private ResponseEntity> build(ErrorCode ec, String msg) { + return ResponseEntity.status(ec.getHttpStatus()) + .body(ApiResponse.fail(ec.getCode(), msg)); + } +} diff --git a/ga-common/src/main/java/com/ga/common/file/FileController.java b/ga-common/src/main/java/com/ga/common/file/FileController.java new file mode 100644 index 0000000..e2ca3ad --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/file/FileController.java @@ -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 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> listByGroup(@PathVariable String fileGroup) { + return ApiResponse.ok(service.listByGroup(fileGroup)); + } + + @DeleteMapping("/{fileId}") + public ApiResponse delete(@PathVariable Long fileId) { + service.delete(fileId); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/file/FileMapper.java b/ga-common/src/main/java/com/ga/common/file/FileMapper.java new file mode 100644 index 0000000..259a6b9 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/file/FileMapper.java @@ -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 selectByGroup(@Param("fileGroup") String fileGroup); + int markDeleted(@Param("fileId") Long fileId); + int incrementDownloadCount(@Param("fileId") Long fileId); +} diff --git a/ga-common/src/main/java/com/ga/common/file/FileService.java b/ga-common/src/main/java/com/ga/common/file/FileService.java new file mode 100644 index 0000000..86374fe --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/file/FileService.java @@ -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 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() : ""; + } +} diff --git a/ga-common/src/main/java/com/ga/common/file/FileVO.java b/ga-common/src/main/java/com/ga/common/file/FileVO.java new file mode 100644 index 0000000..84d6bd0 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/file/FileVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/grid/GridResponse.java b/ga-common/src/main/java/com/ga/common/grid/GridResponse.java new file mode 100644 index 0000000..15e93da --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/grid/GridResponse.java @@ -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 { + private List rows; + private long lastRow; + private Map pinnedBottomRow; +} diff --git a/ga-common/src/main/java/com/ga/common/grid/GridSearchParam.java b/ga-common/src/main/java/com/ga/common/grid/GridSearchParam.java new file mode 100644 index 0000000..dc62f64 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/grid/GridSearchParam.java @@ -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> filterModel; + + /** AG Grid sortModel — [ { colId, sort } ] */ + private List> sortModel; + + /** 시작 행(서버사이드 모드) */ + private Integer startRow; + /** 종료 행 */ + private Integer endRow; +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuController.java b/ga-common/src/main/java/com/ga/common/menu/MenuController.java new file mode 100644 index 0000000..b0d9684 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuController.java @@ -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> myMenus() { + return ApiResponse.ok(service.getMyMenus()); + } + + @GetMapping("/tree") + @RequirePermission(menu = "SYSTEM_MENU", perm = "READ") + public ApiResponse> tree() { + return ApiResponse.ok(service.getMenuTree()); + } + + @GetMapping("/{menuId}") + @RequirePermission(menu = "SYSTEM_MENU", perm = "READ") + public ApiResponse detail(@PathVariable Long menuId) { + return ApiResponse.ok(service.getById(menuId)); + } + + @PostMapping + @RequirePermission(menu = "SYSTEM_MENU", perm = "CREATE") + public ApiResponse create(@RequestBody MenuVO vo) { + return ApiResponse.ok(service.create(vo)); + } + + @PutMapping("/{menuId}") + @RequirePermission(menu = "SYSTEM_MENU", perm = "UPDATE") + public ApiResponse update(@PathVariable Long menuId, @RequestBody MenuVO vo) { + service.update(menuId, vo); + return ApiResponse.ok(); + } + + @DeleteMapping("/{menuId}") + @RequirePermission(menu = "SYSTEM_MENU", perm = "DELETE") + public ApiResponse delete(@PathVariable Long menuId) { + service.delete(menuId); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java b/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java new file mode 100644 index 0000000..6b70fe1 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java @@ -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 selectAllActive(); + + /** 사용자가 접근 가능한 메뉴 목록 */ + List selectAccessibleMenusByUserId(@Param("userId") Long userId); + + /** 사용자별 (menuId → permCodes) 매핑 */ + List> 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); +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuResp.java b/ga-common/src/main/java/com/ga/common/menu/MenuResp.java new file mode 100644 index 0000000..c93771e --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuResp.java @@ -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 permissions = new ArrayList<>(); + + /** 자식 메뉴 */ + private List children = new ArrayList<>(); +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuService.java b/ga-common/src/main/java/com/ga/common/menu/MenuService.java new file mode 100644 index 0000000..b0e0159 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuService.java @@ -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 getMyMenus() { + Long userId = SecurityUtil.getCurrentUserId(); + if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED); + + List flat = mapper.selectAccessibleMenusByUserId(userId); + Map> permissions = loadPermissionsByMenuId(userId); + return MenuTreeBuilder.build(flat, permissions); + } + + /** 전체 메뉴 트리 (관리자) */ + public List 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> loadPermissionsByMenuId(Long userId) { + Map> result = new HashMap<>(); + for (Map 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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java b/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java new file mode 100644 index 0000000..a9fb8d9 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java @@ -0,0 +1,54 @@ +package com.ga.common.menu; + +import java.util.*; + +/** + * 평면 메뉴 목록 → 트리 구조 변환. + */ +public final class MenuTreeBuilder { + + private MenuTreeBuilder() {} + + public static List build(List flat, Map> permissionsByMenuId) { + Map 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 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 nodes) { + nodes.sort(Comparator.comparing(MenuResp::getSortOrder, Comparator.nullsLast(Integer::compareTo))); + for (MenuResp n : nodes) sortRecursive(n.getChildren()); + } +} diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuVO.java b/ga-common/src/main/java/com/ga/common/menu/MenuVO.java new file mode 100644 index 0000000..27aabd6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/MenuVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java b/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java new file mode 100644 index 0000000..e04ac57 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java @@ -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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/model/ApiResponse.java b/ga-common/src/main/java/com/ga/common/model/ApiResponse.java new file mode 100644 index 0000000..96f4c79 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/model/ApiResponse.java @@ -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 { + + 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 ApiResponse ok(T data) { + return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data); + } + + public static ApiResponse ok(T data, String message) { + return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), message, data); + } + + public static ApiResponse ok() { + return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null); + } + + public static ApiResponse fail(String code, String message) { + return new ApiResponse<>(false, code, message, null); + } + + public static ApiResponse fail(ErrorCode errorCode) { + return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null); + } + + public static ApiResponse fail(ErrorCode errorCode, String message) { + return new ApiResponse<>(false, errorCode.getCode(), message, null); + } +} diff --git a/ga-common/src/main/java/com/ga/common/model/PageResponse.java b/ga-common/src/main/java/com/ga/common/model/PageResponse.java new file mode 100644 index 0000000..be56f21 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/model/PageResponse.java @@ -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 { + + private List list; + private int pageNum; + private int pageSize; + private long total; + private int pages; + + public static PageResponse of(List list) { + if (list == null) { + return PageResponse.builder() + .list(Collections.emptyList()) + .pageNum(1).pageSize(0).total(0).pages(0).build(); + } + PageInfo info = new PageInfo<>(list); + return PageResponse.builder() + .list(info.getList()) + .pageNum(info.getPageNum()) + .pageSize(info.getPageSize()) + .total(info.getTotal()) + .pages(info.getPages()) + .build(); + } + + public static PageResponse empty() { + return PageResponse.builder() + .list(Collections.emptyList()) + .pageNum(1).pageSize(0).total(0).pages(0).build(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/model/SearchParam.java b/ga-common/src/main/java/com/ga/common/model/SearchParam.java new file mode 100644 index 0000000..fdbe77b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/model/SearchParam.java @@ -0,0 +1,45 @@ +package com.ga.common.model; + +import com.github.pagehelper.PageHelper; +import lombok.Data; + +/** + * 공통 검색 파라미터. 모든 *SearchParam 의 부모. + * + * 사용: + * public PageResponse 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_]", ""); + } +} diff --git a/ga-common/src/main/java/com/ga/common/model/TreeNode.java b/ga-common/src/main/java/com/ga/common/model/TreeNode.java new file mode 100644 index 0000000..7986731 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/model/TreeNode.java @@ -0,0 +1,22 @@ +package com.ga.common.model; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 트리 응답 공통 모델 (메뉴, 조직 등). + */ +@Data +public class TreeNode { + private Long id; + private Long parentId; + private String label; + private T data; + private List> children = new ArrayList<>(); + + public void addChild(TreeNode child) { + children.add(child); + } +} diff --git a/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java b/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java new file mode 100644 index 0000000..766ba77 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java @@ -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 등)는 무시 + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java b/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java new file mode 100644 index 0000000..ab9b34f --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java @@ -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 selectById(Long id); + List selectList(SearchParam param); + int insert(T entity); + int update(T entity); + int deleteById(Long id); + int countByCondition(SearchParam param); +} diff --git a/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java new file mode 100644 index 0000000..a6b7b00 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java @@ -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 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; + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java b/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java new file mode 100644 index 0000000..d96293e --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java @@ -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 { + + 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); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationController.java b/ga-common/src/main/java/com/ga/common/notification/NotificationController.java new file mode 100644 index 0000000..e8cf3de --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationController.java @@ -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() { + return ApiResponse.ok(service.myNotifications()); + } + + @GetMapping("/unread-count") + public ApiResponse unreadCount() { + return ApiResponse.ok(service.unreadCount()); + } + + @PutMapping("/{notiId}/read") + public ApiResponse markRead(@PathVariable Long notiId) { + service.markRead(notiId); + return ApiResponse.ok(); + } + + @PutMapping("/read-all") + public ApiResponse markAllRead() { + service.markAllRead(); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java b/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java new file mode 100644 index 0000000..eba0f42 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java @@ -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 list); + List 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); +} diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationService.java b/ga-common/src/main/java/com/ga/common/notification/NotificationService.java new file mode 100644 index 0000000..397e8cf --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationService.java @@ -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 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 list) { + if (list == null || list.isEmpty()) return; + mapper.insertBatch(list); + } +} diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java b/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java new file mode 100644 index 0000000..660cbd9 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java b/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..68d0668 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java @@ -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())); + } +} diff --git a/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java b/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java new file mode 100644 index 0000000..82fc0d6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java @@ -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())); + } +} diff --git a/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java b/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java new file mode 100644 index 0000000..4953ab3 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java @@ -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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java b/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java new file mode 100644 index 0000000..e9750ed --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java @@ -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()); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java b/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java new file mode 100644 index 0000000..06d033d --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java @@ -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 changed = new ArrayList<>(); + Iterator 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; + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java new file mode 100644 index 0000000..fe2c097 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java @@ -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(@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 update(@PathVariable String configKey, @RequestBody SystemConfigVO vo) { + vo.setConfigKey(configKey); + service.update(vo); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java new file mode 100644 index 0000000..f16283f --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java @@ -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 selectAll(); + List selectByGroup(@Param("configGroup") String configGroup); + SystemConfigVO selectByKey(@Param("configKey") String configKey); + int update(SystemConfigVO vo); +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java new file mode 100644 index 0000000..320812a --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java @@ -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 listAll() { + return mapper.selectAll(); + } + + public List listByGroup(String group) { + return mapper.selectByGroup(group); + } + + @Cacheable(value = "systemConfig", key = "#key") + public Optional 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); + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java new file mode 100644 index 0000000..43354e2 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java @@ -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; +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemLogController.java b/ga-common/src/main/java/com/ga/common/system/SystemLogController.java new file mode 100644 index 0000000..7618388 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemLogController.java @@ -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>> loginLogs(@ModelAttribute SearchParam param, + @RequestParam(required = false) Long userId) { + param.startPage(); + Map 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>> apiLogs(@ModelAttribute SearchParam param, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String url) { + PageHelper.startPage(param.getPageNum(), param.getPageSize()); + Map 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>> 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 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 toMap(SearchParam p) { + Map m = new HashMap<>(); + m.put("startDate", p.getStartDate()); + m.put("endDate", p.getEndDate()); + return m; + } +} diff --git a/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java b/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java new file mode 100644 index 0000000..7fed2a6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java @@ -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> selectLoginLogs(@Param("param") Map param); + List> selectApiAccessLogs(@Param("param") Map param); + List> selectDataChangeLogs(@Param("param") Map param); +} diff --git a/ga-common/src/main/java/com/ga/common/util/DateUtil.java b/ga-common/src/main/java/com/ga/common/util/DateUtil.java new file mode 100644 index 0000000..6185aa6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/util/DateUtil.java @@ -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); + } +} diff --git a/ga-common/src/main/java/com/ga/common/util/EncryptUtil.java b/ga-common/src/main/java/com/ga/common/util/EncryptUtil.java new file mode 100644 index 0000000..631ef79 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/util/EncryptUtil.java @@ -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, "복호화 실패"); + } + } +} diff --git a/ga-common/src/main/java/com/ga/common/util/MaskUtil.java b/ga-common/src/main/java/com/ga/common/util/MaskUtil.java new file mode 100644 index 0000000..74dce1a --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/util/MaskUtil.java @@ -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; + } +} diff --git a/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java b/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java new file mode 100644 index 0000000..2bec56b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java @@ -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; } +} diff --git a/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java b/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java new file mode 100644 index 0000000..e69c783 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java @@ -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 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); + } +} diff --git a/ga-common/src/main/resources/db/migration/V10__메뉴관리.sql b/ga-common/src/main/resources/db/migration/V10__메뉴관리.sql new file mode 100644 index 0000000..0b52e55 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V10__메뉴관리.sql @@ -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) +); diff --git a/ga-common/src/main/resources/db/migration/V11__시스템관리.sql b/ga-common/src/main/resources/db/migration/V11__시스템관리.sql new file mode 100644 index 0000000..d32d297 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V11__시스템관리.sql @@ -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 +); diff --git a/ga-common/src/main/resources/db/migration/V12__공통_초기데이터.sql b/ga-common/src/main/resources/db/migration/V12__공통_초기데이터.sql new file mode 100644 index 0000000..9ecbce0 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V12__공통_초기데이터.sql @@ -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', '배치 타임아웃(초)'); diff --git a/ga-common/src/main/resources/db/migration/V1__조직_인사.sql b/ga-common/src/main/resources/db/migration/V1__조직_인사.sql new file mode 100644 index 0000000..86a37ed --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V1__조직_인사.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V2__상품_계약.sql b/ga-common/src/main/resources/db/migration/V2__상품_계약.sql new file mode 100644 index 0000000..1957777 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V2__상품_계약.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V3__수수료규정.sql b/ga-common/src/main/resources/db/migration/V3__수수료규정.sql new file mode 100644 index 0000000..22c5a6b --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V3__수수료규정.sql @@ -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' +); diff --git a/ga-common/src/main/resources/db/migration/V4__데이터수신.sql b/ga-common/src/main/resources/db/migration/V4__데이터수신.sql new file mode 100644 index 0000000..155389a --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V4__데이터수신.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V5__원장관리.sql b/ga-common/src/main/resources/db/migration/V5__원장관리.sql new file mode 100644 index 0000000..a4fa104 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V5__원장관리.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V6__정산_지급.sql b/ga-common/src/main/resources/db/migration/V6__정산_지급.sql new file mode 100644 index 0000000..959e040 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V6__정산_지급.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V7__배치_감사.sql b/ga-common/src/main/resources/db/migration/V7__배치_감사.sql new file mode 100644 index 0000000..5424a62 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V7__배치_감사.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V8__초기데이터.sql b/ga-common/src/main/resources/db/migration/V8__초기데이터.sql new file mode 100644 index 0000000..79c5d81 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V8__초기데이터.sql @@ -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','기타 항목'); diff --git a/ga-common/src/main/resources/db/migration/V9__공통코드.sql b/ga-common/src/main/resources/db/migration/V9__공통코드.sql new file mode 100644 index 0000000..abe3e50 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V9__공통코드.sql @@ -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); diff --git a/ga-common/src/main/resources/mapper/common/CommonCodeMapper.xml b/ga-common/src/main/resources/mapper/common/CommonCodeMapper.xml new file mode 100644 index 0000000..122865b --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/CommonCodeMapper.xml @@ -0,0 +1,124 @@ + + + + + + + + + group_code, group_name, description, is_system, is_active, sort_order, + created_at, created_by, updated_at, updated_by + + + + 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 + + + + + + + + + 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} + ) + + + + 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} + + + + DELETE FROM common_code_group WHERE group_code = #{groupCode} AND is_system = 'N' + + + + + + + + + + + + + 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} + ) + + + + 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} + + + + DELETE FROM common_code WHERE code_id = #{codeId} + + + + + diff --git a/ga-common/src/main/resources/mapper/common/FileMapper.xml b/ga-common/src/main/resources/mapper/common/FileMapper.xml new file mode 100644 index 0000000..4b3f690 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/FileMapper.xml @@ -0,0 +1,35 @@ + + + + + + + + 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} + ) + + + + + + + + UPDATE file_storage SET is_deleted = 'Y' WHERE file_id = #{fileId} + + + + UPDATE file_storage SET download_count = download_count + 1 WHERE file_id = #{fileId} + + + diff --git a/ga-common/src/main/resources/mapper/common/MenuMapper.xml b/ga-common/src/main/resources/mapper/common/MenuMapper.xml new file mode 100644 index 0000000..1881e6b --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/MenuMapper.xml @@ -0,0 +1,99 @@ + + + + + + + + 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 + + + + + + + + + + + + + + + + + + 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} + ) + + + + 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} + + + + DELETE FROM menu WHERE menu_id = #{menuId} + + + + + diff --git a/ga-common/src/main/resources/mapper/common/NotificationMapper.xml b/ga-common/src/main/resources/mapper/common/NotificationMapper.xml new file mode 100644 index 0000000..a9bc914 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/NotificationMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + INSERT INTO notification (user_id, noti_type, title, content, link_url, is_read, created_at) + VALUES (#{userId}, #{notiType}, #{title}, #{content}, #{linkUrl}, 'N', NOW()) + + + + INSERT INTO notification (user_id, noti_type, title, content, link_url, is_read, created_at) + VALUES + + (#{item.userId}, #{item.notiType}, #{item.title}, #{item.content}, #{item.linkUrl}, 'N', NOW()) + + + + + + + + + UPDATE notification SET is_read = 'Y', read_at = NOW() + WHERE noti_id = #{notiId} AND user_id = #{userId} + + + + UPDATE notification SET is_read = 'Y', read_at = NOW() + WHERE user_id = #{userId} AND is_read = 'N' + + + + DELETE FROM notification WHERE created_at < NOW() - (#{days} || ' days')::interval + + + diff --git a/ga-common/src/main/resources/mapper/common/SystemConfigMapper.xml b/ga-common/src/main/resources/mapper/common/SystemConfigMapper.xml new file mode 100644 index 0000000..fabd1d5 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/SystemConfigMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + UPDATE system_config SET + config_value = #{configValue}, + updated_at = #{updatedAt}, + updated_by = #{updatedBy} + WHERE config_key = #{configKey} AND is_editable = 'Y' + + + diff --git a/ga-common/src/main/resources/mapper/common/SystemLogMapper.xml b/ga-common/src/main/resources/mapper/common/SystemLogMapper.xml new file mode 100644 index 0000000..f5ca2c3 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/SystemLogMapper.xml @@ -0,0 +1,93 @@ + + + + + + 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 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 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()) + + + + + + + + + diff --git a/ga-common/src/main/resources/mybatis/mybatis-config.xml b/ga-common/src/main/resources/mybatis/mybatis-config.xml new file mode 100644 index 0000000..792b043 --- /dev/null +++ b/ga-common/src/main/resources/mybatis/mybatis-config.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/ga-core/build.gradle b/ga-core/build.gradle new file mode 100644 index 0000000..4963018 --- /dev/null +++ b/ga-core/build.gradle @@ -0,0 +1,7 @@ +// ga-core: 도메인 코어 (VO/Mapper/SQL) +bootJar.enabled = false +jar.enabled = true + +dependencies { + api project(':ga-common') +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8c838a5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..3e079c9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'ga-commission-system' + +include 'ga-common' +include 'ga-core' +include 'ga-api' +include 'ga-batch' +include 'ga-admin'