feat: 1200%룰 이연 분급 도래월 지급(PayInstallmentStep) + 코드베이스 신입교육 주석
PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을 도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고 도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움. - BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록 - InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등 (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리) - RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount - 단위테스트 3건(정상지급/멱등/계약미존재) GREEN 신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가 (동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,54 +21,70 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Authorization: Bearer xxx 헤더에서 JWT 추출 → SecurityContext 설정.
|
||||
* 모든 요청에서 "Authorization: Bearer xxx" 헤더의 JWT를 꺼내 검증하고, 통과하면
|
||||
* 인증 정보를 SecurityContext에 심는 필터.
|
||||
*
|
||||
* <p>왜 필요한가: 이 시스템은 세션이 아니라 토큰(JWT) 기반 무상태(stateless) 인증을 쓴다.
|
||||
* 컨트롤러가 "현재 누가 요청했는지" 알려면, 요청이 컨트롤러에 닿기 전에 이 필터가 토큰을 해석해
|
||||
* 사용자 정보를 SecurityContext에 올려줘야 한다. SecurityConfig에서 스프링 시큐리티 필터 체인에 등록된다.
|
||||
*
|
||||
* <p>OncePerRequestFilter: 한 요청당 정확히 한 번만 실행되도록 보장하는 스프링 기반 필터.
|
||||
* 토큰이 없으면 인증을 세팅하지 않고 그냥 통과시키며(이후 보호된 URL이면 시큐리티가 401 처리),
|
||||
* 토큰이 있는데 잘못됐으면 여기서 즉시 JSON 에러로 응답을 끊는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String HEADER = "Authorization";
|
||||
private static final String PREFIX = "Bearer ";
|
||||
private static final String HEADER = "Authorization"; // 토큰이 실리는 헤더명
|
||||
private static final String PREFIX = "Bearer "; // 토큰 앞에 붙는 표준 접두어
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JwtTokenProvider tokenProvider; // 토큰 파싱/검증 담당
|
||||
private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String header = request.getHeader(HEADER);
|
||||
// "Bearer "로 시작하는 헤더가 있을 때만 토큰 인증을 시도한다(없으면 비로그인 상태로 통과).
|
||||
if (header != null && header.startsWith(PREFIX)) {
|
||||
String token = header.substring(PREFIX.length());
|
||||
String token = header.substring(PREFIX.length()); // "Bearer " 뒤의 실제 토큰만 추출
|
||||
try {
|
||||
// 토큰을 검증/해석해 안에 담긴 사용자 정보(claims)를 꺼낸다.
|
||||
Claims claims = tokenProvider.parse(token);
|
||||
Long userId = claims.get("uid", Long.class);
|
||||
String loginId = claims.getSubject();
|
||||
Long userId = claims.get("uid", Long.class); // 발급 시 넣은 사용자 ID
|
||||
String loginId = claims.getSubject(); // subject = 로그인 아이디
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of());
|
||||
List<String> roles = (List<String>) claims.getOrDefault("roles", List.of()); // 역할 목록
|
||||
|
||||
// 토큰에서 복원한 정보로 인증 주체(LoginUser) 생성.
|
||||
LoginUser user = LoginUser.builder()
|
||||
.userId(userId)
|
||||
.loginId(loginId)
|
||||
.roleCodes(roles)
|
||||
.build();
|
||||
|
||||
// 스프링 시큐리티에 "이 사용자는 인증됨"으로 등록. 이후 컨트롤러/AOP가 이 정보를 참조한다.
|
||||
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);
|
||||
chain.doFilter(request, response); // 다음 필터/컨트롤러로 요청을 넘긴다
|
||||
}
|
||||
|
||||
/** 인증 실패 시 표준 JSON 에러({success,code,message})를 직접 응답에 써준다. */
|
||||
private void writeError(HttpServletResponse response, ErrorCode ec) throws IOException {
|
||||
response.setStatus(ec.getHttpStatus());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
|
||||
@@ -18,23 +18,30 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JWT 발급/검증.
|
||||
* JWT(JSON Web Token)를 발급하고 검증하는 핵심 유틸 컴포넌트.
|
||||
*
|
||||
* <p>왜 필요한가: 로그인 성공 시 여기서 토큰을 만들어 클라이언트에 주고, 이후 요청마다
|
||||
* {@link JwtAuthFilter}가 여기서 토큰을 검증한다. 즉 인증 토큰의 "생성"과 "해석"을 한곳에 모은 것.
|
||||
*
|
||||
* <p>토큰 종류: access(짧은 수명, 실제 API 호출용)와 refresh(긴 수명, access 재발급용) 두 가지.
|
||||
* 서명 비밀키(secret)와 만료시간(ms)은 application 설정(ga.jwt.*)에서 주입받는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long accessExpire;
|
||||
private final long refreshExpire;
|
||||
private final SecretKey key; // HMAC 서명에 쓰는 비밀키(이 키로 서명/검증)
|
||||
private final long accessExpire; // access 토큰 만료시간(ms)
|
||||
private final long refreshExpire; // refresh 토큰 만료시간(ms)
|
||||
|
||||
// 생성자 주입: 설정값(@Value)이 없으면 기본값(access 2시간, refresh 7일) 사용.
|
||||
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비트 미만이면 안전하게 패딩
|
||||
// HMAC-SHA256은 키 길이가 최소 256비트(32바이트)여야 한다. 설정값이 짧으면 0으로 패딩해 길이를 맞춘다.
|
||||
if (raw.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(raw, 0, padded, 0, raw.length);
|
||||
@@ -45,14 +52,17 @@ public class JwtTokenProvider {
|
||||
this.refreshExpire = refreshExpire;
|
||||
}
|
||||
|
||||
/** 로그인 시 발급하는 access 토큰. 사용자 ID/로그인ID/역할을 담는다. */
|
||||
public String createAccessToken(Long userId, String loginId, List<String> roles) {
|
||||
return create(userId, loginId, roles, accessExpire, "ACCESS");
|
||||
}
|
||||
|
||||
/** access 만료 시 재발급에 쓰는 refresh 토큰. 역할은 담지 않고 수명이 길다. */
|
||||
public String createRefreshToken(Long userId, String loginId) {
|
||||
return create(userId, loginId, List.of(), refreshExpire, "REFRESH");
|
||||
}
|
||||
|
||||
// 토큰 생성 공통 로직: subject=로그인ID, claims에 uid/roles/type, 발급/만료 시각을 넣고 비밀키로 서명.
|
||||
private String create(Long userId, String loginId, List<String> roles, long expireMs, String type) {
|
||||
Date now = new Date();
|
||||
return Jwts.builder()
|
||||
@@ -68,17 +78,22 @@ public class JwtTokenProvider {
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰을 비밀키로 검증하고 안의 claims(uid, roles 등)를 꺼낸다.
|
||||
* 만료/위조 시 일반 예외 대신 업무 예외(BizException)로 바꿔 던져, 상위에서 표준 에러응답으로 처리하게 한다.
|
||||
*/
|
||||
public Claims parse(String token) {
|
||||
try {
|
||||
return Jwts.parser().verifyWith(key).build()
|
||||
.parseSignedClaims(token).getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
throw new BizException(ErrorCode.TOKEN_EXPIRED);
|
||||
throw new BizException(ErrorCode.TOKEN_EXPIRED); // 만료된 토큰
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
throw new BizException(ErrorCode.TOKEN_INVALID);
|
||||
throw new BizException(ErrorCode.TOKEN_INVALID); // 서명 불일치/형식 오류 등
|
||||
}
|
||||
}
|
||||
|
||||
/** 예외를 던지지 않고 true/false 로만 유효성을 확인하고 싶을 때 사용하는 편의 메서드. */
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
parse(token);
|
||||
@@ -88,6 +103,7 @@ public class JwtTokenProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 만료시간 조회용 getter. 로그인 응답에 "토큰 만료까지 남은 시간"을 함께 내려줄 때 등에 쓰인다.
|
||||
public long getAccessExpire() { return accessExpire; }
|
||||
public long getRefreshExpire() { return refreshExpire; }
|
||||
}
|
||||
|
||||
@@ -10,25 +10,32 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SecurityContext 에 들어갈 인증 주체.
|
||||
* 로그인한 사용자를 나타내는 인증 주체(principal). 스프링 시큐리티의 SecurityContext에 저장되어,
|
||||
* 어디서든 "현재 누가 요청 중인지"를 꺼낼 수 있게 한다(예: {@code SecurityUtil.getCurrentUserId()}).
|
||||
*
|
||||
* <p>UserDetails 구현: 스프링 시큐리티가 인증 주체로 인식하려면 이 인터페이스를 구현해야 한다.
|
||||
* 이 시스템은 토큰(JWT) 기반이라 비밀번호 비교 등은 쓰지 않으므로, 계정 상태 메서드들은 모두 true를 반환한다.
|
||||
*/
|
||||
@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;
|
||||
private final Long userId; // 내부 사용자 PK
|
||||
private final String loginId; // 로그인 아이디
|
||||
private final String userName; // 사용자 이름(표시용)
|
||||
private final Long agentId; // 소속 설계사/대리점 ID(정산 권한 범위 판단 등에 사용)
|
||||
private final List<String> roleCodes; // 역할 코드 목록(예: ADMIN, MANAGER)
|
||||
|
||||
/** 역할 코드를 스프링 시큐리티 권한(GrantedAuthority)으로 변환. 관례상 "ROLE_" 접두어를 붙인다. */
|
||||
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return roleCodes == null ? List.of() :
|
||||
roleCodes.stream().<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
|
||||
}
|
||||
|
||||
// 비밀번호는 토큰 인증에서 쓰지 않으므로 null. username 은 로그인 아이디를 반환.
|
||||
@Override public String getPassword() { return null; }
|
||||
@Override public String getUsername() { return loginId; }
|
||||
// 아래 4개 계정 상태 플래그: 잠금/만료 검사는 로그인 단계에서 별도로 처리하므로 여기선 모두 정상(true).
|
||||
@Override public boolean isAccountNonExpired() { return true; }
|
||||
@Override public boolean isAccountNonLocked() { return true; }
|
||||
@Override public boolean isCredentialsNonExpired() { return true; }
|
||||
|
||||
Reference in New Issue
Block a user