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

- Gradle multi-module: ga-common, ga-core, ga-api, ga-batch, ga-admin
- Flyway V1-V12: org/product/rule/receive/ledger/settle/batch/code/menu/system + seed
- ga-common (complete):
  - model: ApiResponse, PageResponse, SearchParam, TreeNode
  - exception: ErrorCode, BizException, GlobalExceptionHandler
  - annotation + aop: @RequirePermission, @DataChangeLog, ApiLog
  - auth + security: JWT, SecurityConfig
  - mybatis: BaseMapper, EncryptTypeHandler, JsonTypeHandler, AuditInterceptor
  - code (Redis cached) / menu (RBAC tree) / file / system / notification
  - excel: SXSSF+Cursor export, SAX+batch import (1M/700K rows)
  - util: Date/Money/Mask/Encrypt/Security
This commit is contained in:
GA Pro
2026-05-09 21:29:18 +09:00
parent c716f5eb70
commit cef4e48e27
100 changed files with 4914 additions and 0 deletions
@@ -0,0 +1,25 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 데이터 변경 이력 자동 기록.
*
* <pre>
* @DataChangeLog(menu = "RULE", table = "payout_rule")
* public void updatePayoutRule(PayoutRuleVO vo) { ... }
* </pre>
*
* 동작: 메서드 실행 전·후의 대상 데이터를 비교 → data_change_log JSONB 컬럼에 저장.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataChangeLog {
String menu();
String table();
/** 액션 유형. 자동 추정 안 될 때 명시 */
String action() default "";
}
@@ -0,0 +1,22 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 엑셀 컬럼 매핑 어노테이션. ExcelExporter / ExcelImporter 가 사용.
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelColumn {
String header();
int order();
int width() default 15;
String format() default "";
String codeGroup() default "";
boolean masked() default false;
String defaultValue() default "";
boolean required() default false;
}
@@ -0,0 +1,17 @@
package com.ga.common.annotation;
import com.ga.common.util.MaskUtil.MaskType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 응답 시 자동 마스킹 어노테이션 (응답 직렬화 시 적용).
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Mask {
MaskType value() default MaskType.NAME;
}
@@ -0,0 +1,24 @@
package com.ga.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 메뉴+권한 단위 권한 검증.
*
* <pre>
* @RequirePermission(menu = "ORG_AGENT", perm = "READ")
* public ApiResponse&lt;...&gt; list(...) { ... }
* </pre>
*
* 동작: 현재 사용자의 역할 → role_menu_permission 에 (menu, perm) 권한이 있는지 확인.
* 없으면 BizException(ErrorCode.FORBIDDEN).
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
String menu();
String perm() default "READ";
}
@@ -0,0 +1,78 @@
package com.ga.common.aop;
import com.ga.common.system.ApiAccessLogService;
import com.ga.common.util.SecurityUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 모든 @RestController 의 요청을 비동기 로깅한다.
* - URL, Method, 응답시간, 사용자
* - 민감 파라미터(비밀번호 등)는 마스킹
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ApiLogAspect {
private final ApiAccessLogService apiAccessLogService;
@Around("within(@org.springframework.web.bind.annotation.RestController *)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
long start = System.currentTimeMillis();
Throwable error = null;
Object result = null;
try {
result = jp.proceed();
return result;
} catch (Throwable e) {
error = e;
throw e;
} finally {
try {
int elapsed = (int) (System.currentTimeMillis() - start);
HttpServletRequest req = currentRequest();
if (req != null) {
apiAccessLogService.saveAsync(
SecurityUtil.getCurrentUserId(),
req.getMethod(),
req.getRequestURI(),
queryString(req),
error == null ? "200" : "500",
elapsed,
ipOf(req),
error == null ? null : error.getMessage()
);
}
} catch (Exception ex) {
log.debug("ApiLog save failed: {}", ex.getMessage());
}
}
}
private HttpServletRequest currentRequest() {
var attr = RequestContextHolder.getRequestAttributes();
return attr instanceof ServletRequestAttributes sra ? sra.getRequest() : null;
}
private String queryString(HttpServletRequest req) {
String qs = req.getQueryString();
if (qs == null) return null;
// 민감 파라미터 마스킹
return qs.replaceAll("(password|pwd|token)=[^&]*", "$1=***");
}
private String ipOf(HttpServletRequest req) {
String xf = req.getHeader("X-Forwarded-For");
if (xf != null && !xf.isBlank()) return xf.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -0,0 +1,92 @@
package com.ga.common.aop;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.system.DataChangeLogService;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @DataChangeLog 처리.
*
* 정책:
* - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 직렬화 → before
* - 정상 종료 후 동일 인자를 직렬화 → after
* - DataChangeLogService 가 before/after 비교 후 changed_keys 산출하여 INSERT
*
* 단순화를 위해 본 구현은 메서드 인자 전체를 JSON 직렬화 후 저장한다.
* 실제 환경에서는 menu/table 별 조회 콜백을 등록하여 정확한 before-state 를 가져오는 것을 권장.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DataChangeLogAspect {
private final DataChangeLogService dataChangeLogService;
private final ObjectMapper objectMapper;
@Around("@annotation(com.ga.common.annotation.DataChangeLog)")
public Object log(ProceedingJoinPoint jp) throws Throwable {
MethodSignature sig = (MethodSignature) jp.getSignature();
DataChangeLog ann = sig.getMethod().getAnnotation(DataChangeLog.class);
String beforeJson = "{}";
String afterJson;
Object[] args = jp.getArgs();
try {
beforeJson = args.length > 0 ? toJson(args[0]) : "{}";
} catch (Exception ignore) { }
Object result = jp.proceed();
try {
afterJson = args.length > 0 ? toJson(args[0]) : toJson(result);
dataChangeLogService.save(
SecurityUtil.getCurrentUserId(),
ann.menu(),
ann.table(),
extractRecordId(args),
inferAction(ann, sig.getMethod().getName()),
beforeJson,
afterJson
);
} catch (Exception e) {
log.warn("DataChangeLog save failed: {}", e.getMessage());
}
return result;
}
private String toJson(Object o) {
if (o == null) return "{}";
try {
return objectMapper.writeValueAsString(o);
} catch (Exception e) {
return "{}";
}
}
private String extractRecordId(Object[] args) {
for (Object arg : args) {
if (arg instanceof Long l) return String.valueOf(l);
if (arg instanceof Integer i) return String.valueOf(i);
if (arg instanceof String s) return s;
}
return null;
}
private String inferAction(DataChangeLog ann, String methodName) {
if (!ann.action().isBlank()) return ann.action();
String n = methodName.toLowerCase();
if (n.startsWith("create") || n.startsWith("insert") || n.startsWith("add")) return "INSERT";
if (n.startsWith("delete") || n.startsWith("remove")) return "DELETE";
return "UPDATE";
}
}
@@ -0,0 +1,40 @@
package com.ga.common.aop;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.menu.PermissionChecker;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
/**
* @RequirePermission 처리. 현재 사용자의 (menu, perm) 권한을 검증한다.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class PermissionAspect {
private final PermissionChecker permissionChecker;
@Before("@annotation(com.ga.common.annotation.RequirePermission)")
public void check(org.aspectj.lang.JoinPoint jp) {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
MethodSignature sig = (MethodSignature) jp.getSignature();
RequirePermission ann = sig.getMethod().getAnnotation(RequirePermission.class);
if (ann == null) return;
if (!permissionChecker.hasPermission(userId, ann.menu(), ann.perm())) {
log.warn("Permission denied: userId={}, menu={}, perm={}", userId, ann.menu(), ann.perm());
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -0,0 +1,78 @@
package com.ga.common.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* Authorization: Bearer xxx 헤더에서 JWT 추출 → SecurityContext 설정.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private final JwtTokenProvider tokenProvider;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HEADER);
if (header != null && header.startsWith(PREFIX)) {
String token = header.substring(PREFIX.length());
try {
Claims claims = tokenProvider.parse(token);
Long userId = claims.get("uid", Long.class);
String loginId = claims.getSubject();
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of());
LoginUser user = LoginUser.builder()
.userId(userId)
.loginId(loginId)
.roleCodes(roles)
.build();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (BizException e) {
writeError(response, e.getErrorCode());
return;
} catch (Exception e) {
log.warn("JWT processing error: {}", e.getMessage());
writeError(response, ErrorCode.TOKEN_INVALID);
return;
}
}
chain.doFilter(request, response);
}
private void writeError(HttpServletResponse response, ErrorCode ec) throws IOException {
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,93 @@
package com.ga.common.auth;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* JWT 발급/검증.
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long accessExpire;
private final long refreshExpire;
public JwtTokenProvider(
@Value("${ga.jwt.secret}") String secret,
@Value("${ga.jwt.access-token-expire:7200000}") long accessExpire,
@Value("${ga.jwt.refresh-token-expire:604800000}") long refreshExpire
) {
byte[] raw = secret.getBytes(StandardCharsets.UTF_8);
// 256비트 미만이면 안전하게 패딩
if (raw.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(raw, 0, padded, 0, raw.length);
raw = padded;
}
this.key = Keys.hmacShaKeyFor(raw);
this.accessExpire = accessExpire;
this.refreshExpire = refreshExpire;
}
public String createAccessToken(Long userId, String loginId, List<String> roles) {
return create(userId, loginId, roles, accessExpire, "ACCESS");
}
public String createRefreshToken(Long userId, String loginId) {
return create(userId, loginId, List.of(), refreshExpire, "REFRESH");
}
private String create(Long userId, String loginId, List<String> roles, long expireMs, String type) {
Date now = new Date();
return Jwts.builder()
.subject(loginId)
.claims(Map.of(
"uid", userId,
"roles", roles,
"type", type
))
.issuedAt(now)
.expiration(new Date(now.getTime() + expireMs))
.signWith(key)
.compact();
}
public Claims parse(String token) {
try {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new BizException(ErrorCode.TOKEN_EXPIRED);
} catch (JwtException | IllegalArgumentException e) {
throw new BizException(ErrorCode.TOKEN_INVALID);
}
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (Exception e) {
return false;
}
}
public long getAccessExpire() { return accessExpire; }
public long getRefreshExpire() { return refreshExpire; }
}
@@ -0,0 +1,36 @@
package com.ga.common.auth;
import lombok.Builder;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
/**
* SecurityContext 에 들어갈 인증 주체.
*/
@Getter
@Builder
public class LoginUser implements UserDetails {
private final Long userId;
private final String loginId;
private final String userName;
private final Long agentId;
private final List<String> roleCodes;
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return roleCodes == null ? List.of() :
roleCodes.stream().<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
}
@Override public String getPassword() { return null; }
@Override public String getUsername() { return loginId; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
}
@@ -0,0 +1,91 @@
package com.ga.common.code;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "공통코드")
@RestController
@RequestMapping("/api/common/codes")
@RequiredArgsConstructor
public class CommonCodeController {
private final CommonCodeService service;
/** 활성 코드 목록 조회 (드롭다운 용) — 인증된 사용자라면 모두 호출 가능 */
@GetMapping("/{groupCode}")
public ApiResponse<List<CommonCodeVO>> getCodes(@PathVariable String groupCode) {
return ApiResponse.ok(service.getCodesByGroup(groupCode));
}
// ----- 시스템관리 화면 -----
@GetMapping("/groups")
@RequirePermission(menu = "SYSTEM_CODE", perm = "READ")
public ApiResponse<List<CommonCodeGroupVO>> listGroups(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.listGroups(keyword));
}
@PostMapping("/groups")
@RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE")
public ApiResponse<Void> createGroup(@RequestBody CommonCodeGroupVO vo) {
service.createGroup(vo);
return ApiResponse.ok();
}
@PutMapping("/groups/{groupCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> updateGroup(@PathVariable String groupCode, @RequestBody CommonCodeGroupVO vo) {
vo.setGroupCode(groupCode);
service.updateGroup(vo);
return ApiResponse.ok();
}
@DeleteMapping("/groups/{groupCode}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE")
public ApiResponse<Void> deleteGroup(@PathVariable String groupCode) {
service.deleteGroup(groupCode);
return ApiResponse.ok();
}
@GetMapping("/groups/{groupCode}/codes")
@RequirePermission(menu = "SYSTEM_CODE", perm = "READ")
public ApiResponse<List<CommonCodeVO>> listCodes(@PathVariable String groupCode) {
return ApiResponse.ok(service.listCodes(groupCode));
}
@PostMapping("/groups/{groupCode}/codes")
@RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE")
public ApiResponse<Void> createCode(@PathVariable String groupCode, @RequestBody CommonCodeVO vo) {
vo.setGroupCode(groupCode);
service.createCode(vo);
return ApiResponse.ok();
}
@PutMapping("/codes/{codeId}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> updateCode(@PathVariable Long codeId, @RequestBody CommonCodeVO vo) {
vo.setCodeId(codeId);
service.updateCode(vo);
return ApiResponse.ok();
}
@DeleteMapping("/codes/{codeId}")
@RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE")
public ApiResponse<Void> deleteCode(@PathVariable Long codeId) {
service.deleteCode(codeId);
return ApiResponse.ok();
}
@PostMapping("/refresh")
@RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE")
public ApiResponse<Void> refresh(@RequestParam(required = false) String groupCode) {
if (groupCode == null || groupCode.isBlank()) service.refreshAll();
else service.refreshCache(groupCode);
return ApiResponse.ok();
}
}
@@ -0,0 +1,19 @@
package com.ga.common.code;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CommonCodeGroupVO {
private String groupCode;
private String groupName;
private String description;
private String isSystem;
private String isActive;
private Integer sortOrder;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,27 @@
package com.ga.common.code;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommonCodeMapper {
// 그룹
List<CommonCodeGroupVO> selectGroupList(@Param("keyword") String keyword);
CommonCodeGroupVO selectGroup(@Param("groupCode") String groupCode);
int insertGroup(CommonCodeGroupVO vo);
int updateGroup(CommonCodeGroupVO vo);
int deleteGroup(@Param("groupCode") String groupCode);
// 상세 코드
List<CommonCodeVO> selectCodes(@Param("groupCode") String groupCode);
List<CommonCodeVO> selectActiveCodes(@Param("groupCode") String groupCode);
CommonCodeVO selectCode(@Param("codeId") Long codeId);
CommonCodeVO selectByGroupAndCode(@Param("groupCode") String groupCode, @Param("code") String code);
int insertCode(CommonCodeVO vo);
int updateCode(CommonCodeVO vo);
int deleteCode(@Param("codeId") Long codeId);
int existsByGroupAndCode(@Param("groupCode") String groupCode, @Param("code") String code);
}
@@ -0,0 +1,148 @@
package com.ga.common.code;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* 공통코드 조회/관리. Redis 캐시 적용.
*
* 캐시 키: commonCode::{groupCode}
* 무효화: refreshCache(groupCode) 또는 코드 변경 시 자동.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommonCodeService {
private final CommonCodeMapper mapper;
/** 활성 코드 목록 (Redis 캐시) */
@Cacheable(value = "commonCode", key = "#groupCode")
public List<CommonCodeVO> getCodesByGroup(String groupCode) {
return mapper.selectActiveCodes(groupCode);
}
/** 코드 → 코드명 (없으면 null) */
public String getCodeName(String groupCode, String code) {
if (code == null) return null;
return getCodesByGroup(groupCode).stream()
.filter(c -> code.equals(c.getCode()))
.map(CommonCodeVO::getCodeName)
.findFirst().orElse(null);
}
/** 유효성 검증. 미존재 시 예외. */
public void validateCode(String groupCode, String code) {
boolean exists = getCodesByGroup(groupCode).stream()
.anyMatch(c -> c.getCode().equals(code));
if (!exists) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"[" + groupCode + "] 그룹의 [" + code + "] 코드가 없습니다");
}
}
@CacheEvict(value = "commonCode", key = "#groupCode")
public void refreshCache(String groupCode) {
log.info("Common code cache evicted: {}", groupCode);
}
@CacheEvict(value = "commonCode", allEntries = true)
public void refreshAll() {
log.info("Common code cache evicted: ALL");
}
// ========== 그룹 관리 ==========
public List<CommonCodeGroupVO> listGroups(String keyword) {
return mapper.selectGroupList(keyword);
}
public CommonCodeGroupVO getGroup(String groupCode) {
CommonCodeGroupVO g = mapper.selectGroup(groupCode);
if (g == null) throw new BizException(ErrorCode.NOT_FOUND);
return g;
}
@Transactional
public void createGroup(CommonCodeGroupVO vo) {
if (mapper.selectGroup(vo.getGroupCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insertGroup(vo);
}
@Transactional
public void updateGroup(CommonCodeGroupVO vo) {
CommonCodeGroupVO g = getGroup(vo.getGroupCode());
if ("Y".equals(g.getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.updateGroup(vo);
refreshCache(vo.getGroupCode());
}
@Transactional
@CacheEvict(value = "commonCode", key = "#groupCode")
public void deleteGroup(String groupCode) {
CommonCodeGroupVO g = getGroup(groupCode);
if ("Y".equals(g.getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.deleteGroup(groupCode);
}
// ========== 상세 코드 관리 ==========
public List<CommonCodeVO> listCodes(String groupCode) {
return mapper.selectCodes(groupCode);
}
public CommonCodeVO getCode(Long codeId) {
CommonCodeVO c = mapper.selectCode(codeId);
if (c == null) throw new BizException(ErrorCode.NOT_FOUND);
return c;
}
@Transactional
public void createCode(CommonCodeVO vo) {
if (mapper.existsByGroupAndCode(vo.getGroupCode(), vo.getCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insertCode(vo);
refreshCache(vo.getGroupCode());
}
@Transactional
public void updateCode(CommonCodeVO vo) {
CommonCodeVO old = getCode(vo.getCodeId());
// 시스템 그룹은 코드 수정 제한
Optional<CommonCodeGroupVO> g = Optional.ofNullable(mapper.selectGroup(old.getGroupCode()));
if (g.isPresent() && "Y".equals(g.get().getIsSystem())) {
// 코드 식별자(code) 자체는 변경 불가, 코드명/설명만 허용
vo.setCode(old.getCode());
vo.setGroupCode(old.getGroupCode());
}
mapper.updateCode(vo);
refreshCache(old.getGroupCode());
}
@Transactional
public void deleteCode(Long codeId) {
CommonCodeVO c = getCode(codeId);
Optional<CommonCodeGroupVO> g = Optional.ofNullable(mapper.selectGroup(c.getGroupCode()));
if (g.isPresent() && "Y".equals(g.get().getIsSystem())) {
throw new BizException(ErrorCode.SYSTEM_CODE_LOCKED);
}
mapper.deleteCode(codeId);
refreshCache(c.getGroupCode());
}
}
@@ -0,0 +1,25 @@
package com.ga.common.code;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CommonCodeVO {
private Long codeId;
private String groupCode;
private String code;
private String codeName;
private String codeNameEn;
private String codeDesc;
private String attr1;
private String attr2;
private String attr3;
private String refCode;
private Integer sortOrder;
private String isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,24 @@
package com.ga.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("ga-async-");
ex.initialize();
return ex;
}
}
@@ -0,0 +1,16 @@
package com.ga.common.config;
import com.ga.common.mybatis.AuditInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackages = {"com.ga.common.mapper", "com.ga.core.mapper"})
public class MyBatisConfig {
@Bean
public AuditInterceptor auditInterceptor() {
return new AuditInterceptor();
}
}
@@ -0,0 +1,44 @@
package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisTemplate<String, Object> tpl = new RedisTemplate<>();
tpl.setConnectionFactory(cf);
tpl.setKeySerializer(new StringRedisSerializer());
tpl.setHashKeySerializer(new StringRedisSerializer());
GenericJackson2JsonRedisSerializer json = new GenericJackson2JsonRedisSerializer(objectMapper);
tpl.setValueSerializer(json);
tpl.setHashValueSerializer(json);
return tpl;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf, ObjectMapper objectMapper) {
RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper)));
return RedisCacheManager.builder(cf).cacheDefaults(cfg).build();
}
}
@@ -0,0 +1,30 @@
package com.ga.common.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
private static final String SCHEME = "bearerAuth";
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("GA 수수료 정산 솔루션 API")
.version("1.0.0-alpha")
.description("법인보험대리점 수수료 정산 시스템 REST API"))
.components(new Components().addSecuritySchemes(SCHEME,
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList(SCHEME));
}
}
@@ -0,0 +1,17 @@
package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebMvcConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.findAndRegisterModules();
}
}
@@ -0,0 +1,36 @@
package com.ga.common.excel;
import com.ga.common.annotation.ExcelColumn;
import lombok.Builder;
import lombok.Getter;
import java.lang.reflect.Field;
@Getter
@Builder
public class ExcelColumnInfo {
private final Field field;
private final String header;
private final int order;
private final int width;
private final String format;
private final String codeGroup;
private final boolean masked;
private final String defaultValue;
private final boolean required;
public static ExcelColumnInfo of(Field field, ExcelColumn ann) {
field.setAccessible(true);
return ExcelColumnInfo.builder()
.field(field)
.header(ann.header())
.order(ann.order())
.width(ann.width())
.format(ann.format())
.codeGroup(ann.codeGroup())
.masked(ann.masked())
.defaultValue(ann.defaultValue())
.required(ann.required())
.build();
}
}
@@ -0,0 +1,26 @@
package com.ga.common.excel;
import com.ga.common.annotation.ExcelColumn;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* VO 클래스 → @ExcelColumn 메타정보 추출.
*/
public final class ExcelColumnParser {
private ExcelColumnParser() {}
public static List<ExcelColumnInfo> parse(Class<?> clazz) {
List<ExcelColumnInfo> list = new ArrayList<>();
for (Field f : clazz.getDeclaredFields()) {
ExcelColumn ann = f.getAnnotation(ExcelColumn.class);
if (ann != null) list.add(ExcelColumnInfo.of(f, ann));
}
list.sort(Comparator.comparingInt(ExcelColumnInfo::getOrder));
return list;
}
}
@@ -0,0 +1,20 @@
package com.ga.common.excel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelErrorRow {
private int rowNumber;
private String fieldName;
private String value;
private String errorMessage;
public ExcelErrorRow(int rowNumber, String errorMessage) {
this.rowNumber = rowNumber;
this.errorMessage = errorMessage;
}
}
@@ -0,0 +1,23 @@
package com.ga.common.excel;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class ExcelImportResult {
private int totalCount;
private int successCount;
private int errorCount;
private int skipCount;
private String errorFileId;
private List<ExcelErrorRow> errors = new ArrayList<>();
public void addSuccess(int n) { successCount += n; totalCount += n; }
public void addError(ExcelErrorRow row) {
errors.add(row);
errorCount++;
totalCount++;
}
}
@@ -0,0 +1,257 @@
package com.ga.common.excel;
import com.ga.common.code.CommonCodeService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.MaskUtil;
import com.monitorjbl.xlsx.StreamingReader;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* 대용량 엑셀 처리.
* - exportLargeExcel: SXSSFWorkbook + MyBatis Cursor로 100만건 다운로드
* - importLargeExcel: xlsx-streamer(SAX) + 배치 INSERT로 70만건 업로드
* - exportTemplate: 업로드 양식 (헤더 + 코드그룹 드롭다운)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExcelService {
private final CommonCodeService codeService;
/* =========================================================
다운로드 (Export) — SXSSF + Cursor 스트리밍
========================================================= */
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
Supplier<Cursor<T>> cursorSupplier,
HttpServletResponse response) {
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
wb.setCompressTempFiles(true);
SXSSFSheet sheet = wb.createSheet("Sheet1");
// 1. 헤더
createHeaderRow(wb, sheet, columns);
// 2. 데이터
int rowNum = 1;
try (Cursor<T> cursor = cursorSupplier.get()) {
for (T item : cursor) {
Row row = sheet.createRow(rowNum++);
writeRow(wb, row, item, columns);
if (rowNum % 100_000 == 0) {
log.info("Excel export progress: {} rows", rowNum);
}
}
}
// 3. 응답
setExcelResponseHeader(response, fileName);
wb.write(response.getOutputStream());
wb.dispose();
} catch (Exception e) {
log.error("Excel export error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "엑셀 다운로드 실패");
}
}
/** 빈 템플릿(헤더만) 다운로드 */
public <T> void exportTemplate(String fileName, Class<T> clazz, HttpServletResponse response) {
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
SXSSFSheet sheet = wb.createSheet("Sheet1");
createHeaderRow(wb, sheet, columns);
setExcelResponseHeader(response, fileName);
wb.write(response.getOutputStream());
wb.dispose();
} catch (Exception e) {
log.error("Excel template error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "템플릿 다운로드 실패");
}
}
/* =========================================================
업로드 (Import) — SAX 스트리밍 + 배치 INSERT
========================================================= */
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
int batchSize, Consumer<List<T>> batchConsumer) {
ExcelImportResult result = new ExcelImportResult();
List<ExcelColumnInfo> columns = ExcelColumnParser.parse(clazz);
java.util.List<T> batch = new java.util.ArrayList<>(batchSize);
try (InputStream is = file.getInputStream();
org.apache.poi.ss.usermodel.Workbook wb = StreamingReader.builder()
.rowCacheSize(100)
.bufferSize(4096)
.open(is)) {
Sheet sheet = wb.getSheetAt(0);
int rowNum = 0;
for (Row row : sheet) {
rowNum++;
if (rowNum == 1) continue; // 헤더 스킵
try {
T item = parseRow(row, clazz, columns);
batch.add(item);
if (batch.size() >= batchSize) {
batchConsumer.accept(batch);
result.setSuccessCount(result.getSuccessCount() + batch.size());
result.setTotalCount(result.getTotalCount() + batch.size());
batch.clear();
if (rowNum % 100_000 == 0) {
log.info("Excel import progress: {} rows", rowNum);
}
}
} catch (Exception e) {
result.addError(new ExcelErrorRow(rowNum, null, null, e.getMessage()));
}
}
if (!batch.isEmpty()) {
batchConsumer.accept(batch);
result.setSuccessCount(result.getSuccessCount() + batch.size());
result.setTotalCount(result.getTotalCount() + batch.size());
}
} catch (Exception e) {
log.error("Excel import error", e);
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
}
return result;
}
/* =========================================================
내부 헬퍼
========================================================= */
private void createHeaderRow(SXSSFWorkbook wb, Sheet sheet, List<ExcelColumnInfo> columns) {
CellStyle headerStyle = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(true);
headerStyle.setFont(font);
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setBorderBottom(BorderStyle.THIN);
Row header = sheet.createRow(0);
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Cell c = header.createCell(i);
c.setCellValue(info.getHeader());
c.setCellStyle(headerStyle);
sheet.setColumnWidth(i, info.getWidth() * 256);
}
}
private <T> void writeRow(SXSSFWorkbook wb, Row row, T item, List<ExcelColumnInfo> columns) throws Exception {
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Object value = info.getField().get(item);
Cell cell = row.createCell(i);
writeCell(cell, value, info);
}
}
private void writeCell(Cell cell, Object value, ExcelColumnInfo info) {
if (value == null) { cell.setBlank(); return; }
// 코드그룹 변환 (코드 → 코드명)
if (!info.getCodeGroup().isBlank() && value instanceof String s) {
String name = codeService.getCodeName(info.getCodeGroup(), s);
cell.setCellValue(name != null ? name : s);
return;
}
// 마스킹
if (info.isMasked() && value instanceof String s) {
cell.setCellValue(MaskUtil.mask(s, MaskUtil.MaskType.RRN));
return;
}
if (value instanceof Number n) cell.setCellValue(n.doubleValue());
else if (value instanceof BigDecimal bd) cell.setCellValue(bd.doubleValue());
else if (value instanceof LocalDate d) cell.setCellValue(d.format(DateTimeFormatter.ISO_LOCAL_DATE));
else if (value instanceof LocalDateTime dt) cell.setCellValue(dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
else if (value instanceof Boolean b) cell.setCellValue(b);
else cell.setCellValue(value.toString());
}
private <T> T parseRow(Row row, Class<T> clazz, List<ExcelColumnInfo> columns) throws Exception {
T item = clazz.getDeclaredConstructor().newInstance();
for (int i = 0; i < columns.size(); i++) {
ExcelColumnInfo info = columns.get(i);
Cell cell = row.getCell(i);
Object value = readCell(cell, info);
if (value != null) info.getField().set(item, value);
else if (!info.getDefaultValue().isBlank()) {
info.getField().set(item, convertString(info.getDefaultValue(), info.getField().getType()));
} else if (info.isRequired()) {
throw new IllegalArgumentException("[" + info.getHeader() + "] 은(는) 필수입니다");
}
}
return item;
}
private Object readCell(Cell cell, ExcelColumnInfo info) {
if (cell == null) return null;
Class<?> type = info.getField().getType();
try {
return switch (cell.getCellType()) {
case STRING -> convertString(cell.getStringCellValue(), type);
case NUMERIC -> {
if (DateUtil.isCellDateFormatted(cell) && type == LocalDate.class) {
yield cell.getDateCellValue().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
}
double d = cell.getNumericCellValue();
if (type == BigDecimal.class) yield BigDecimal.valueOf(d);
if (type == Integer.class || type == int.class) yield (int) d;
if (type == Long.class || type == long.class) yield (long) d;
if (type == String.class) yield d == (long) d ? String.valueOf((long) d) : String.valueOf(d);
yield d;
}
case BOOLEAN -> cell.getBooleanCellValue();
case FORMULA -> cell.getStringCellValue();
default -> null;
};
} catch (Exception e) {
return null;
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
private Object convertString(String s, Class<?> type) {
if (s == null) return null;
s = s.trim();
if (s.isEmpty()) return null;
if (type == String.class) return s;
if (type == BigDecimal.class) return new BigDecimal(s.replace(",", ""));
if (type == Integer.class || type == int.class) return Integer.valueOf(s.replace(",", ""));
if (type == Long.class || type == long.class) return Long.valueOf(s.replace(",", ""));
if (type == LocalDate.class) return LocalDate.parse(s);
if (type.isEnum()) return Enum.valueOf((Class<Enum>) type, s);
return s;
}
private void setExcelResponseHeader(HttpServletResponse response, String fileName) {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
String encoded = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
}
}
@@ -0,0 +1,35 @@
package com.ga.common.exception;
import lombok.Getter;
/**
* 업무 예외. 서비스에서 throw new BizException(ErrorCode.XXX) 형태로 사용.
*/
@Getter
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
private final String customMessage;
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.customMessage = null;
}
public BizException(ErrorCode errorCode, String customMessage) {
super(customMessage);
this.errorCode = errorCode;
this.customMessage = customMessage;
}
public BizException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.errorCode = errorCode;
this.customMessage = null;
}
public String getDisplayMessage() {
return customMessage != null ? customMessage : errorCode.getMessage();
}
}
@@ -0,0 +1,66 @@
package com.ga.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 시스템 표준 에러 코드.
* - 0xxx: 성공
* - Exxx: 공통 에러
* - Axxx: 인증/권한
* - Bxxx: 업무 에러
* - Cxxx: 외부 연동
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
SUCCESS ("0000", 200, "성공"),
// 공통
BAD_REQUEST ("E400", 400, "잘못된 요청입니다"),
UNAUTHORIZED ("E401", 401, "인증이 필요합니다"),
FORBIDDEN ("E403", 403, "권한이 없습니다"),
NOT_FOUND ("E404", 404, "데이터를 찾을 수 없습니다"),
METHOD_NOT_ALLOWED("E405", 405, "허용되지 않는 요청 방식입니다"),
INTERNAL_ERROR ("E500", 500, "서버 오류가 발생했습니다"),
VALIDATION_FAIL ("E410", 400, "입력값 검증에 실패했습니다"),
INVALID_PARAMETER("E411", 400, "파라미터가 올바르지 않습니다"),
// 인증
TOKEN_EXPIRED ("A001", 401, "토큰이 만료되었습니다"),
TOKEN_INVALID ("A002", 401, "유효하지 않은 토큰입니다"),
LOGIN_FAIL ("A003", 401, "아이디 또는 비밀번호가 올바르지 않습니다"),
ACCOUNT_LOCKED ("A004", 403, "계정이 잠겨있습니다"),
ACCOUNT_RETIRED ("A005", 403, "퇴사한 계정입니다"),
PASSWORD_EXPIRED("A006", 403, "비밀번호가 만료되었습니다"),
PASSWORD_POLICY ("A007", 400, "비밀번호 정책에 맞지 않습니다"),
// 업무
ALREADY_CONFIRMED ("B001", 400, "이미 확정된 정산입니다"),
RULE_VERSION_CONFLICT("B002", 409, "규정 버전이 변경되었습니다. 다시 시도해주세요"),
APPROVAL_REQUIRED ("B003", 400, "승인이 필요합니다"),
INVALID_STATUS ("B004", 400, "상태가 올바르지 않습니다"),
INVALID_PERIOD ("B005", 400, "기간이 올바르지 않습니다"),
NOT_PERMITTED ("B006", 403, "해당 작업이 허용되지 않습니다"),
DEPENDENT_DATA ("B007", 409, "참조 중인 데이터가 있습니다"),
SYSTEM_CODE_LOCKED ("B008", 400, "시스템 공통코드는 수정할 수 없습니다"),
DUPLICATE_DATA ("B009", 409, "중복된 데이터입니다"),
BATCH_RUNNING ("B010", 409, "배치가 실행 중입니다"),
INSUFFICIENT_BALANCE("B011", 400, "잔액이 부족합니다"),
// 파일/엑셀
FILE_NOT_FOUND ("F001", 404, "파일을 찾을 수 없습니다"),
FILE_UPLOAD_FAIL ("F002", 500, "파일 업로드에 실패했습니다"),
FILE_TYPE_NOT_ALLOWED("F003",400, "허용되지 않는 파일 형식입니다"),
FILE_TOO_LARGE ("F004", 400, "파일 크기가 제한을 초과했습니다"),
EXCEL_PARSE_FAIL ("F005", 400, "엑셀 파싱에 실패했습니다"),
// 외부 연동
EXTERNAL_API_FAIL ("C001", 502, "외부 시스템 호출에 실패했습니다");
private final String code;
private final int httpStatus;
private final String message;
}
@@ -0,0 +1,86 @@
package com.ga.common.exception;
import com.ga.common.model.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 전역 예외 처리. 모든 예외를 ApiResponse 표준 응답으로 변환.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBiz(BizException e, HttpServletRequest req) {
log.warn("BizException at {} : {} - {}", req.getRequestURI(), e.getErrorCode(), e.getDisplayMessage());
return build(e.getErrorCode(), e.getDisplayMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
fieldErrors.put(fe.getField(), fe.getDefaultMessage());
}
ErrorCode ec = ErrorCode.VALIDATION_FAIL;
return ResponseEntity.status(ec.getHttpStatus())
.body(ApiResponse.<Map<String, String>>fail(ec.getCode(), ec.getMessage()));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return build(ErrorCode.INVALID_PARAMETER,
"파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethod(HttpRequestMethodNotSupportedException e) {
return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage());
}
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<ApiResponse<Void>> handleDup(DuplicateKeyException e) {
log.warn("DuplicateKeyException: {}", e.getMessage());
return build(ErrorCode.DUPLICATE_DATA, ErrorCode.DUPLICATE_DATA.getMessage());
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleForbidden(AccessDeniedException e) {
return build(ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuth(AuthenticationException e) {
return build(ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegal(IllegalArgumentException e) {
return build(ErrorCode.BAD_REQUEST, e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAll(Exception e, HttpServletRequest req) {
log.error("Unhandled exception at {}", req.getRequestURI(), e);
return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getMessage());
}
private ResponseEntity<ApiResponse<Void>> build(ErrorCode ec, String msg) {
return ResponseEntity.status(ec.getHttpStatus())
.body(ApiResponse.fail(ec.getCode(), msg));
}
}
@@ -0,0 +1,62 @@
package com.ga.common.file;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@Tag(name = "파일")
@Slf4j
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileService service;
@PostMapping
public ApiResponse<FileVO> upload(@RequestParam MultipartFile file,
@RequestParam(required = false, defaultValue = "default") String fileGroup) {
return ApiResponse.ok(service.upload(file, fileGroup));
}
@GetMapping("/{fileId}")
public void download(@PathVariable Long fileId, HttpServletResponse response) {
FileVO vo = service.download(fileId);
Path path = Paths.get(vo.getFilePath());
if (!Files.exists(path)) throw new BizException(ErrorCode.FILE_NOT_FOUND);
try {
response.setContentType(vo.getContentType() != null ? vo.getContentType() : "application/octet-stream");
String encoded = URLEncoder.encode(vo.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
Files.copy(path, response.getOutputStream());
} catch (IOException e) {
log.error("File download error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "파일 다운로드 실패");
}
}
@GetMapping("/groups/{fileGroup}")
public ApiResponse<List<FileVO>> listByGroup(@PathVariable String fileGroup) {
return ApiResponse.ok(service.listByGroup(fileGroup));
}
@DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@PathVariable Long fileId) {
service.delete(fileId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,15 @@
package com.ga.common.file;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FileMapper {
int insert(FileVO vo);
FileVO selectById(@Param("fileId") Long fileId);
List<FileVO> selectByGroup(@Param("fileGroup") String fileGroup);
int markDeleted(@Param("fileId") Long fileId);
int incrementDownloadCount(@Param("fileId") Long fileId);
}
@@ -0,0 +1,91 @@
package com.ga.common.file;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* 파일 업로드/다운로드. 로컬 파일시스템 저장 (S3 등으로 교체 가능한 구조).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
private final FileMapper mapper;
@Value("${ga.file.upload-dir:./uploads}")
private String uploadDir;
@Transactional
public FileVO upload(MultipartFile file, String fileGroup) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, "빈 파일입니다");
}
String original = file.getOriginalFilename();
if (original == null) original = "unknown";
String ext = extension(original);
String stored = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
String relPath = LocalDate.now().toString().replace("-", "/");
Path dir = Paths.get(uploadDir, relPath);
try {
Files.createDirectories(dir);
Path dest = dir.resolve(stored);
file.transferTo(dest.toFile());
FileVO vo = new FileVO();
vo.setFileGroup(fileGroup);
vo.setOriginalName(original);
vo.setStoredName(stored);
vo.setFilePath(dest.toAbsolutePath().toString());
vo.setFileSize(file.getSize());
vo.setContentType(file.getContentType());
vo.setExtension(ext);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
vo.setCreatedAt(java.time.LocalDateTime.now());
mapper.insert(vo);
return vo;
} catch (IOException e) {
log.error("File upload error", e);
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, e.getMessage());
}
}
public FileVO download(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
mapper.incrementDownloadCount(fileId);
return vo;
}
public List<FileVO> listByGroup(String fileGroup) {
return mapper.selectByGroup(fileGroup);
}
@Transactional
public void delete(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
mapper.markDeleted(fileId);
// 물리 삭제는 배치로 처리
}
private String extension(String name) {
int idx = name.lastIndexOf('.');
return idx >= 0 ? name.substring(idx + 1).toLowerCase() : "";
}
}
@@ -0,0 +1,21 @@
package com.ga.common.file;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FileVO {
private Long fileId;
private String fileGroup;
private String originalName;
private String storedName;
private String filePath;
private Long fileSize;
private String contentType;
private String extension;
private Integer downloadCount;
private String isDeleted;
private LocalDateTime createdAt;
private Long createdBy;
}
@@ -0,0 +1,22 @@
package com.ga.common.grid;
import lombok.Builder;
import lombok.Getter;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* AG Grid 서버사이드 응답.
* - rows: 현재 페이지 데이터
* - lastRow: 전체 행 수 (페이지 끝 표시용; -1이면 알 수 없음)
* - pinnedBottomRow: 합계행 (선택)
*/
@Getter
@Builder
public class GridResponse<T> {
private List<T> rows;
private long lastRow;
private Map<String, BigDecimal> pinnedBottomRow;
}
@@ -0,0 +1,32 @@
package com.ga.common.grid;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
import java.util.Map;
/**
* AG Grid 서버사이드용 검색 파라미터.
*
* - filterModel: { fieldName: { type: "contains", filter: "abc" } }
* - sortModel: [ { colId: "agentName", sort: "asc" } ]
*
* MyBatis XML 에서 동적 WHERE 로 활용 가능.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class GridSearchParam extends SearchParam {
/** AG Grid filterModel — { field: { type, filter, filterTo } } */
private Map<String, Map<String, Object>> filterModel;
/** AG Grid sortModel — [ { colId, sort } ] */
private List<Map<String, String>> sortModel;
/** 시작 행(서버사이드 모드) */
private Integer startRow;
/** 종료 행 */
private Integer endRow;
}
@@ -0,0 +1,56 @@
package com.ga.common.menu;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "메뉴")
@RestController
@RequestMapping("/api/menus")
@RequiredArgsConstructor
public class MenuController {
private final MenuService service;
/** 로그인 사용자의 메뉴 (좌측 사이드바용) */
@GetMapping("/my")
public ApiResponse<List<MenuResp>> myMenus() {
return ApiResponse.ok(service.getMyMenus());
}
@GetMapping("/tree")
@RequirePermission(menu = "SYSTEM_MENU", perm = "READ")
public ApiResponse<List<MenuResp>> tree() {
return ApiResponse.ok(service.getMenuTree());
}
@GetMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "READ")
public ApiResponse<MenuVO> detail(@PathVariable Long menuId) {
return ApiResponse.ok(service.getById(menuId));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_MENU", perm = "CREATE")
public ApiResponse<Long> create(@RequestBody MenuVO vo) {
return ApiResponse.ok(service.create(vo));
}
@PutMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "UPDATE")
public ApiResponse<Void> update(@PathVariable Long menuId, @RequestBody MenuVO vo) {
service.update(menuId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/{menuId}")
@RequirePermission(menu = "SYSTEM_MENU", perm = "DELETE")
public ApiResponse<Void> delete(@PathVariable Long menuId) {
service.delete(menuId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,32 @@
package com.ga.common.menu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface MenuMapper {
/** 전체 활성 메뉴 (정렬됨) */
List<MenuVO> selectAllActive();
/** 사용자가 접근 가능한 메뉴 목록 */
List<MenuVO> selectAccessibleMenusByUserId(@Param("userId") Long userId);
/** 사용자별 (menuId → permCodes) 매핑 */
List<Map<String, Object>> selectUserPermissions(@Param("userId") Long userId);
/** 단일 (menuCode, permCode) 권한 보유 여부 */
int hasPermission(@Param("userId") Long userId,
@Param("menuCode") String menuCode,
@Param("permCode") String permCode);
MenuVO selectById(@Param("menuId") Long menuId);
MenuVO selectByCode(@Param("menuCode") String menuCode);
int insert(MenuVO vo);
int update(MenuVO vo);
int deleteById(@Param("menuId") Long menuId);
int countChildren(@Param("menuId") Long menuId);
}
@@ -0,0 +1,31 @@
package com.ga.common.menu;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 메뉴 응답 (트리 구조 + 사용자 권한 코드 포함).
*/
@Data
public class MenuResp {
private Long menuId;
private Long parentMenuId;
private String menuCode;
private String menuName;
private String menuType;
private String menuPath;
private String menuIcon;
private String componentPath;
private Integer menuLevel;
private Integer sortOrder;
private String isVisible;
private String isActive;
/** 현재 사용자가 보유한 perm code 목록 (READ/CREATE/UPDATE...) */
private List<String> permissions = new ArrayList<>();
/** 자식 메뉴 */
private List<MenuResp> children = new ArrayList<>();
}
@@ -0,0 +1,74 @@
package com.ga.common.menu;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class MenuService {
private final MenuMapper mapper;
/** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함) */
public List<MenuResp> getMyMenus() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
List<MenuVO> flat = mapper.selectAccessibleMenusByUserId(userId);
Map<Long, List<String>> permissions = loadPermissionsByMenuId(userId);
return MenuTreeBuilder.build(flat, permissions);
}
/** 전체 메뉴 트리 (관리자) */
public List<MenuResp> getMenuTree() {
return MenuTreeBuilder.build(mapper.selectAllActive(), Map.of());
}
public MenuVO getById(Long menuId) {
MenuVO m = mapper.selectById(menuId);
if (m == null) throw new BizException(ErrorCode.NOT_FOUND);
return m;
}
@Transactional
public Long create(MenuVO vo) {
if (mapper.selectByCode(vo.getMenuCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insert(vo);
return vo.getMenuId();
}
@Transactional
public void update(Long menuId, MenuVO vo) {
getById(menuId);
vo.setMenuId(menuId);
mapper.update(vo);
}
@Transactional
public void delete(Long menuId) {
if (mapper.countChildren(menuId) > 0) {
throw new BizException(ErrorCode.DEPENDENT_DATA, "하위 메뉴가 존재합니다");
}
mapper.deleteById(menuId);
}
private Map<Long, List<String>> loadPermissionsByMenuId(Long userId) {
Map<Long, List<String>> result = new HashMap<>();
for (Map<String, Object> row : mapper.selectUserPermissions(userId)) {
Long menuId = ((Number) row.get("menuId")).longValue();
String perm = (String) row.get("permCode");
result.computeIfAbsent(menuId, k -> new java.util.ArrayList<>()).add(perm);
}
return result;
}
}
@@ -0,0 +1,54 @@
package com.ga.common.menu;
import java.util.*;
/**
* 평면 메뉴 목록 → 트리 구조 변환.
*/
public final class MenuTreeBuilder {
private MenuTreeBuilder() {}
public static List<MenuResp> build(List<MenuVO> flat, Map<Long, List<String>> permissionsByMenuId) {
Map<Long, MenuResp> map = new LinkedHashMap<>();
// VO → Resp 변환
for (MenuVO vo : flat) {
MenuResp r = new MenuResp();
r.setMenuId(vo.getMenuId());
r.setParentMenuId(vo.getParentMenuId());
r.setMenuCode(vo.getMenuCode());
r.setMenuName(vo.getMenuName());
r.setMenuType(vo.getMenuType());
r.setMenuPath(vo.getMenuPath());
r.setMenuIcon(vo.getMenuIcon());
r.setComponentPath(vo.getComponentPath());
r.setMenuLevel(vo.getMenuLevel());
r.setSortOrder(vo.getSortOrder());
r.setIsVisible(vo.getIsVisible());
r.setIsActive(vo.getIsActive());
if (permissionsByMenuId != null) {
r.setPermissions(permissionsByMenuId.getOrDefault(vo.getMenuId(), List.of()));
}
map.put(vo.getMenuId(), r);
}
// 부모 → 자식 연결
List<MenuResp> roots = new ArrayList<>();
for (MenuResp r : map.values()) {
if (r.getParentMenuId() == null) {
roots.add(r);
} else {
MenuResp parent = map.get(r.getParentMenuId());
if (parent != null) parent.getChildren().add(r);
else roots.add(r);
}
}
sortRecursive(roots);
return roots;
}
private static void sortRecursive(List<MenuResp> nodes) {
nodes.sort(Comparator.comparing(MenuResp::getSortOrder, Comparator.nullsLast(Integer::compareTo)));
for (MenuResp n : nodes) sortRecursive(n.getChildren());
}
}
@@ -0,0 +1,20 @@
package com.ga.common.menu;
import lombok.Data;
@Data
public class MenuVO {
private Long menuId;
private Long parentMenuId;
private String menuCode;
private String menuName;
private String menuType; // DIRECTORY / PAGE / LINK
private String menuPath;
private String menuIcon;
private String componentPath;
private Integer menuLevel;
private Integer sortOrder;
private String isVisible;
private String isActive;
private String description;
}
@@ -0,0 +1,21 @@
package com.ga.common.menu;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class PermissionChecker {
private final MenuMapper menuMapper;
/** 사용자 + 메뉴 + 권한 조합을 체크. 캐시: userPerm::userId_menuCode_permCode */
@Cacheable(value = "userPerm",
key = "T(java.lang.String).format('%d_%s_%s', #userId, #menuCode, #permCode)",
unless = "#result == false")
public boolean hasPermission(Long userId, String menuCode, String permCode) {
if (userId == null) return false;
return menuMapper.hasPermission(userId, menuCode, permCode) > 0;
}
}
@@ -0,0 +1,51 @@
package com.ga.common.model;
import com.ga.common.exception.ErrorCode;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 모든 REST API의 표준 응답.
* 신입 개발자는 ApiResponse.ok(...) / ApiResponse.fail(...) 만 사용하면 된다.
*/
@Getter
public class ApiResponse<T> {
private final boolean success;
private final String code;
private final String message;
private final T data;
private final LocalDateTime timestamp = LocalDateTime.now();
private ApiResponse(boolean success, String code, String message, T data) {
this.success = success;
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
}
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), message, data);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
}
public static <T> ApiResponse<T> fail(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
public static <T> ApiResponse<T> fail(ErrorCode errorCode) {
return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
}
public static <T> ApiResponse<T> fail(ErrorCode errorCode, String message) {
return new ApiResponse<>(false, errorCode.getCode(), message, null);
}
}
@@ -0,0 +1,49 @@
package com.ga.common.model;
import com.github.pagehelper.PageInfo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Collections;
import java.util.List;
/**
* 페이징 응답 (PageHelper 연동).
* 사용 예: return PageResponse.of(mapper.selectList(param));
*/
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PageResponse<T> {
private List<T> list;
private int pageNum;
private int pageSize;
private long total;
private int pages;
public static <T> PageResponse<T> of(List<T> list) {
if (list == null) {
return PageResponse.<T>builder()
.list(Collections.emptyList())
.pageNum(1).pageSize(0).total(0).pages(0).build();
}
PageInfo<T> info = new PageInfo<>(list);
return PageResponse.<T>builder()
.list(info.getList())
.pageNum(info.getPageNum())
.pageSize(info.getPageSize())
.total(info.getTotal())
.pages(info.getPages())
.build();
}
public static <T> PageResponse<T> empty() {
return PageResponse.<T>builder()
.list(Collections.emptyList())
.pageNum(1).pageSize(0).total(0).pages(0).build();
}
}
@@ -0,0 +1,45 @@
package com.ga.common.model;
import com.github.pagehelper.PageHelper;
import lombok.Data;
/**
* 공통 검색 파라미터. 모든 *SearchParam 의 부모.
*
* 사용:
* public PageResponse<X> list(XSearchParam p) {
* p.startPage();
* return PageResponse.of(mapper.selectList(p));
* }
*/
@Data
public class SearchParam {
private int pageNum = 1;
private int pageSize = 20;
private String searchType;
private String searchKeyword;
private String startDate;
private String endDate;
private String status;
private String sortField;
private String sortOrder = "DESC";
/** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */
public void startPage() {
PageHelper.startPage(pageNum, pageSize);
if (sortField != null && !sortField.isBlank()) {
String order = "ASC".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
PageHelper.orderBy(sanitizeSortField(sortField) + " " + order);
}
}
/** SQL Injection 방지: 영문/숫자/_ 만 허용 */
private String sanitizeSortField(String s) {
return s.replaceAll("[^A-Za-z0-9_]", "");
}
}
@@ -0,0 +1,22 @@
package com.ga.common.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 트리 응답 공통 모델 (메뉴, 조직 등).
*/
@Data
public class TreeNode<T> {
private Long id;
private Long parentId;
private String label;
private T data;
private List<TreeNode<T>> children = new ArrayList<>();
public void addChild(TreeNode<T> child) {
children.add(child);
}
}
@@ -0,0 +1,92 @@
package com.ga.common.mybatis;
import com.ga.common.util.SecurityUtil;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Map;
/**
* INSERT / UPDATE 시 감사 필드(created_at, created_by, updated_at, updated_by)를 자동 채운다.
*
* VO 에 다음 필드가 있을 때만 작동:
* - createdAt, createdBy, updatedAt, updatedBy
*
* Map 또는 Collection 파라미터의 경우 내부 객체 각각에 대해 적용한다.
*/
@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class AuditInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
SqlCommandType type = ms.getSqlCommandType();
if (type == SqlCommandType.INSERT || type == SqlCommandType.UPDATE) {
apply(parameter, type);
}
return invocation.proceed();
}
private void apply(Object parameter, SqlCommandType type) {
if (parameter == null) return;
if (parameter instanceof Collection<?> col) {
col.forEach(o -> applyOne(o, type));
return;
}
if (parameter instanceof Map<?, ?> map) {
for (Object v : map.values()) {
if (v instanceof Collection<?> col) {
col.forEach(o -> applyOne(o, type));
} else {
applyOne(v, type);
}
}
return;
}
applyOne(parameter, type);
}
private void applyOne(Object obj, SqlCommandType type) {
if (obj == null) return;
try {
MetaObject meta = SystemMetaObject.forObject(obj);
LocalDateTime now = LocalDateTime.now();
Long userId = SecurityUtil.getCurrentUserId();
if (type == SqlCommandType.INSERT) {
if (meta.hasSetter("createdAt") && meta.getValue("createdAt") == null) {
meta.setValue("createdAt", now);
}
if (meta.hasSetter("createdBy") && meta.getValue("createdBy") == null && userId != null) {
meta.setValue("createdBy", userId);
}
}
if (type == SqlCommandType.UPDATE) {
if (meta.hasSetter("updatedAt")) {
meta.setValue("updatedAt", now);
}
if (meta.hasSetter("updatedBy") && userId != null) {
meta.setValue("updatedBy", userId);
}
}
} catch (Exception ignore) {
// VO 가 아닌 단순 파라미터(Long, String 등)는 무시
}
}
}
@@ -0,0 +1,18 @@
package com.ga.common.mybatis;
import com.ga.common.model.SearchParam;
import java.util.List;
/**
* 공통 CRUD Mapper. 단순한 단일 테이블에 대해 선택적으로 상속하여 사용.
* 복잡한 조인이 필요하면 일반 Mapper Interface 만 정의해도 된다.
*/
public interface BaseMapper<T> {
T selectById(Long id);
List<T> selectList(SearchParam param);
int insert(T entity);
int update(T entity);
int deleteById(Long id);
int countByCondition(SearchParam param);
}
@@ -0,0 +1,66 @@
package com.ga.common.mybatis;
import com.ga.common.util.EncryptUtil;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* AES 암호화 컬럼용 TypeHandler.
* Mapper XML 에서 typeHandler="com.ga.common.mybatis.EncryptTypeHandler" 지정.
*/
@Component
@MappedTypes(String.class)
public class EncryptTypeHandler extends BaseTypeHandler<String> implements ApplicationContextAware {
private static EncryptUtil encryptUtil;
@Override
public void setApplicationContext(ApplicationContext ctx) {
EncryptTypeHandler.encryptUtil = ctx.getBean(EncryptUtil.class);
}
@Autowired(required = false)
public void setEncryptUtil(EncryptUtil util) {
EncryptTypeHandler.encryptUtil = util;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, encryptUtil != null ? encryptUtil.encrypt(parameter) : parameter);
}
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return decrypt(rs.getString(columnName));
}
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return decrypt(rs.getString(columnIndex));
}
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return decrypt(cs.getString(columnIndex));
}
private String decrypt(String v) {
if (v == null || v.isBlank()) return v;
try {
return encryptUtil != null ? encryptUtil.decrypt(v) : v;
} catch (Exception e) {
// 암호화 안 된 기존 값일 수 있음 → 원본 반환
return v;
}
}
}
@@ -0,0 +1,47 @@
package com.ga.common.mybatis;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.postgresql.util.PGobject;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* PostgreSQL JSONB ↔ Jackson JsonNode 변환 TypeHandler.
*/
@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> {
private static final ObjectMapper M = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException {
PGobject pg = new PGobject();
pg.setType("jsonb");
try {
pg.setValue(M.writeValueAsString(parameter));
} catch (Exception e) {
throw new SQLException(e);
}
ps.setObject(i, pg);
}
@Override public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); }
@Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); }
@Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); }
private JsonNode parse(String json) throws SQLException {
if (json == null || json.isBlank()) return null;
try {
return M.readTree(json);
} catch (Exception e) {
throw new SQLException(e);
}
}
}
@@ -0,0 +1,39 @@
package com.ga.common.notification;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "알림")
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService service;
@GetMapping
public ApiResponse<List<NotificationVO>> list() {
return ApiResponse.ok(service.myNotifications());
}
@GetMapping("/unread-count")
public ApiResponse<Integer> unreadCount() {
return ApiResponse.ok(service.unreadCount());
}
@PutMapping("/{notiId}/read")
public ApiResponse<Void> markRead(@PathVariable Long notiId) {
service.markRead(notiId);
return ApiResponse.ok();
}
@PutMapping("/read-all")
public ApiResponse<Void> markAllRead() {
service.markAllRead();
return ApiResponse.ok();
}
}
@@ -0,0 +1,17 @@
package com.ga.common.notification;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface NotificationMapper {
int insert(NotificationVO vo);
int insertBatch(@Param("list") List<NotificationVO> list);
List<NotificationVO> selectByUser(@Param("userId") Long userId);
int countUnread(@Param("userId") Long userId);
int markRead(@Param("notiId") Long notiId, @Param("userId") Long userId);
int markAllRead(@Param("userId") Long userId);
int deleteOld(@Param("days") int days);
}
@@ -0,0 +1,60 @@
package com.ga.common.notification;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationMapper mapper;
public List<NotificationVO> myNotifications() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
return mapper.selectByUser(userId);
}
public int unreadCount() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) return 0;
return mapper.countUnread(userId);
}
@Transactional
public void markRead(Long notiId) {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
mapper.markRead(notiId, userId);
}
@Transactional
public void markAllRead() {
Long userId = SecurityUtil.getCurrentUserId();
if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED);
mapper.markAllRead(userId);
}
@Transactional
public void send(Long userId, String type, String title, String content, String linkUrl) {
NotificationVO vo = new NotificationVO();
vo.setUserId(userId);
vo.setNotiType(type);
vo.setTitle(title);
vo.setContent(content);
vo.setLinkUrl(linkUrl);
mapper.insert(vo);
}
@Transactional
public void sendBatch(List<NotificationVO> list) {
if (list == null || list.isEmpty()) return;
mapper.insertBatch(list);
}
}
@@ -0,0 +1,18 @@
package com.ga.common.notification;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class NotificationVO {
private Long notiId;
private Long userId;
private String notiType; // INFO / WARN / ERROR
private String title;
private String content;
private String linkUrl;
private String isRead;
private LocalDateTime readAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,30 @@
package com.ga.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
ErrorCode ec = ErrorCode.FORBIDDEN;
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,30 @@
package com.ga.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorCode ec = ErrorCode.UNAUTHORIZED;
response.setStatus(ec.getHttpStatus());
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(),
Map.of("success", false, "code", ec.getCode(), "message", ec.getMessage()));
}
}
@@ -0,0 +1,76 @@
package com.ga.common.security;
import com.ga.common.auth.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final JwtAuthEntryPoint jwtAuthEntryPoint;
private final AccessDeniedHandlerImpl accessDeniedHandler;
@Bean
public SecurityFilterChain filter(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.exceptionHandling(eh -> eh
.authenticationEntryPoint(jwtAuthEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsSource() {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOriginPatterns(List.of("*"));
c.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
c.setAllowedHeaders(List.of("*"));
c.setExposedHeaders(List.of("Authorization", "Content-Disposition"));
c.setAllowCredentials(true);
UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/**", c);
return src;
}
}
@@ -0,0 +1,25 @@
package com.ga.common.system;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiAccessLogService {
private final SystemLogMapper logMapper;
@Async("asyncExecutor")
public void saveAsync(Long userId, String method, String url, String requestParam,
String responseCode, int responseTime, String ipAddress, String errorMessage) {
try {
logMapper.insertApiAccessLog(userId, method, url, requestParam, responseCode,
responseTime, ipAddress, errorMessage);
} catch (Exception e) {
log.warn("ApiAccessLog save fail: {}", e.getMessage());
}
}
}
@@ -0,0 +1,55 @@
package com.ga.common.system;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* 데이터 변경 이력 저장 (비동기).
* before/after JSON 비교 → changed_keys 산출 → JSONB INSERT.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataChangeLogService {
private final SystemLogMapper logMapper;
private final ObjectMapper objectMapper;
@Async("asyncExecutor")
public void save(Long userId, String menuCode, String tableName, String recordId,
String actionType, String beforeJson, String afterJson) {
try {
String changedKeys = diffKeys(beforeJson, afterJson);
logMapper.insertDataChangeLog(userId, menuCode, tableName, recordId, actionType,
beforeJson, afterJson, changedKeys);
} catch (Exception e) {
log.warn("DataChangeLog save fail: {}", e.getMessage());
}
}
private String diffKeys(String beforeJson, String afterJson) {
try {
JsonNode b = objectMapper.readTree(beforeJson == null ? "{}" : beforeJson);
JsonNode a = objectMapper.readTree(afterJson == null ? "{}" : afterJson);
List<String> changed = new ArrayList<>();
Iterator<String> names = a.fieldNames();
while (names.hasNext()) {
String n = names.next();
JsonNode bv = b.get(n);
JsonNode av = a.get(n);
if (bv == null || !bv.equals(av)) changed.add(n);
}
return String.join(",", changed);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,32 @@
package com.ga.common.system;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "시스템설정")
@RestController
@RequestMapping("/api/system/config")
@RequiredArgsConstructor
public class SystemConfigController {
private final SystemConfigService service;
@GetMapping
@RequirePermission(menu = "SYSTEM_CONFIG", perm = "READ")
public ApiResponse<List<SystemConfigVO>> list(@RequestParam(required = false) String group) {
return ApiResponse.ok(group == null || group.isBlank() ? service.listAll() : service.listByGroup(group));
}
@PutMapping("/{configKey}")
@RequirePermission(menu = "SYSTEM_CONFIG", perm = "UPDATE")
public ApiResponse<Void> update(@PathVariable String configKey, @RequestBody SystemConfigVO vo) {
vo.setConfigKey(configKey);
service.update(vo);
return ApiResponse.ok();
}
}
@@ -0,0 +1,14 @@
package com.ga.common.system;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SystemConfigMapper {
List<SystemConfigVO> selectAll();
List<SystemConfigVO> selectByGroup(@Param("configGroup") String configGroup);
SystemConfigVO selectByKey(@Param("configKey") String configKey);
int update(SystemConfigVO vo);
}
@@ -0,0 +1,65 @@
package com.ga.common.system;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class SystemConfigService {
private final SystemConfigMapper mapper;
public List<SystemConfigVO> listAll() {
return mapper.selectAll();
}
public List<SystemConfigVO> listByGroup(String group) {
return mapper.selectByGroup(group);
}
@Cacheable(value = "systemConfig", key = "#key")
public Optional<String> getValue(String key) {
SystemConfigVO vo = mapper.selectByKey(key);
return Optional.ofNullable(vo).map(SystemConfigVO::getConfigValue);
}
public String getString(String key, String defaultValue) {
return getValue(key).orElse(defaultValue);
}
public int getInt(String key, int defaultValue) {
return getValue(key).map(Integer::parseInt).orElse(defaultValue);
}
public BigDecimal getDecimal(String key, BigDecimal defaultValue) {
return getValue(key).map(BigDecimal::new).orElse(defaultValue);
}
public boolean getBoolean(String key, boolean defaultValue) {
return getValue(key).map(s -> "true".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s)).orElse(defaultValue);
}
@Transactional
@CacheEvict(value = "systemConfig", key = "#vo.configKey")
public void update(SystemConfigVO vo) {
SystemConfigVO existing = mapper.selectByKey(vo.getConfigKey());
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"Y".equals(existing.getIsEditable())) {
throw new BizException(ErrorCode.NOT_PERMITTED, "수정할 수 없는 설정입니다");
}
vo.setUpdatedAt(LocalDateTime.now());
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
mapper.update(vo);
}
}
@@ -0,0 +1,18 @@
package com.ga.common.system;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SystemConfigVO {
private String configKey;
private String configValue;
private String configGroup;
private String valueType;
private String isEncrypted;
private String isEditable;
private String description;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,65 @@
package com.ga.common.system;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.common.model.SearchParam;
import com.github.pagehelper.PageHelper;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "시스템로그")
@RestController
@RequestMapping("/api/system/logs")
@RequiredArgsConstructor
public class SystemLogController {
private final SystemLogMapper mapper;
@GetMapping("/login")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> loginLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId) {
param.startPage();
Map<String, Object> p = toMap(param);
p.put("userId", userId);
return ApiResponse.ok(PageResponse.of(mapper.selectLoginLogs(p)));
}
@GetMapping("/api")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> apiLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String url) {
PageHelper.startPage(param.getPageNum(), param.getPageSize());
Map<String, Object> p = toMap(param);
p.put("userId", userId);
p.put("url", url);
return ApiResponse.ok(PageResponse.of(mapper.selectApiAccessLogs(p)));
}
@GetMapping("/data-change")
@RequirePermission(menu = "SYSTEM_LOG", perm = "READ")
public ApiResponse<PageResponse<Map<String, Object>>> dataChangeLogs(@ModelAttribute SearchParam param,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String tableName,
@RequestParam(required = false) String recordId) {
PageHelper.startPage(param.getPageNum(), param.getPageSize());
Map<String, Object> p = toMap(param);
p.put("userId", userId);
p.put("tableName", tableName);
p.put("recordId", recordId);
return ApiResponse.ok(PageResponse.of(mapper.selectDataChangeLogs(p)));
}
private Map<String, Object> toMap(SearchParam p) {
Map<String, Object> m = new HashMap<>();
m.put("startDate", p.getStartDate());
m.put("endDate", p.getEndDate());
return m;
}
}
@@ -0,0 +1,40 @@
package com.ga.common.system;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface SystemLogMapper {
int insertLoginLog(@Param("userId") Long userId,
@Param("loginId") String loginId,
@Param("loginType") String loginType,
@Param("failReason") String failReason,
@Param("ipAddress") String ipAddress,
@Param("userAgent") String userAgent);
int insertApiAccessLog(@Param("userId") Long userId,
@Param("method") String method,
@Param("url") String url,
@Param("requestParam") String requestParam,
@Param("responseCode") String responseCode,
@Param("responseTime") Integer responseTime,
@Param("ipAddress") String ipAddress,
@Param("errorMessage") String errorMessage);
int insertDataChangeLog(@Param("userId") Long userId,
@Param("menuCode") String menuCode,
@Param("tableName") String tableName,
@Param("recordId") String recordId,
@Param("actionType") String actionType,
@Param("beforeJson") String beforeJson,
@Param("afterJson") String afterJson,
@Param("changedKeys") String changedKeys);
List<Map<String, Object>> selectLoginLogs(@Param("param") Map<String, Object> param);
List<Map<String, Object>> selectApiAccessLogs(@Param("param") Map<String, Object> param);
List<Map<String, Object>> selectDataChangeLogs(@Param("param") Map<String, Object> param);
}
@@ -0,0 +1,68 @@
package com.ga.common.util;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
/**
* 날짜/정산월 유틸. 정산월 포맷은 "yyyyMM" 6자리.
*/
public final class DateUtil {
private DateUtil() {}
public static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static final DateTimeFormatter FMT_DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static final DateTimeFormatter FMT_MONTH = DateTimeFormatter.ofPattern("yyyyMM");
public static final DateTimeFormatter FMT_DATE_KOR = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
public static String today() {
return LocalDate.now().format(FMT_DATE);
}
public static String getSettleMonth() {
return YearMonth.now().format(FMT_MONTH);
}
public static String getSettleMonth(LocalDate date) {
return YearMonth.from(date).format(FMT_MONTH);
}
/** "202605" → ["2026-05-01", "2026-05-31"] */
public static String[] getMonthRange(String settleMonth) {
YearMonth ym = YearMonth.parse(settleMonth, FMT_MONTH);
return new String[] {
ym.atDay(1).format(FMT_DATE),
ym.atEndOfMonth().format(FMT_DATE)
};
}
public static int calcMonthDiff(String fromYyyymm, String toYyyymm) {
YearMonth from = YearMonth.parse(fromYyyymm, FMT_MONTH);
YearMonth to = YearMonth.parse(toYyyymm, FMT_MONTH);
return (int) ChronoUnit.MONTHS.between(from, to);
}
public static int calcMonthDiff(LocalDate from, LocalDate to) {
return (int) ChronoUnit.MONTHS.between(YearMonth.from(from), YearMonth.from(to));
}
public static LocalDate parseDate(String s, String pattern) {
if (s == null || s.isBlank()) return null;
try {
return LocalDate.parse(s, DateTimeFormatter.ofPattern(pattern));
} catch (DateTimeParseException e) {
return null;
}
}
public static String format(LocalDate date, String pattern) {
return date == null ? null : date.format(DateTimeFormatter.ofPattern(pattern));
}
public static String addMonth(String yyyymm, int months) {
return YearMonth.parse(yyyymm, FMT_MONTH).plusMonths(months).format(FMT_MONTH);
}
}
@@ -0,0 +1,62 @@
package com.ga.common.util;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* AES-256 CBC 암호화 (resident_no, account_no 등에 사용).
* 키는 application.yml: ga.encrypt.key (32바이트)
*/
@Component
public class EncryptUtil {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final String AES = "AES";
private final byte[] keyBytes;
private final byte[] iv;
public EncryptUtil(@Value("${ga.encrypt.key}") String key) {
byte[] raw = key.getBytes(StandardCharsets.UTF_8);
// 키를 32바이트로 정규화
this.keyBytes = new byte[32];
for (int i = 0; i < 32; i++) {
keyBytes[i] = i < raw.length ? raw[i] : (byte) 0;
}
// IV는 키의 앞 16바이트 (운영에서는 별도 관리 권장)
this.iv = new byte[16];
System.arraycopy(keyBytes, 0, this.iv, 0, 16);
}
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) return plainText;
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, AES), new IvParameterSpec(iv));
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "암호화 실패");
}
}
public String decrypt(String encryptedBase64) {
if (encryptedBase64 == null || encryptedBase64.isEmpty()) return encryptedBase64;
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, AES), new IvParameterSpec(iv));
byte[] decoded = Base64.getDecoder().decode(encryptedBase64);
return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "복호화 실패");
}
}
}
@@ -0,0 +1,73 @@
package com.ga.common.util;
/**
* 개인정보 마스킹 유틸.
* - NAME : 홍길동 → 홍*동
* - PHONE: 010-1234-5678 → 010-****-5678
* - RRN : 880101-1234567 → 880101-*******
* - ACCT : 110-123-456789 → 110-***-******
* - EMAIL: aaa@bbb.com → a**@bbb.com
*/
public final class MaskUtil {
private MaskUtil() {}
public enum MaskType { NAME, PHONE, RRN, ACCOUNT, EMAIL }
public static String mask(String value, MaskType type) {
if (value == null || value.isBlank()) return value;
return switch (type) {
case NAME -> maskName(value);
case PHONE -> maskPhone(value);
case RRN -> maskRrn(value);
case ACCOUNT -> maskAccount(value);
case EMAIL -> maskEmail(value);
};
}
public static String maskName(String name) {
int len = name.length();
if (len <= 1) return name;
if (len == 2) return name.charAt(0) + "*";
return name.charAt(0) + "*".repeat(len - 2) + name.charAt(len - 1);
}
public static String maskPhone(String phone) {
String digits = phone.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
return digits.substring(0, 3) + "-****-" + digits.substring(7);
}
if (digits.length() == 10) {
return digits.substring(0, 3) + "-***-" + digits.substring(6);
}
return phone;
}
public static String maskRrn(String rrn) {
String s = rrn.replaceAll("[^0-9]", "");
if (s.length() != 13) return rrn;
return s.substring(0, 6) + "-*******";
}
public static String maskAccount(String account) {
if (account == null) return null;
String[] parts = account.split("-");
if (parts.length >= 2) {
StringBuilder sb = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
sb.append('-').append("*".repeat(parts[i].length()));
}
return sb.toString();
}
if (account.length() <= 4) return account;
return account.substring(0, 4) + "*".repeat(account.length() - 4);
}
public static String maskEmail(String email) {
int at = email.indexOf('@');
if (at <= 1) return email;
String id = email.substring(0, at);
String domain = email.substring(at);
return id.charAt(0) + "*".repeat(id.length() - 1) + domain;
}
}
@@ -0,0 +1,73 @@
package com.ga.common.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
/**
* 금액 계산 유틸. 모든 금액은 BigDecimal, 반올림은 HALF_UP, 소수점 2자리.
*/
public final class MoneyUtil {
private MoneyUtil() {}
public static final int SCALE = 2;
public static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
public static final BigDecimal DEFAULT_TAX_RATE = new BigDecimal("3.3");
private static final DecimalFormat FMT = new DecimalFormat("#,##0");
private static final DecimalFormat FMT_DEC = new DecimalFormat("#,##0.00");
private static final DecimalFormat FMT_SIGN = new DecimalFormat("+#,##0;-#,##0");
public static BigDecimal zero(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
public static BigDecimal add(BigDecimal a, BigDecimal b) {
return zero(a).add(zero(b));
}
public static BigDecimal sub(BigDecimal a, BigDecimal b) {
return zero(a).subtract(zero(b));
}
public static BigDecimal mul(BigDecimal a, BigDecimal b) {
return zero(a).multiply(zero(b)).setScale(SCALE, RoundingMode.HALF_UP);
}
public static BigDecimal div(BigDecimal a, BigDecimal b) {
if (b == null || b.signum() == 0) return BigDecimal.ZERO;
return zero(a).divide(b, SCALE, RoundingMode.HALF_UP);
}
/** amount × rate / 100 (HALF_UP, 2자리) */
public static BigDecimal pct(BigDecimal amount, BigDecimal ratePct) {
if (amount == null || ratePct == null) return BigDecimal.ZERO;
return amount.multiply(ratePct).divide(HUNDRED, SCALE, RoundingMode.HALF_UP);
}
/** 세액 계산 (기본 3.3%) */
public static BigDecimal tax(BigDecimal amount) {
return pct(amount, DEFAULT_TAX_RATE);
}
public static BigDecimal tax(BigDecimal amount, BigDecimal ratePct) {
return pct(amount, ratePct);
}
public static String fmt(BigDecimal v) {
return v == null ? "" : FMT.format(v);
}
public static String fmtDec(BigDecimal v) {
return v == null ? "" : FMT_DEC.format(v);
}
public static String fmtSign(BigDecimal v) {
return v == null ? "" : FMT_SIGN.format(v);
}
public static boolean isZero(BigDecimal v) { return v == null || v.signum() == 0; }
public static boolean isPositive(BigDecimal v) { return v != null && v.signum() > 0; }
public static boolean isNegative(BigDecimal v) { return v != null && v.signum() < 0; }
}
@@ -0,0 +1,31 @@
package com.ga.common.util;
import com.ga.common.auth.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
/**
* 현재 로그인 사용자 조회.
*/
public final class SecurityUtil {
private SecurityUtil() {}
public static Optional<LoginUser> getLoginUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return Optional.empty();
Object principal = auth.getPrincipal();
if (principal instanceof LoginUser lu) return Optional.of(lu);
return Optional.empty();
}
public static Long getCurrentUserId() {
return getLoginUser().map(LoginUser::getUserId).orElse(null);
}
public static String getCurrentLoginId() {
return getLoginUser().map(LoginUser::getLoginId).orElse(null);
}
}