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