mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
kos-mock 상품변경 실제 DB 업데이트 기능 추가
- MockDataService에 updateCustomerProduct 메서드 추가 - KosMockService에 실제 고객 데이터 업데이트 로직 추가 - 상품변경 시 고객의 current_product_code를 실제로 업데이트하도록 수정 - 트랜잭션 처리로 데이터 일관성 보장 - product-service Hibernate dialect 설정 추가 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,56 +3,27 @@
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Connection -->
|
||||
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
|
||||
<entry key="DB_HOST" value="20.249.107.185" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
<entry key="DB_NAME" value="product_change_db" />
|
||||
<entry key="DB_USERNAME" value="product_change_user" />
|
||||
<entry key="DB_PASSWORD" value="ProductUser2025!" />
|
||||
<entry key="DB_KIND" value="postgresql" />
|
||||
|
||||
<!-- Redis Connection -->
|
||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||
<entry key="DB_NAME" value="product_change_db" />
|
||||
<entry key="DB_PASSWORD" value="ProductUser2025!" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
<entry key="DB_USERNAME" value="product_change_user" />
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
|
||||
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
|
||||
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
|
||||
<entry key="KOS_API_KEY" value="dev-api-key" />
|
||||
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
|
||||
<entry key="KOS_CLIENT_ID" value="product-service-dev" />
|
||||
<entry key="KOS_MOCK_ENABLED" value="true" />
|
||||
<entry key="REDIS_DATABASE" value="2" />
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="SERVER_PORT" value="8083" />
|
||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||
|
||||
<!-- JWT Configuration -->
|
||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
||||
<entry key="JWT_EXPIRATION" value="3600" />
|
||||
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="JPA_DDL_AUTO" value="update" />
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<entry key="LOG_FILE" value="logs/product-service.log" />
|
||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||
|
||||
<!-- KOS Mock Configuration -->
|
||||
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
|
||||
<entry key="KOS_MOCK_ENABLED" value="true" />
|
||||
<entry key="KOS_API_KEY" value="dev-api-key" />
|
||||
<entry key="KOS_CLIENT_ID" value="product-service-dev" />
|
||||
|
||||
<!-- Product Service Specific Settings -->
|
||||
<entry key="PRODUCT_PROCESSING_ASYNC_ENABLED" value="false" />
|
||||
<entry key="PRODUCT_CACHE_CUSTOMER_INFO_TTL" value="600" />
|
||||
<entry key="PRODUCT_CACHE_PRODUCT_INFO_TTL" value="300" />
|
||||
<entry key="PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL" value="1800" />
|
||||
<entry key="PRODUCT_CACHE_PRODUCT_STATUS_TTL" value="300" />
|
||||
<entry key="PRODUCT_CACHE_LINE_STATUS_TTL" value="180" />
|
||||
<entry key="PRODUCT_VALIDATION_ENABLED" value="true" />
|
||||
<entry key="PRODUCT_VALIDATION_STRICT_MODE" value="false" />
|
||||
<entry key="TEST_DATA_ENABLED" value="true" />
|
||||
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="executionName" />
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Circuit Breaker 패턴 설정
|
||||
*
|
||||
* Resilience4j를 활용한 장애 격리 및 복구 시스템 구성
|
||||
* - KOS 시스템 연동 시 장애 상황에 대한 자동 회복
|
||||
* - 실패율 기반 Circuit Breaker
|
||||
* - 응답 시간 기반 Time Limiter
|
||||
* - 재시도 정책 구성
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class CircuitBreakerConfig {
|
||||
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동용 Circuit Breaker 구성
|
||||
*
|
||||
* @return Circuit Breaker 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public CircuitBreakerRegistry circuitBreakerRegistry() {
|
||||
log.info("Circuit Breaker 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 Circuit Breaker 설정
|
||||
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig kosCircuitBreakerConfig =
|
||||
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
|
||||
// 실패율 임계값 (50%)
|
||||
.failureRateThreshold(kosProperties.getCircuitBreaker().getFailureRateThreshold() * 100)
|
||||
// 느린 호출 임계값 (10초)
|
||||
.slowCallDurationThreshold(Duration.ofMillis(
|
||||
kosProperties.getCircuitBreaker().getSlowCallDurationThreshold()))
|
||||
// 느린 호출 비율 임계값 (50%)
|
||||
.slowCallRateThreshold(kosProperties.getCircuitBreaker().getSlowCallRateThreshold() * 100)
|
||||
// 슬라이딩 윈도우 크기 (10회)
|
||||
.slidingWindowSize(kosProperties.getCircuitBreaker().getSlidingWindowSize())
|
||||
// 슬라이딩 윈도우 타입 (횟수 기반)
|
||||
.slidingWindowType(SlidingWindowType.COUNT_BASED)
|
||||
// 최소 호출 수 (5회)
|
||||
.minimumNumberOfCalls(kosProperties.getCircuitBreaker().getMinimumNumberOfCalls())
|
||||
// Half-Open 상태에서 허용되는 호출 수 (3회)
|
||||
.permittedNumberOfCallsInHalfOpenState(
|
||||
kosProperties.getCircuitBreaker().getPermittedNumberOfCallsInHalfOpenState())
|
||||
// Open 상태 유지 시간 (60초)
|
||||
.waitDurationInOpenState(Duration.ofMillis(
|
||||
kosProperties.getCircuitBreaker().getWaitDurationInOpenState()))
|
||||
// Circuit Breaker 상태 변경 이벤트 리스너
|
||||
.recordExceptions(Exception.class)
|
||||
.ignoreExceptions()
|
||||
.build();
|
||||
|
||||
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(kosCircuitBreakerConfig);
|
||||
|
||||
// KOS Circuit Breaker 등록
|
||||
CircuitBreaker kosCircuitBreaker = registry.circuitBreaker("kos-system", kosCircuitBreakerConfig);
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
kosCircuitBreaker.getEventPublisher()
|
||||
.onStateTransition(event -> {
|
||||
log.warn("Circuit Breaker 상태 변경 - From: {}, To: {}",
|
||||
event.getStateTransition().getFromState(),
|
||||
event.getStateTransition().getToState());
|
||||
})
|
||||
.onCallNotPermitted(event -> {
|
||||
log.error("Circuit Breaker OPEN 상태 - 호출 차단됨: {}", event.getCircuitBreakerName());
|
||||
})
|
||||
.onFailureRateExceeded(event -> {
|
||||
log.error("Circuit Breaker 실패율 초과");
|
||||
});
|
||||
|
||||
log.info("Circuit Breaker 레지스트리 구성 완료 - KOS Circuit Breaker 등록됨");
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재시도 정책 레지스트리 구성
|
||||
*
|
||||
* @return 재시도 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public RetryRegistry retryRegistry() {
|
||||
log.info("Retry 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 재시도 설정
|
||||
io.github.resilience4j.retry.RetryConfig kosRetryConfig =
|
||||
io.github.resilience4j.retry.RetryConfig.custom()
|
||||
// 최대 재시도 횟수
|
||||
.maxAttempts(kosProperties.getMaxRetries())
|
||||
// 재시도 간격
|
||||
.waitDuration(Duration.ofMillis(kosProperties.getRetryDelay()))
|
||||
// 지수 백오프 비활성화 (고정 간격 사용)
|
||||
// .intervalFunction() 대신 waitDuration 사용
|
||||
// 재시도 대상 예외
|
||||
.retryExceptions(Exception.class)
|
||||
// 재시도 제외 예외
|
||||
.ignoreExceptions(IllegalArgumentException.class, SecurityException.class)
|
||||
.build();
|
||||
|
||||
RetryRegistry registry = RetryRegistry.of(kosRetryConfig);
|
||||
|
||||
// KOS Retry 등록
|
||||
Retry kosRetry = registry.retry("kos-system", kosRetryConfig);
|
||||
|
||||
// 재시도 이벤트 리스너
|
||||
kosRetry.getEventPublisher()
|
||||
.onRetry(event -> {
|
||||
log.warn("재시도 실행 - 시도 횟수: {}/{}, 마지막 오류: {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
kosRetryConfig.getMaxAttempts(),
|
||||
event.getLastThrowable().getMessage());
|
||||
})
|
||||
.onError(event -> {
|
||||
log.error("재시도 최종 실패 - 총 시도 횟수: {}, 최종 오류: {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
event.getLastThrowable().getMessage());
|
||||
});
|
||||
|
||||
log.info("Retry 레지스트리 구성 완료 - 최대 재시도: {}회, 간격: {}ms",
|
||||
kosProperties.getMaxRetries(), kosProperties.getRetryDelay());
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time Limiter 레지스트리 구성
|
||||
*
|
||||
* @return Time Limiter 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public TimeLimiterRegistry timeLimiterRegistry() {
|
||||
log.info("Time Limiter 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 타임아웃 설정
|
||||
io.github.resilience4j.timelimiter.TimeLimiterConfig kosTimeLimiterConfig =
|
||||
io.github.resilience4j.timelimiter.TimeLimiterConfig.custom()
|
||||
// 타임아웃 (연결 타임아웃 + 읽기 타임아웃)
|
||||
.timeoutDuration(Duration.ofMillis(kosProperties.getTotalTimeout()))
|
||||
// 타임아웃 시 작업 취소 여부
|
||||
.cancelRunningFuture(true)
|
||||
.build();
|
||||
|
||||
TimeLimiterRegistry registry = TimeLimiterRegistry.of(kosTimeLimiterConfig);
|
||||
|
||||
// KOS Time Limiter 등록
|
||||
TimeLimiter kosTimeLimiter = registry.timeLimiter("kos-system", kosTimeLimiterConfig);
|
||||
|
||||
// 타임아웃 이벤트 리스너
|
||||
kosTimeLimiter.getEventPublisher()
|
||||
.onTimeout(event -> {
|
||||
log.error("Time Limiter 타임아웃 발생 - 설정 시간: {}ms",
|
||||
kosTimeLimiterConfig.getTimeoutDuration().toMillis());
|
||||
});
|
||||
|
||||
log.info("Time Limiter 레지스트리 구성 완료 - 타임아웃: {}ms",
|
||||
kosProperties.getTotalTimeout());
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Circuit Breaker 조회
|
||||
*
|
||||
* @param circuitBreakerRegistry Circuit Breaker 레지스트리
|
||||
* @return KOS Circuit Breaker
|
||||
*/
|
||||
@Bean
|
||||
public CircuitBreaker kosCircuitBreaker(CircuitBreakerRegistry circuitBreakerRegistry) {
|
||||
return circuitBreakerRegistry.circuitBreaker("kos-system");
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Retry 조회
|
||||
*
|
||||
* @param retryRegistry Retry 레지스트리
|
||||
* @return KOS Retry
|
||||
*/
|
||||
@Bean
|
||||
public Retry kosRetry(RetryRegistry retryRegistry) {
|
||||
return retryRegistry.retry("kos-system");
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Time Limiter 조회
|
||||
*
|
||||
* @param timeLimiterRegistry Time Limiter 레지스트리
|
||||
* @return KOS Time Limiter
|
||||
*/
|
||||
@Bean
|
||||
public TimeLimiter kosTimeLimiter(TimeLimiterRegistry timeLimiterRegistry) {
|
||||
return timeLimiterRegistry.timeLimiter("kos-system");
|
||||
}
|
||||
}
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.UnsupportedJwtException;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터
|
||||
*
|
||||
* 주요 기능:
|
||||
* - Authorization 헤더에서 JWT 토큰 추출
|
||||
* - JWT 토큰 검증 및 파싱
|
||||
* - 사용자 인증 정보를 SecurityContext에 설정
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
@Value("${app.jwt.secret:mySecretKey}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${app.jwt.expiration:86400}")
|
||||
private long jwtExpirationInSeconds;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
// JWT 토큰 추출
|
||||
String jwt = resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(jwt) && validateToken(jwt)) {
|
||||
// JWT에서 사용자 정보 추출
|
||||
Authentication authentication = getAuthenticationFromToken(jwt);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 사용자 정보를 헤더에 추가 (다운스트림 서비스에서 활용)
|
||||
addUserInfoToHeaders(request, response, jwt);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.error("JWT 인증 처리 중 오류 발생", ex);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization 헤더에서 JWT 토큰 추출
|
||||
*/
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||
return bearerToken.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 유효성 검증
|
||||
*/
|
||||
private boolean validateToken(String token) {
|
||||
try {
|
||||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
|
||||
return true;
|
||||
} catch (MalformedJwtException e) {
|
||||
logger.error("JWT 토큰이 유효하지 않습니다: {}", e.getMessage());
|
||||
} catch (ExpiredJwtException e) {
|
||||
logger.error("JWT 토큰이 만료되었습니다: {}", e.getMessage());
|
||||
} catch (UnsupportedJwtException e) {
|
||||
logger.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage());
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("JWT 클레임이 비어있습니다: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("JWT 토큰 검증 중 오류 발생: {}", e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 인증 정보 추출
|
||||
*/
|
||||
private Authentication getAuthenticationFromToken(String token) {
|
||||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
String userId = claims.getSubject();
|
||||
String authorities = claims.get("auth", String.class);
|
||||
|
||||
Collection<SimpleGrantedAuthority> grantedAuthorities =
|
||||
StringUtils.hasText(authorities) ?
|
||||
Arrays.stream(authorities.split(","))
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList()) :
|
||||
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
|
||||
return new UsernamePasswordAuthenticationToken(userId, "", grantedAuthorities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보를 응답 헤더에 추가
|
||||
*/
|
||||
private void addUserInfoToHeaders(HttpServletRequest request, HttpServletResponse response, String token) {
|
||||
try {
|
||||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
// 사용자 ID 헤더 추가
|
||||
String userId = claims.getSubject();
|
||||
if (StringUtils.hasText(userId)) {
|
||||
response.setHeader("X-User-ID", userId);
|
||||
}
|
||||
|
||||
// 고객 ID 헤더 추가 (있는 경우)
|
||||
String customerId = claims.get("customerId", String.class);
|
||||
if (StringUtils.hasText(customerId)) {
|
||||
response.setHeader("X-Customer-ID", customerId);
|
||||
}
|
||||
|
||||
// 요청 ID 헤더 추가 (추적용)
|
||||
String requestId = request.getHeader("X-Request-ID");
|
||||
if (StringUtils.hasText(requestId)) {
|
||||
response.setHeader("X-Request-ID", requestId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("사용자 정보 헤더 추가 중 오류 발생: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 적용 제외 경로 설정
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// Health Check 및 문서화 API는 필터 제외
|
||||
return path.startsWith("/actuator/") ||
|
||||
path.startsWith("/v3/api-docs") ||
|
||||
path.startsWith("/swagger-ui");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import com.phonebill.common.security.JwtTokenProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JWT 설정 프로퍼티
|
||||
*/
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "jwt")
|
||||
@Getter
|
||||
@Setter
|
||||
public class JwtConfig {
|
||||
|
||||
private String secret;
|
||||
private long accessTokenValidity = 1800000; // 30분 (milliseconds)
|
||||
private long refreshTokenValidity = 86400000; // 24시간 (milliseconds)
|
||||
private String issuer = "phonebill-auth-service";
|
||||
|
||||
/**
|
||||
* Access Token 만료 시간 (초 단위)
|
||||
*/
|
||||
public int getAccessTokenValidityInSeconds() {
|
||||
return (int) (accessTokenValidity / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 만료 시간 (초 단위)
|
||||
*/
|
||||
public int getRefreshTokenValidityInSeconds() {
|
||||
return (int) (refreshTokenValidity / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* JwtTokenProvider 빈 정의
|
||||
*/
|
||||
@Bean
|
||||
public JwtTokenProvider jwtTokenProvider(
|
||||
@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.access-token-validity}") long tokenValidityInMilliseconds) {
|
||||
long tokenValidityInSeconds = tokenValidityInMilliseconds / 1000;
|
||||
return new JwtTokenProvider(secret, tokenValidityInSeconds);
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동 설정 프로퍼티
|
||||
*
|
||||
* application.yml 파일의 kos 설정을 바인딩하는 설정 클래스
|
||||
* - 연결 정보 (URL, 타임아웃 등)
|
||||
* - 재시도 정책
|
||||
* - Circuit Breaker 설정
|
||||
* - 인증 관련 설정
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-09
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "kos")
|
||||
@Getter
|
||||
@Setter
|
||||
@Validated
|
||||
public class KosProperties {
|
||||
|
||||
/**
|
||||
* KOS 시스템 기본 URL
|
||||
*/
|
||||
@NotBlank(message = "KOS 기본 URL은 필수입니다")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 연결 타임아웃 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer connectTimeout = 5000;
|
||||
|
||||
/**
|
||||
* 읽기 타임아웃 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer readTimeout = 30000;
|
||||
|
||||
/**
|
||||
* 최대 재시도 횟수
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer maxRetries = 3;
|
||||
|
||||
/**
|
||||
* 재시도 간격 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Long retryDelay = 1000L;
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정
|
||||
*/
|
||||
private CircuitBreaker circuitBreaker = new CircuitBreaker();
|
||||
|
||||
/**
|
||||
* 인증 설정
|
||||
*/
|
||||
private Authentication authentication = new Authentication();
|
||||
|
||||
/**
|
||||
* 모니터링 설정
|
||||
*/
|
||||
private Monitoring monitoring = new Monitoring();
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CircuitBreaker {
|
||||
|
||||
/**
|
||||
* 실패율 임계값 (0.0 ~ 1.0)
|
||||
*/
|
||||
private Float failureRateThreshold = 0.5f;
|
||||
|
||||
/**
|
||||
* 느린 호출 임계값 (밀리초)
|
||||
*/
|
||||
private Long slowCallDurationThreshold = 10000L;
|
||||
|
||||
/**
|
||||
* 느린 호출 비율 임계값 (0.0 ~ 1.0)
|
||||
*/
|
||||
private Float slowCallRateThreshold = 0.5f;
|
||||
|
||||
/**
|
||||
* 슬라이딩 윈도우 크기
|
||||
*/
|
||||
private Integer slidingWindowSize = 10;
|
||||
|
||||
/**
|
||||
* 최소 호출 수
|
||||
*/
|
||||
private Integer minimumNumberOfCalls = 5;
|
||||
|
||||
/**
|
||||
* Half-Open 상태에서 허용되는 호출 수
|
||||
*/
|
||||
private Integer permittedNumberOfCallsInHalfOpenState = 3;
|
||||
|
||||
/**
|
||||
* Open 상태 유지 시간 (밀리초)
|
||||
*/
|
||||
private Long waitDurationInOpenState = 60000L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Authentication {
|
||||
|
||||
/**
|
||||
* 인증 토큰 사용 여부
|
||||
*/
|
||||
private Boolean enabled = true;
|
||||
|
||||
/**
|
||||
* API 키
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 시크릿 키
|
||||
*/
|
||||
private String secretKey;
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간 (초)
|
||||
*/
|
||||
private Long tokenExpirationSeconds = 3600L;
|
||||
|
||||
/**
|
||||
* 토큰 갱신 임계 시간 (초)
|
||||
*/
|
||||
private Long tokenRefreshThresholdSeconds = 300L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모니터링 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Monitoring {
|
||||
|
||||
/**
|
||||
* 성능 로깅 사용 여부
|
||||
*/
|
||||
private Boolean performanceLoggingEnabled = true;
|
||||
|
||||
/**
|
||||
* 느린 요청 임계값 (밀리초)
|
||||
*/
|
||||
private Long slowRequestThreshold = 3000L;
|
||||
|
||||
/**
|
||||
* 메트릭 수집 사용 여부
|
||||
*/
|
||||
private Boolean metricsEnabled = true;
|
||||
|
||||
/**
|
||||
* 상태 체크 주기 (밀리초)
|
||||
*/
|
||||
private Long healthCheckInterval = 30000L;
|
||||
}
|
||||
|
||||
// === Computed Properties ===
|
||||
|
||||
/**
|
||||
* 상품목록 API URL 조회
|
||||
*
|
||||
* @return 상품목록 API 전체 URL
|
||||
*/
|
||||
public String getProductListUrl() {
|
||||
return baseUrl + "/api/v1/kos/product/list";
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입상품 조회 API URL 조회
|
||||
*
|
||||
* @return 가입상품 조회 API 전체 URL
|
||||
*/
|
||||
public String getProductInquiryUrl() {
|
||||
return baseUrl + "/api/v1/kos/product/inquiry";
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 API URL 조회
|
||||
*
|
||||
* @return 상품변경 API 전체 URL
|
||||
*/
|
||||
public String getProductChangeUrl() {
|
||||
return baseUrl + "/api/v1/kos/product/change";
|
||||
}
|
||||
|
||||
/**
|
||||
* 헬스체크 API URL 조회
|
||||
*
|
||||
* @return 헬스체크 API 전체 URL
|
||||
*/
|
||||
public String getHealthCheckUrl() {
|
||||
return baseUrl + "/health";
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 타임아웃 계산 (연결 + 읽기)
|
||||
*
|
||||
* @return 전체 타임아웃 (밀리초)
|
||||
*/
|
||||
public Integer getTotalTimeout() {
|
||||
return connectTimeout + readTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대 재시도 시간 계산
|
||||
*
|
||||
* @return 최대 재시도 시간 (밀리초)
|
||||
*/
|
||||
public Long getMaxRetryDuration() {
|
||||
return retryDelay * maxRetries;
|
||||
}
|
||||
|
||||
// === Validation Methods ===
|
||||
|
||||
/**
|
||||
* 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return baseUrl != null && !baseUrl.trim().isEmpty() &&
|
||||
connectTimeout > 0 && readTimeout > 0 &&
|
||||
maxRetries > 0 && retryDelay > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isCircuitBreakerConfigValid() {
|
||||
return circuitBreaker.failureRateThreshold >= 0.0f && circuitBreaker.failureRateThreshold <= 1.0f &&
|
||||
circuitBreaker.slowCallRateThreshold >= 0.0f && circuitBreaker.slowCallRateThreshold <= 1.0f &&
|
||||
circuitBreaker.slidingWindowSize > 0 &&
|
||||
circuitBreaker.minimumNumberOfCalls > 0 &&
|
||||
circuitBreaker.permittedNumberOfCallsInHalfOpenState > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isAuthenticationConfigValid() {
|
||||
if (!authentication.enabled) {
|
||||
return true;
|
||||
}
|
||||
return authentication.apiKey != null && !authentication.apiKey.trim().isEmpty() &&
|
||||
authentication.secretKey != null && !authentication.secretKey.trim().isEmpty();
|
||||
}
|
||||
|
||||
// === Utility Methods ===
|
||||
|
||||
/**
|
||||
* 설정 정보 요약
|
||||
*
|
||||
* @return 설정 요약 문자열
|
||||
*/
|
||||
public String getConfigSummary() {
|
||||
return String.format(
|
||||
"KOS 설정 - URL: %s, 연결타임아웃: %dms, 읽기타임아웃: %dms, 재시도: %d회",
|
||||
baseUrl, connectTimeout, readTimeout, maxRetries
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스킹된 인증 정보 조회 (로깅용)
|
||||
*
|
||||
* @return 마스킹된 인증 정보
|
||||
*/
|
||||
public String getMaskedAuthInfo() {
|
||||
if (!authentication.enabled || authentication.apiKey == null) {
|
||||
return "인증 비활성화";
|
||||
}
|
||||
|
||||
String maskedApiKey = authentication.apiKey.length() > 8 ?
|
||||
authentication.apiKey.substring(0, 4) + "****" +
|
||||
authentication.apiKey.substring(authentication.apiKey.length() - 4) :
|
||||
"****";
|
||||
|
||||
return String.format("API키: %s, 토큰만료: %d초", maskedApiKey, authentication.tokenExpirationSeconds);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* RestTemplate 설정 클래스
|
||||
*
|
||||
* KOS 시스템 연동을 위한 HTTP 클라이언트 구성
|
||||
* - Connection Pool 설정
|
||||
* - Timeout 설정
|
||||
* - 재시도 및 회로 차단기와 연동
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class RestTemplateConfig {
|
||||
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 연동용 RestTemplate 빈 생성
|
||||
*
|
||||
* @param builder RestTemplate 빌더
|
||||
* @return 설정된 RestTemplate
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||
log.info("RestTemplate 빈 생성 - 연결 타임아웃: {}ms, 읽기 타임아웃: {}ms",
|
||||
kosProperties.getConnectTimeout(), kosProperties.getReadTimeout());
|
||||
|
||||
return builder
|
||||
// 타임아웃 설정
|
||||
.setConnectTimeout(Duration.ofMillis(kosProperties.getConnectTimeout()))
|
||||
.setReadTimeout(Duration.ofMillis(kosProperties.getReadTimeout()))
|
||||
|
||||
// RestTemplate 생성
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+80
-104
@@ -1,11 +1,14 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import com.phonebill.common.security.JwtAuthenticationFilter;
|
||||
import com.phonebill.common.security.JwtTokenProvider;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
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;
|
||||
@@ -16,133 +19,106 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - JWT 인증 필터 설정
|
||||
* - CORS 설정
|
||||
* - API 엔드포인트 보안 설정
|
||||
* - 세션 비활성화 (Stateless)
|
||||
* Spring Security 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
@Value("${cors.allowed-origins")
|
||||
private String allowedOrigins;
|
||||
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
JwtAccessDeniedHandler jwtAccessDeniedHandler,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security Filter Chain 설정
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF 비활성화 (JWT 사용으로 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
// CSRF 비활성화 (JWT 사용으로 불필요)
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
// CORS 설정
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
||||
// 세션 비활성화 (JWT 기반 Stateless)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 권한 설정
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
// Public endpoints (인증 불필요)
|
||||
.requestMatchers(
|
||||
"/actuator/health",
|
||||
"/actuator/info",
|
||||
"/actuator/prometheus",
|
||||
"/v3/api-docs/**",
|
||||
"/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/swagger-resources/**",
|
||||
"/webjars/**"
|
||||
).permitAll()
|
||||
|
||||
// CORS 설정
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
// OPTIONS 요청은 모두 허용 (CORS Preflight)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
|
||||
// 세션 비활성화 (Stateless)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// Protected endpoints (인증 필요)
|
||||
.requestMatchers("/products/**").authenticated()
|
||||
|
||||
// 예외 처리 설정
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
|
||||
.accessDeniedHandler(jwtAccessDeniedHandler))
|
||||
// Actuator endpoints (관리용)
|
||||
.requestMatchers("/actuator/**").hasRole("ADMIN")
|
||||
|
||||
// 권한 설정
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
// Health Check 및 문서화 API는 인증 불필요
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.requestMatchers("/v3/api-docs/**", "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
||||
|
||||
// OPTIONS 요청은 인증 불필요 (CORS Preflight)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
|
||||
// 모든 API는 인증 필요
|
||||
.requestMatchers("/products/**").authenticated()
|
||||
|
||||
// 나머지 요청은 모두 인증 필요
|
||||
.anyRequest().authenticated())
|
||||
|
||||
// JWT 인증 필터 추가
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
// 나머지 모든 요청 인증 필요
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
// JWT 필터 추가
|
||||
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
|
||||
// Exception 처리
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
response.setStatus(401);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
|
||||
})
|
||||
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||
response.setStatus(403);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
|
||||
})
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
*/
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(jwtTokenProvider);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// 허용할 Origin 설정
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(
|
||||
"http://localhost:3000", // 개발환경 프론트엔드
|
||||
"http://localhost:8080", // API Gateway
|
||||
"https://*.mvno.com", // 운영환경
|
||||
"https://*.mvno-dev.com" // 개발환경
|
||||
));
|
||||
|
||||
// 허용할 HTTP 메서드
|
||||
configuration.setAllowedMethods(Arrays.asList(
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
|
||||
));
|
||||
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"Access-Control-Request-Method",
|
||||
"Access-Control-Request-Headers",
|
||||
"X-User-ID",
|
||||
"X-Customer-ID",
|
||||
"X-Request-ID"
|
||||
));
|
||||
|
||||
// 노출할 헤더
|
||||
configuration.setExposedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"X-Request-ID",
|
||||
"X-Total-Count"
|
||||
));
|
||||
|
||||
// 자격 증명 허용
|
||||
|
||||
// 환경변수에서 허용할 Origin 패턴 설정
|
||||
String[] origins = allowedOrigins.split(",");
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Preflight 요청 캐시 시간 설정 (1시간)
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화기
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -39,9 +39,9 @@ public class SwaggerConfig {
|
||||
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||
._default("8083")
|
||||
.description("Server port"))))
|
||||
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
|
||||
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
|
||||
.addSecuritySchemes("bearerAuth", createAPIKeyScheme()));
|
||||
}
|
||||
|
||||
private Info apiInfo() {
|
||||
|
||||
+16
-89
@@ -29,7 +29,6 @@ import java.time.LocalDate;
|
||||
* 상품변경 서비스 REST API 컨트롤러
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 메뉴 조회 (UFR-PROD-010)
|
||||
* - 고객 및 상품 정보 조회 (UFR-PROD-020)
|
||||
* - 상품변경 요청 및 사전체크 (UFR-PROD-030)
|
||||
* - KOS 연동 상품변경 처리 (UFR-PROD-040)
|
||||
@@ -50,41 +49,12 @@ public class ProductController {
|
||||
this.productService = productService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 메뉴 조회
|
||||
* UFR-PROD-010 구현
|
||||
*/
|
||||
@GetMapping("/menu")
|
||||
@Operation(summary = "상품변경 메뉴 조회",
|
||||
description = "상품변경 메뉴 접근 시 필요한 기본 정보를 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "메뉴 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = ProductMenuResponse.class))),
|
||||
@ApiResponse(responseCode = "401", description = "인증 실패",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "403", description = "권한 없음",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<ProductMenuResponse> getProductMenu() {
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 메뉴 조회 요청: userId={}", userId);
|
||||
|
||||
try {
|
||||
ProductMenuResponse response = productService.getProductMenu(userId);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 메뉴 조회 실패: userId={}", userId, e);
|
||||
throw new RuntimeException("메뉴 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 정보 조회
|
||||
* UFR-PROD-020 구현
|
||||
*/
|
||||
@GetMapping("/customer/{lineNumber}")
|
||||
@GetMapping("/customer")
|
||||
@Operation(summary = "고객 정보 조회",
|
||||
description = "특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다")
|
||||
@ApiResponses({
|
||||
@@ -99,7 +69,7 @@ public class ProductController {
|
||||
})
|
||||
public ResponseEntity<CustomerInfoResponse> getCustomerInfo(
|
||||
@Parameter(description = "고객 회선번호", example = "01012345678")
|
||||
@PathVariable
|
||||
@RequestParam("lineNumber")
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
String lineNumber) {
|
||||
|
||||
@@ -130,20 +100,18 @@ public class ProductController {
|
||||
})
|
||||
public ResponseEntity<AvailableProductsResponse> getAvailableProducts(
|
||||
@Parameter(description = "현재 상품코드 (필터링용)")
|
||||
@RequestParam(required = false) String currentProductCode,
|
||||
@Parameter(description = "사업자 코드")
|
||||
@RequestParam(required = false) String operatorCode) {
|
||||
@RequestParam(required = false) String currentProductCode) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, operatorCode={}, userId={}",
|
||||
currentProductCode, operatorCode, userId);
|
||||
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, userId={}",
|
||||
currentProductCode, userId);
|
||||
|
||||
try {
|
||||
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode, operatorCode);
|
||||
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, operatorCode={}, userId={}",
|
||||
currentProductCode, operatorCode, userId, e);
|
||||
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, userId={}",
|
||||
currentProductCode, userId, e);
|
||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
@@ -186,12 +154,10 @@ public class ProductController {
|
||||
*/
|
||||
@PostMapping("/change")
|
||||
@Operation(summary = "상품변경 요청",
|
||||
description = "실제 상품변경 처리를 요청합니다")
|
||||
description = "실제 상품변경 처리를 요청합니다 (동기 처리)")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "상품변경 처리 완료",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeResponse.class))),
|
||||
@ApiResponse(responseCode = "202", description = "상품변경 요청 접수 (비동기 처리)",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeAsyncResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "409", description = "사전체크 실패 또는 처리 불가 상태",
|
||||
@@ -201,63 +167,24 @@ public class ProductController {
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<?> requestProductChange(
|
||||
@Valid @RequestBody ProductChangeRequest request,
|
||||
@Parameter(description = "처리 모드 (sync: 동기, async: 비동기)")
|
||||
@RequestParam(defaultValue = "sync") String mode) {
|
||||
public ResponseEntity<ProductChangeResponse> requestProductChange(
|
||||
@Valid @RequestBody ProductChangeRequest request) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, mode={}, userId={}",
|
||||
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, userId={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), mode, userId);
|
||||
request.getTargetProductCode(), userId);
|
||||
|
||||
try {
|
||||
if ("async".equalsIgnoreCase(mode)) {
|
||||
// 비동기 처리
|
||||
ProductChangeAsyncResponse response = productService.requestProductChangeAsync(request, userId);
|
||||
return ResponseEntity.accepted().body(response);
|
||||
} else {
|
||||
// 동기 처리 (기본값)
|
||||
ProductChangeResponse response = productService.requestProductChange(request, userId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
// 동기 처리
|
||||
ProductChangeResponse response = productService.requestProductChange(request, userId);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 요청 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
|
||||
throw new RuntimeException("상품변경 처리 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 결과 조회
|
||||
*/
|
||||
@GetMapping("/change/{requestId}")
|
||||
@Operation(summary = "상품변경 결과 조회",
|
||||
description = "특정 요청ID의 상품변경 처리 결과를 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "처리 결과 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeResultResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "404", description = "요청 정보를 찾을 수 없음",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<ProductChangeResultResponse> getProductChangeResult(
|
||||
@Parameter(description = "상품변경 요청 ID")
|
||||
@PathVariable String requestId) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 결과 조회 요청: requestId={}, userId={}", requestId, userId);
|
||||
|
||||
try {
|
||||
ProductChangeResultResponse response = productService.getProductChangeResult(requestId);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 결과 조회 실패: requestId={}, userId={}", requestId, userId, e);
|
||||
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 이력 조회
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -10,6 +13,7 @@ import java.math.BigDecimal;
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class Product {
|
||||
|
||||
private final String productCode;
|
||||
@@ -22,6 +26,30 @@ public class Product {
|
||||
private final String operatorCode;
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* Jackson 역직렬화를 위한 생성자
|
||||
*/
|
||||
@JsonCreator
|
||||
public Product(@JsonProperty("productCode") String productCode,
|
||||
@JsonProperty("productName") String productName,
|
||||
@JsonProperty("monthlyFee") BigDecimal monthlyFee,
|
||||
@JsonProperty("dataAllowance") String dataAllowance,
|
||||
@JsonProperty("voiceAllowance") String voiceAllowance,
|
||||
@JsonProperty("smsAllowance") String smsAllowance,
|
||||
@JsonProperty("status") ProductStatus status,
|
||||
@JsonProperty("operatorCode") String operatorCode,
|
||||
@JsonProperty("description") String description) {
|
||||
this.productCode = productCode;
|
||||
this.productName = productName;
|
||||
this.monthlyFee = monthlyFee;
|
||||
this.dataAllowance = dataAllowance;
|
||||
this.voiceAllowance = voiceAllowance;
|
||||
this.smsAllowance = smsAllowance;
|
||||
this.status = status;
|
||||
this.operatorCode = operatorCode;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 상품으로 변경 가능한지 확인
|
||||
*/
|
||||
|
||||
+149
-19
@@ -1,25 +1,167 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 상품변경 처리 결과 도메인 모델
|
||||
* Updated for compilation fix
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class ProductChangeResult {
|
||||
|
||||
private final String requestId;
|
||||
private final boolean success;
|
||||
private final String resultCode;
|
||||
private final String resultMessage;
|
||||
private final String failureReason;
|
||||
private final String kosOrderNumber;
|
||||
private final String effectiveDate;
|
||||
private final Product changedProduct;
|
||||
private final LocalDateTime processedAt;
|
||||
private final Map<String, Object> additionalData;
|
||||
private final Map<String, Object> kosResponseData;
|
||||
|
||||
// Builder 패턴용 생성자
|
||||
private ProductChangeResult(Builder builder) {
|
||||
this.requestId = builder.requestId;
|
||||
this.success = builder.success;
|
||||
this.resultCode = builder.resultCode;
|
||||
this.resultMessage = builder.resultMessage;
|
||||
this.failureReason = builder.failureReason;
|
||||
this.kosOrderNumber = builder.kosOrderNumber;
|
||||
this.effectiveDate = builder.effectiveDate;
|
||||
this.changedProduct = builder.changedProduct;
|
||||
this.processedAt = builder.processedAt;
|
||||
this.additionalData = builder.additionalData;
|
||||
this.kosResponseData = builder.kosResponseData;
|
||||
}
|
||||
|
||||
// Getter 메서드들
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public String getResultCode() {
|
||||
return resultCode;
|
||||
}
|
||||
|
||||
public String getResultMessage() {
|
||||
return resultMessage;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return failureReason;
|
||||
}
|
||||
|
||||
public String getKosOrderNumber() {
|
||||
return kosOrderNumber;
|
||||
}
|
||||
|
||||
public String getEffectiveDate() {
|
||||
return effectiveDate;
|
||||
}
|
||||
|
||||
public Product getChangedProduct() {
|
||||
return changedProduct;
|
||||
}
|
||||
|
||||
public LocalDateTime getProcessedAt() {
|
||||
return processedAt;
|
||||
}
|
||||
|
||||
public Map<String, Object> getAdditionalData() {
|
||||
return additionalData;
|
||||
}
|
||||
|
||||
public Map<String, Object> getKosResponseData() {
|
||||
return kosResponseData;
|
||||
}
|
||||
|
||||
public boolean isFailure() {
|
||||
return !success;
|
||||
}
|
||||
|
||||
// Builder 패턴
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private String requestId;
|
||||
private boolean success;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private String failureReason;
|
||||
private String kosOrderNumber;
|
||||
private String effectiveDate;
|
||||
private Product changedProduct;
|
||||
private LocalDateTime processedAt;
|
||||
private Map<String, Object> additionalData;
|
||||
private Map<String, Object> kosResponseData;
|
||||
|
||||
public Builder requestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder success(boolean success) {
|
||||
this.success = success;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder resultCode(String resultCode) {
|
||||
this.resultCode = resultCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder resultMessage(String resultMessage) {
|
||||
this.resultMessage = resultMessage;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder failureReason(String failureReason) {
|
||||
this.failureReason = failureReason;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder kosOrderNumber(String kosOrderNumber) {
|
||||
this.kosOrderNumber = kosOrderNumber;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder effectiveDate(String effectiveDate) {
|
||||
this.effectiveDate = effectiveDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder changedProduct(Product changedProduct) {
|
||||
this.changedProduct = changedProduct;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder processedAt(LocalDateTime processedAt) {
|
||||
this.processedAt = processedAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder additionalData(Map<String, Object> additionalData) {
|
||||
this.additionalData = additionalData;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder kosResponseData(Map<String, Object> kosResponseData) {
|
||||
this.kosResponseData = kosResponseData;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProductChangeResult build() {
|
||||
return new ProductChangeResult(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 결과 생성 (팩토리 메소드)
|
||||
@@ -52,6 +194,7 @@ public class ProductChangeResult {
|
||||
.success(false)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.failureReason(resultMessage)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
@@ -70,22 +213,9 @@ public class ProductChangeResult {
|
||||
.success(false)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.failureReason(resultMessage)
|
||||
.additionalData(additionalData)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과가 성공인지 확인
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과가 실패인지 확인
|
||||
*/
|
||||
public boolean isFailure() {
|
||||
return !success;
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -1,6 +1,6 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -8,8 +8,6 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 상품변경 요청 DTO
|
||||
@@ -21,19 +19,16 @@ import java.time.LocalDateTime;
|
||||
@AllArgsConstructor
|
||||
public class ProductChangeRequest {
|
||||
|
||||
@JsonProperty("lineNumber")
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
private String lineNumber;
|
||||
|
||||
@JsonProperty("currentProductCode")
|
||||
@NotBlank(message = "현재 상품 코드는 필수입니다")
|
||||
private String currentProductCode;
|
||||
|
||||
@JsonProperty("targetProductCode")
|
||||
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
|
||||
private String targetProductCode;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime requestDate;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate changeEffectiveDate;
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 상품변경 메뉴 조회 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@Schema(description = "상품변경 메뉴 조회 응답")
|
||||
public class ProductMenuResponse {
|
||||
|
||||
@Schema(description = "응답 성공 여부", example = "true")
|
||||
private final boolean success;
|
||||
|
||||
@Schema(description = "메뉴 데이터")
|
||||
private final MenuData data;
|
||||
|
||||
@Schema(description = "응답 시간")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private final LocalDateTime timestamp;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@Schema(description = "메뉴 데이터")
|
||||
public static class MenuData {
|
||||
|
||||
@Schema(description = "고객 ID", example = "CUST001")
|
||||
private final String customerId;
|
||||
|
||||
@Schema(description = "회선번호", example = "01012345678")
|
||||
private final String lineNumber;
|
||||
|
||||
@Schema(description = "현재 상품 정보")
|
||||
private final ProductInfoDto currentProduct;
|
||||
|
||||
@Schema(description = "메뉴 항목 목록")
|
||||
private final List<MenuItem> menuItems;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@Schema(description = "메뉴 항목")
|
||||
public static class MenuItem {
|
||||
|
||||
@Schema(description = "메뉴 ID", example = "MENU001")
|
||||
private final String menuId;
|
||||
|
||||
@Schema(description = "메뉴명", example = "상품변경")
|
||||
private final String menuName;
|
||||
|
||||
@Schema(description = "사용 가능 여부", example = "true")
|
||||
private final boolean available;
|
||||
|
||||
@Schema(description = "메뉴 설명", example = "현재 이용 중인 상품을 다른 상품으로 변경합니다")
|
||||
private final String description;
|
||||
}
|
||||
|
||||
public static ProductMenuResponse success(MenuData data) {
|
||||
return ProductMenuResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.unicorn.phonebill.product.dto.kos;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* KOS 공통 응답 DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "KOS 공통 응답")
|
||||
public class KosCommonResponse<T> {
|
||||
|
||||
@Schema(description = "성공 여부", example = "true")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "처리 결과 코드", example = "0000")
|
||||
private String resultCode;
|
||||
|
||||
@Schema(description = "처리 결과 메시지", example = "정상 처리되었습니다")
|
||||
private String resultMessage;
|
||||
|
||||
@Schema(description = "응답 데이터")
|
||||
private T data;
|
||||
|
||||
@Schema(description = "처리 시간", example = "2025-01-08T14:30:00")
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
@Schema(description = "요청 추적 ID", example = "TRACE_20250108_001")
|
||||
private String traceId;
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static <T> KosCommonResponse<T> success(T data) {
|
||||
return KosCommonResponse.<T>builder()
|
||||
.success(true)
|
||||
.resultCode("0000")
|
||||
.resultMessage("정상 처리되었습니다")
|
||||
.data(data)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (메시지 포함)
|
||||
*/
|
||||
public static <T> KosCommonResponse<T> success(T data, String message) {
|
||||
return KosCommonResponse.<T>builder()
|
||||
.success(true)
|
||||
.resultCode("0000")
|
||||
.resultMessage(message)
|
||||
.data(data)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성
|
||||
*/
|
||||
public static <T> KosCommonResponse<T> failure(String errorCode, String errorMessage) {
|
||||
return KosCommonResponse.<T>builder()
|
||||
.success(false)
|
||||
.resultCode(errorCode)
|
||||
.resultMessage(errorMessage)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 오류 응답 생성
|
||||
*/
|
||||
public static <T> KosCommonResponse<T> systemError() {
|
||||
return KosCommonResponse.<T>builder()
|
||||
.success(false)
|
||||
.resultCode("9999")
|
||||
.resultMessage("시스템 오류가 발생했습니다")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.unicorn.phonebill.product.dto.kos;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* KOS 상품 정보 DTO
|
||||
* kos-mock 서비스의 KosProductInfo와 동일한 구조
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KosProductInfo {
|
||||
|
||||
@JsonProperty("product_code")
|
||||
private String productCode;
|
||||
|
||||
@JsonProperty("product_name")
|
||||
private String productName;
|
||||
|
||||
@JsonProperty("product_type")
|
||||
private String productType;
|
||||
|
||||
@JsonProperty("monthly_fee")
|
||||
private Integer monthlyFee;
|
||||
|
||||
@JsonProperty("data_allowance")
|
||||
private Integer dataAllowance;
|
||||
|
||||
@JsonProperty("voice_allowance")
|
||||
private Integer voiceAllowance;
|
||||
|
||||
@JsonProperty("sms_allowance")
|
||||
private Integer smsAllowance;
|
||||
|
||||
@JsonProperty("network_type")
|
||||
private String networkType;
|
||||
|
||||
@JsonProperty("status")
|
||||
private String status;
|
||||
|
||||
@JsonProperty("description")
|
||||
private String description;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.unicorn.phonebill.product.dto.kos;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* KOS 가입상품 조회 요청 DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "KOS 가입상품 조회 요청")
|
||||
public class KosProductInquiryRequest {
|
||||
|
||||
@Schema(description = "회선번호", example = "01012345679")
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
||||
@JsonProperty("lineNumber")
|
||||
private String lineNumber;
|
||||
|
||||
@Schema(description = "요청 ID", example = "REQ_20250108_001")
|
||||
@NotBlank(message = "요청 ID는 필수입니다")
|
||||
@JsonProperty("requestId")
|
||||
private String requestId;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.unicorn.phonebill.product.dto.kos;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* KOS 가입상품 조회 응답 DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "KOS 가입상품 조회 응답")
|
||||
public class KosProductInquiryResponse {
|
||||
|
||||
@Schema(description = "요청 ID", example = "REQ_20250108_001")
|
||||
@JsonProperty("requestId")
|
||||
private String requestId;
|
||||
|
||||
@Schema(description = "처리 상태", example = "SUCCESS")
|
||||
@JsonProperty("procStatus")
|
||||
private String procStatus;
|
||||
|
||||
@Schema(description = "결과 코드", example = "0000")
|
||||
@JsonProperty("resultCode")
|
||||
private String resultCode;
|
||||
|
||||
@Schema(description = "결과 메시지", example = "정상 처리되었습니다")
|
||||
@JsonProperty("resultMessage")
|
||||
private String resultMessage;
|
||||
|
||||
@Schema(description = "상품 정보")
|
||||
@JsonProperty("productInfo")
|
||||
private ProductInfo productInfo;
|
||||
|
||||
@Schema(description = "고객 정보")
|
||||
@JsonProperty("customerInfo")
|
||||
private CustomerInfo customerInfo;
|
||||
|
||||
/**
|
||||
* 상품 정보
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "상품 정보")
|
||||
public static class ProductInfo {
|
||||
|
||||
@Schema(description = "회선번호", example = "01012345679")
|
||||
@JsonProperty("lineNumber")
|
||||
private String lineNumber;
|
||||
|
||||
@Schema(description = "현재 상품 코드", example = "KT_5G_BASIC")
|
||||
@JsonProperty("currentProductCode")
|
||||
private String currentProductCode;
|
||||
|
||||
@Schema(description = "현재 상품명", example = "KT 5G 베이직")
|
||||
@JsonProperty("currentProductName")
|
||||
private String currentProductName;
|
||||
|
||||
@Schema(description = "월 요금", example = "45000")
|
||||
@JsonProperty("monthlyFee")
|
||||
private BigDecimal monthlyFee;
|
||||
|
||||
@Schema(description = "데이터 허용량", example = "무제한")
|
||||
@JsonProperty("dataAllowance")
|
||||
private String dataAllowance;
|
||||
|
||||
@Schema(description = "음성 허용량", example = "무제한")
|
||||
@JsonProperty("voiceAllowance")
|
||||
private String voiceAllowance;
|
||||
|
||||
@Schema(description = "SMS 허용량", example = "무제한")
|
||||
@JsonProperty("smsAllowance")
|
||||
private String smsAllowance;
|
||||
|
||||
@Schema(description = "상품 상태", example = "ACTIVE")
|
||||
@JsonProperty("productStatus")
|
||||
private String productStatus;
|
||||
|
||||
@Schema(description = "계약일")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
@JsonProperty("contractDate")
|
||||
private LocalDateTime contractDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 정보
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "고객 정보")
|
||||
public static class CustomerInfo {
|
||||
|
||||
@Schema(description = "고객명", example = "홍길동")
|
||||
@JsonProperty("customerName")
|
||||
private String customerName;
|
||||
|
||||
@Schema(description = "고객 ID", example = "CUST_001")
|
||||
@JsonProperty("customerId")
|
||||
private String customerId;
|
||||
|
||||
@Schema(description = "통신사 코드", example = "KT")
|
||||
@JsonProperty("operatorCode")
|
||||
private String operatorCode;
|
||||
|
||||
@Schema(description = "회선 상태", example = "ACTIVE")
|
||||
@JsonProperty("lineStatus")
|
||||
private String lineStatus;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.unicorn.phonebill.product.dto.kos;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* KOS 상품 목록 응답 DTO
|
||||
* kos-mock 서비스의 KosProductListResponse와 동일한 구조
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KosProductListResponse {
|
||||
|
||||
@JsonProperty("total_count")
|
||||
private Integer totalCount;
|
||||
|
||||
@JsonProperty("products")
|
||||
private List<KosProductInfo> products;
|
||||
}
|
||||
+12
-22
@@ -1,45 +1,35 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
/**
|
||||
* Circuit Breaker Open 상태 예외
|
||||
* Circuit Breaker 관련 예외
|
||||
*/
|
||||
public class CircuitBreakerException extends BusinessException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String serviceName;
|
||||
private final String circuitBreakerState;
|
||||
|
||||
public CircuitBreakerException(String errorCode, String message, String serviceName, String circuitBreakerState) {
|
||||
public CircuitBreakerException(String errorCode, String message, String serviceName) {
|
||||
super(errorCode, message);
|
||||
this.serviceName = serviceName;
|
||||
this.circuitBreakerState = circuitBreakerState;
|
||||
}
|
||||
|
||||
public CircuitBreakerException(String errorCode, String message, String serviceName, Throwable cause) {
|
||||
super(errorCode, message, cause);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
public String getServiceName() {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
public String getCircuitBreakerState() {
|
||||
return circuitBreakerState;
|
||||
}
|
||||
|
||||
// 자주 사용되는 Circuit Breaker 예외 팩토리 메소드들
|
||||
public static CircuitBreakerException circuitOpen(String serviceName) {
|
||||
public static CircuitBreakerException circuitBreakerOpen(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_OPEN",
|
||||
"서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
|
||||
serviceName, "OPEN");
|
||||
"Circuit Breaker가 OPEN 상태입니다. 잠시 후 다시 시도해주세요", serviceName);
|
||||
}
|
||||
|
||||
public static CircuitBreakerException halfOpenFailed(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_HALF_OPEN_FAILED",
|
||||
"서비스 복구 시도 중 실패했습니다",
|
||||
serviceName, "HALF_OPEN");
|
||||
}
|
||||
|
||||
public static CircuitBreakerException callNotPermitted(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_CALL_NOT_PERMITTED",
|
||||
"서비스 호출이 차단되었습니다",
|
||||
serviceName, "OPEN");
|
||||
public static CircuitBreakerException circuitBreakerTimeout(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_TIMEOUT",
|
||||
"Circuit Breaker 타임아웃이 발생했습니다", serviceName);
|
||||
}
|
||||
}
|
||||
+15
@@ -43,4 +43,19 @@ public class KosConnectionException extends BusinessException {
|
||||
return new KosConnectionException("KOS_AUTH_FAILED",
|
||||
"KOS 시스템 인증에 실패했습니다", serviceName);
|
||||
}
|
||||
|
||||
public static KosConnectionException apiError(String serviceName, String statusCode, String details) {
|
||||
return new KosConnectionException("KOS_API_ERROR",
|
||||
"KOS API 호출 오류 [" + statusCode + "]: " + details, serviceName);
|
||||
}
|
||||
|
||||
public static KosConnectionException networkError(String serviceName, Throwable cause) {
|
||||
return new KosConnectionException("KOS_NETWORK_ERROR",
|
||||
"KOS 네트워크 연결 오류", serviceName, cause);
|
||||
}
|
||||
|
||||
public static KosConnectionException dataConversionError(String serviceName, String dataType, Throwable cause) {
|
||||
return new KosConnectionException("KOS_DATA_CONVERSION_ERROR",
|
||||
"KOS 데이터 변환 오류: " + dataType, serviceName, cause);
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -33,8 +33,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
||||
ProductChangeHistoryEntity entity = ProductChangeHistoryEntity.fromDomain(history);
|
||||
ProductChangeHistoryEntity savedEntity = jpaRepository.save(entity);
|
||||
|
||||
log.info("상품변경 이력 저장 완료: id={}, requestId={}",
|
||||
savedEntity.getId(), savedEntity.getRequestId());
|
||||
log.info("상품변경 이력 저장 완료: id={}", savedEntity.getId());
|
||||
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
@@ -43,7 +42,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
||||
public Optional<ProductChangeHistory> findByRequestId(String requestId) {
|
||||
log.debug("요청 ID로 이력 조회: requestId={}", requestId);
|
||||
|
||||
return jpaRepository.findByRequestId(requestId)
|
||||
return jpaRepository.findById(requestId)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@@ -160,14 +159,14 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
||||
public boolean existsByRequestId(String requestId) {
|
||||
log.debug("요청 ID 존재 여부 확인: requestId={}", requestId);
|
||||
|
||||
return jpaRepository.existsByRequestId(requestId);
|
||||
return jpaRepository.existsById(requestId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Long id) {
|
||||
log.info("상품변경 이력 삭제: id={}", id);
|
||||
|
||||
jpaRepository.deleteById(id);
|
||||
jpaRepository.deleteById(id.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+103
-6
@@ -2,14 +2,18 @@ package com.unicorn.phonebill.product.repository;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductStatus;
|
||||
import com.unicorn.phonebill.product.service.KosClientService;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductInfo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Redis 캐시를 활용한 상품 Repository 구현체
|
||||
@@ -21,6 +25,7 @@ public class ProductRepositoryImpl implements ProductRepository {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductRepositoryImpl.class);
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final KosClientService kosClientService;
|
||||
|
||||
// 캐시 키 접두사
|
||||
private static final String PRODUCT_CACHE_PREFIX = "product:";
|
||||
@@ -32,8 +37,10 @@ public class ProductRepositoryImpl implements ProductRepository {
|
||||
private static final long PRODUCT_CACHE_TTL = 3600; // 1시간
|
||||
private static final long PRODUCTS_CACHE_TTL = 1800; // 30분
|
||||
|
||||
public ProductRepositoryImpl(RedisTemplate<String, Object> redisTemplate) {
|
||||
public ProductRepositoryImpl(RedisTemplate<String, Object> redisTemplate,
|
||||
KosClientService kosClientService) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.kosClientService = kosClientService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -76,14 +83,17 @@ public class ProductRepositoryImpl implements ProductRepository {
|
||||
logger.debug("Cache miss for available products");
|
||||
incrementCacheMisses();
|
||||
|
||||
// TODO: KOS API 호출로 실제 데이터 조회
|
||||
// 현재는 테스트 데이터 반환
|
||||
List<Product> products = createTestAvailableProducts();
|
||||
// KOS API 호출로 실제 데이터 조회
|
||||
List<KosProductInfo> kosProducts = kosClientService.getProductListFromKos();
|
||||
List<Product> products = convertKosProductsToProducts(kosProducts);
|
||||
|
||||
cacheProducts(products, AVAILABLE_PRODUCTS_KEY);
|
||||
logger.info("KOS에서 조회한 상품 개수: {}", products.size());
|
||||
return products;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error finding available products", e);
|
||||
logger.error("Error finding available products from KOS", e);
|
||||
// KOS 연동 실패 시 빈 목록 반환 (fallback은 KosClientService에서 처리)
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -220,7 +230,94 @@ public class ProductRepositoryImpl implements ProductRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 데이터 생성 메서드들 (실제 운영에서는 KOS API 호출로 대체)
|
||||
/**
|
||||
* KOS 상품 정보를 Product 도메인으로 변환
|
||||
*/
|
||||
private List<Product> convertKosProductsToProducts(List<KosProductInfo> kosProducts) {
|
||||
return kosProducts.stream()
|
||||
.filter(kosProduct -> "ACTIVE".equalsIgnoreCase(kosProduct.getStatus()))
|
||||
.map(this::convertKosProductToProduct)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* KosProductInfo를 Product로 변환
|
||||
*/
|
||||
private Product convertKosProductToProduct(KosProductInfo kosProduct) {
|
||||
return Product.builder()
|
||||
.productCode(kosProduct.getProductCode())
|
||||
.productName(kosProduct.getProductName())
|
||||
.monthlyFee(kosProduct.getMonthlyFee() != null ?
|
||||
new BigDecimal(kosProduct.getMonthlyFee()) : BigDecimal.ZERO)
|
||||
.dataAllowance(formatDataAllowance(kosProduct.getDataAllowance()))
|
||||
.voiceAllowance(formatVoiceAllowance(kosProduct.getVoiceAllowance()))
|
||||
.smsAllowance(formatSmsAllowance(kosProduct.getSmsAllowance()))
|
||||
.status(convertKosStatusToProductStatus(kosProduct.getStatus()))
|
||||
.operatorCode(determineOperatorCode(kosProduct))
|
||||
.description(kosProduct.getDescription())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 허용량 포맷팅
|
||||
*/
|
||||
private String formatDataAllowance(Integer dataAllowanceGB) {
|
||||
if (dataAllowanceGB == null || dataAllowanceGB == 0) {
|
||||
return "0GB";
|
||||
}
|
||||
if (dataAllowanceGB >= 1000) {
|
||||
return "무제한";
|
||||
}
|
||||
return dataAllowanceGB + "GB";
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성통화 허용량 포맷팅
|
||||
*/
|
||||
private String formatVoiceAllowance(Integer voiceAllowanceMin) {
|
||||
if (voiceAllowanceMin == null || voiceAllowanceMin == 0) {
|
||||
return "0분";
|
||||
}
|
||||
if (voiceAllowanceMin >= 10000) {
|
||||
return "무제한";
|
||||
}
|
||||
return voiceAllowanceMin + "분";
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 허용량 포맷팅
|
||||
*/
|
||||
private String formatSmsAllowance(Integer smsAllowanceCount) {
|
||||
if (smsAllowanceCount == null || smsAllowanceCount == 0) {
|
||||
return "0건";
|
||||
}
|
||||
if (smsAllowanceCount >= 10000) {
|
||||
return "무제한";
|
||||
}
|
||||
return smsAllowanceCount + "건";
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 상태를 Product 상태로 변환
|
||||
*/
|
||||
private ProductStatus convertKosStatusToProductStatus(String kosStatus) {
|
||||
if ("ACTIVE".equalsIgnoreCase(kosStatus)) {
|
||||
return ProductStatus.ACTIVE;
|
||||
}
|
||||
return ProductStatus.DISCONTINUED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업자 코드 결정 (KOS에서는 별도로 제공하지 않으므로 상품코드나 기타 정보로 추정)
|
||||
*/
|
||||
private String determineOperatorCode(KosProductInfo kosProduct) {
|
||||
// 실제 운영에서는 KOS API에서 사업자 정보를 제공하거나
|
||||
// 상품코드 패턴으로 판단해야 함
|
||||
// 현재는 기본값으로 "KOS"를 사용
|
||||
return "KOS";
|
||||
}
|
||||
|
||||
// 테스트 데이터 생성 메서드들 (KOS 연동 실패 시 fallback용으로 유지)
|
||||
private Optional<Product> createTestProduct(String productCode) {
|
||||
Product product = Product.builder()
|
||||
.productCode(productCode)
|
||||
|
||||
+51
-96
@@ -7,15 +7,12 @@ import lombok.AccessLevel;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 엔티티
|
||||
* 모든 상품변경 요청 및 처리 이력을 관리
|
||||
* 상품변경 이력 엔티티 (실제 DB 스키마에 맞춘 버전)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "pc_product_change_history")
|
||||
@@ -24,105 +21,94 @@ import java.util.Map;
|
||||
public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 50)
|
||||
private String requestId;
|
||||
@Column(name = "id", nullable = false, unique = true, length = 100)
|
||||
private String id;
|
||||
|
||||
@Column(name = "line_number", nullable = false, length = 20)
|
||||
private String lineNumber;
|
||||
|
||||
@Column(name = "customer_id", nullable = false, length = 50)
|
||||
@Column(name = "customer_id", nullable = false, length = 100)
|
||||
private String customerId;
|
||||
|
||||
@Column(name = "current_product_code", nullable = false, length = 20)
|
||||
@Column(name = "old_product_code", length = 50)
|
||||
private String currentProductCode;
|
||||
|
||||
@Column(name = "target_product_code", nullable = false, length = 20)
|
||||
@Column(name = "new_product_code", nullable = false, length = 50)
|
||||
private String targetProductCode;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "process_status", nullable = false, length = 20)
|
||||
@Column(name = "change_status", length = 20)
|
||||
private ProcessStatus processStatus;
|
||||
|
||||
@Column(name = "validation_result", columnDefinition = "TEXT")
|
||||
private String validationResult;
|
||||
@Column(name = "change_reason", length = 255)
|
||||
private String changeReason;
|
||||
|
||||
@Column(name = "process_message", columnDefinition = "TEXT")
|
||||
private String processMessage;
|
||||
@Column(name = "change_method", length = 50)
|
||||
private String changeMethod;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "kos_request_data", columnDefinition = "jsonb")
|
||||
private Map<String, Object> kosRequestData;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "kos_response_data", columnDefinition = "jsonb")
|
||||
private Map<String, Object> kosResponseData;
|
||||
|
||||
@Column(name = "requested_at", nullable = false)
|
||||
@Column(name = "request_time", nullable = false)
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@Column(name = "validated_at")
|
||||
private LocalDateTime validatedAt;
|
||||
@Column(name = "approval_time")
|
||||
private LocalDateTime approvalTime;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
@Column(name = "completion_time")
|
||||
private LocalDateTime completionTime;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
@Column(name = "approver_id", length = 50)
|
||||
private String approverId;
|
||||
|
||||
@Column(name = "processor_id", length = 50)
|
||||
private String processorId;
|
||||
|
||||
@Column(name = "kos_request_id", length = 100)
|
||||
private String kosRequestId;
|
||||
|
||||
@Column(name = "kos_response_code", length = 20)
|
||||
private String kosResponseCode;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "retry_count")
|
||||
private Integer retryCount;
|
||||
|
||||
@Builder
|
||||
public ProductChangeHistoryEntity(
|
||||
String requestId,
|
||||
String id,
|
||||
String lineNumber,
|
||||
String customerId,
|
||||
String currentProductCode,
|
||||
String targetProductCode,
|
||||
ProcessStatus processStatus,
|
||||
String validationResult,
|
||||
String processMessage,
|
||||
Map<String, Object> kosRequestData,
|
||||
Map<String, Object> kosResponseData,
|
||||
LocalDateTime requestedAt,
|
||||
LocalDateTime validatedAt,
|
||||
LocalDateTime processedAt) {
|
||||
this.requestId = requestId;
|
||||
String changeReason,
|
||||
String changeMethod,
|
||||
LocalDateTime requestedAt) {
|
||||
this.id = id;
|
||||
this.lineNumber = lineNumber;
|
||||
this.customerId = customerId;
|
||||
this.currentProductCode = currentProductCode;
|
||||
this.targetProductCode = targetProductCode;
|
||||
this.processStatus = processStatus != null ? processStatus : ProcessStatus.REQUESTED;
|
||||
this.validationResult = validationResult;
|
||||
this.processMessage = processMessage;
|
||||
this.kosRequestData = kosRequestData;
|
||||
this.kosResponseData = kosResponseData;
|
||||
this.changeReason = changeReason;
|
||||
this.changeMethod = changeMethod != null ? changeMethod : "API";
|
||||
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
|
||||
this.validatedAt = validatedAt;
|
||||
this.processedAt = processedAt;
|
||||
this.retryCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인 모델로 변환
|
||||
* 도메인 모델로 변환 (간소화)
|
||||
*/
|
||||
public ProductChangeHistory toDomain() {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.id(null) // Long type을 위해 null 처리
|
||||
.requestId(this.id)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(this.processStatus)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(this.processMessage)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(this.processedAt)
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -131,30 +117,22 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
||||
*/
|
||||
public static ProductChangeHistoryEntity fromDomain(ProductChangeHistory domain) {
|
||||
return ProductChangeHistoryEntity.builder()
|
||||
.requestId(domain.getRequestId())
|
||||
.id(UUID.randomUUID().toString()) // 새로운 UUID 생성
|
||||
.lineNumber(domain.getLineNumber())
|
||||
.customerId(domain.getCustomerId())
|
||||
.currentProductCode(domain.getCurrentProductCode())
|
||||
.targetProductCode(domain.getTargetProductCode())
|
||||
.processStatus(domain.getProcessStatus())
|
||||
.validationResult(domain.getValidationResult())
|
||||
.processMessage(domain.getProcessMessage())
|
||||
.kosRequestData(domain.getKosRequestData())
|
||||
.kosResponseData(domain.getKosResponseData())
|
||||
.requestedAt(domain.getRequestedAt())
|
||||
.validatedAt(domain.getValidatedAt())
|
||||
.processedAt(domain.getProcessedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 완료로 변경
|
||||
*/
|
||||
public void markAsCompleted(String message, Map<String, Object> kosResponseData) {
|
||||
public void markAsCompleted(String message) {
|
||||
this.processStatus = ProcessStatus.COMPLETED;
|
||||
this.processMessage = message;
|
||||
this.kosResponseData = kosResponseData;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
this.completionTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,37 +140,14 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
||||
*/
|
||||
public void markAsFailed(String message) {
|
||||
this.processStatus = ProcessStatus.FAILED;
|
||||
this.processMessage = message;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
this.errorMessage = message;
|
||||
this.completionTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 완료로 상태 변경
|
||||
*/
|
||||
public void markAsValidated(String validationResult) {
|
||||
this.processStatus = ProcessStatus.VALIDATED;
|
||||
this.validationResult = validationResult;
|
||||
this.validatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중으로 상태 변경
|
||||
* 상태를 처리중으로 변경
|
||||
*/
|
||||
public void markAsProcessing() {
|
||||
this.processStatus = ProcessStatus.PROCESSING;
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 요청 데이터 설정
|
||||
*/
|
||||
public void setKosRequestData(Map<String, Object> kosRequestData) {
|
||||
this.kosRequestData = kosRequestData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 메시지 업데이트
|
||||
*/
|
||||
public void updateProcessMessage(String message) {
|
||||
this.processMessage = message;
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -17,12 +17,12 @@ import java.util.Optional;
|
||||
* 상품변경 이력 JPA Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface ProductChangeHistoryJpaRepository extends JpaRepository<ProductChangeHistoryEntity, Long> {
|
||||
public interface ProductChangeHistoryJpaRepository extends JpaRepository<ProductChangeHistoryEntity, String> {
|
||||
|
||||
/**
|
||||
* 요청 ID로 이력 조회
|
||||
* 요청 ID로 이력 조회 (id 필드 사용)
|
||||
*/
|
||||
Optional<ProductChangeHistoryEntity> findByRequestId(String requestId);
|
||||
Optional<ProductChangeHistoryEntity> findById(String id);
|
||||
|
||||
/**
|
||||
* 회선번호로 이력 조회 (최신순)
|
||||
@@ -82,7 +82,7 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
||||
* 처리 중인 요청 조회 (타임아웃 체크용)
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.processStatus IN ('PROCESSING', 'VALIDATED') " +
|
||||
"WHERE h.processStatus IN (com.unicorn.phonebill.product.domain.ProcessStatus.PROCESSING, com.unicorn.phonebill.product.domain.ProcessStatus.VALIDATED) " +
|
||||
"AND h.requestedAt < :timeoutThreshold " +
|
||||
"ORDER BY h.requestedAt ASC")
|
||||
List<ProductChangeHistoryEntity> findProcessingRequestsOlderThan(
|
||||
@@ -93,8 +93,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"AND h.processStatus = 'COMPLETED' " +
|
||||
"ORDER BY h.processedAt DESC")
|
||||
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
|
||||
"ORDER BY h.completionTime DESC")
|
||||
Page<ProductChangeHistoryEntity> findLatestSuccessfulChangeByLineNumber(
|
||||
@Param("lineNumber") String lineNumber,
|
||||
Pageable pageable);
|
||||
@@ -115,8 +115,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.currentProductCode = :currentProductCode " +
|
||||
"AND h.targetProductCode = :targetProductCode " +
|
||||
"AND h.processStatus = 'COMPLETED' " +
|
||||
"AND h.processedAt >= :fromDate")
|
||||
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
|
||||
"AND h.completionTime >= :fromDate")
|
||||
long countSuccessfulChangesByProductCodesSince(
|
||||
@Param("currentProductCode") String currentProductCode,
|
||||
@Param("targetProductCode") String targetProductCode,
|
||||
@@ -127,11 +127,11 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
||||
*/
|
||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"AND h.processStatus IN ('PROCESSING', 'VALIDATED')")
|
||||
"AND h.processStatus IN (com.unicorn.phonebill.product.domain.ProcessStatus.PROCESSING, com.unicorn.phonebill.product.domain.ProcessStatus.VALIDATED)")
|
||||
long countInProgressRequestsByLineNumber(@Param("lineNumber") String lineNumber);
|
||||
|
||||
/**
|
||||
* 요청 ID 존재 여부 확인
|
||||
* 요청 ID 존재 여부 확인 (id 필드 사용)
|
||||
*/
|
||||
boolean existsByRequestId(String requestId);
|
||||
boolean existsById(String id);
|
||||
}
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
package com.unicorn.phonebill.product.service;
|
||||
|
||||
import com.unicorn.phonebill.product.config.KosProperties;
|
||||
import com.unicorn.phonebill.product.exception.CircuitBreakerException;
|
||||
import com.unicorn.phonebill.product.exception.KosConnectionException;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosCommonResponse;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductInfo;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductListResponse;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryRequest;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryResponse;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동 클라이언트 서비스 (상품 관리)
|
||||
*
|
||||
* 통신사 백엔드 시스템(KOS)과의 상품 관련 연동을 담당하는 서비스
|
||||
* - Circuit Breaker 패턴으로 외부 시스템 장애 격리
|
||||
* - Retry 패턴으로 일시적 네트워크 오류 극복
|
||||
* - Timeout 설정으로 응답 지연 방지
|
||||
* - 데이터 변환 및 오류 처리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-09
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KosClientService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 시스템에서 전체 상품 목록 조회
|
||||
*
|
||||
* @return KOS 상품 목록 응답
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-product-list", fallbackMethod = "getProductListFallback")
|
||||
@Retry(name = "kos-product-list")
|
||||
public List<KosProductInfo> getProductListFromKos() {
|
||||
log.info("KOS 상품 목록 조회 요청");
|
||||
|
||||
try {
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("X-Service-Name", "MVNO-PRODUCT-SERVICE");
|
||||
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
|
||||
|
||||
// KOS Mock API 호출
|
||||
String kosUrl = kosProperties.getProductListUrl();
|
||||
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
|
||||
kosUrl, HttpMethod.GET, requestEntity,
|
||||
new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() {}
|
||||
);
|
||||
|
||||
Map<String, Object> response = responseEntity.getBody();
|
||||
|
||||
if (response == null) {
|
||||
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
|
||||
String.valueOf(responseEntity.getStatusCode().value()), "응답 데이터가 없습니다");
|
||||
}
|
||||
|
||||
// KosCommonResponse의 data 부분에서 KosProductListResponse 추출
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) {
|
||||
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
|
||||
"NO_DATA", "응답에서 data를 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
// 상품 목록 추출 및 변환
|
||||
List<KosProductInfo> productList = convertToKosProductInfoList(data);
|
||||
|
||||
log.info("KOS 상품 목록 조회 성공 - 상품 개수: {}", productList.size());
|
||||
return productList;
|
||||
|
||||
} catch (HttpClientErrorException e) {
|
||||
log.error("KOS API 클라이언트 오류 - 상태: {}, 응답: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
|
||||
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||
|
||||
} catch (HttpServerErrorException e) {
|
||||
log.error("KOS API 서버 오류 - 상태: {}, 응답: {}",
|
||||
e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw KosConnectionException.apiError("KOS-PRODUCT-LIST",
|
||||
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||
|
||||
} catch (ResourceAccessException e) {
|
||||
log.error("KOS 네트워크 연결 오류 - 오류: {}", e.getMessage());
|
||||
throw KosConnectionException.networkError("KOS-PRODUCT-LIST", e);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 연동 중 예상치 못한 오류 - 오류: {}", e.getMessage(), e);
|
||||
throw new KosConnectionException("KOS-PRODUCT-LIST",
|
||||
"KOS 시스템 연동 중 오류가 발생했습니다", "KOS-PRODUCT-LIST", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 시스템에서 상품 변경 처리
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param currentProductCode 현재 상품코드
|
||||
* @param targetProductCode 변경할 상품코드
|
||||
* @return 상품변경 결과
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-product-change", fallbackMethod = "changeProductFallback")
|
||||
@Retry(name = "kos-product-change")
|
||||
public Map<String, Object> changeProductInKos(String lineNumber, String currentProductCode, String targetProductCode) {
|
||||
log.info("KOS 상품 변경 요청 - 회선: {}, 현재상품: {}, 변경상품: {}",
|
||||
lineNumber, currentProductCode, targetProductCode);
|
||||
|
||||
try {
|
||||
// 요청 데이터 구성
|
||||
Map<String, Object> requestData = Map.of(
|
||||
"lineNumber", lineNumber.replaceAll("-", ""),
|
||||
"currentProductCode", currentProductCode,
|
||||
"targetProductCode", targetProductCode,
|
||||
"requestId", generateRequestId()
|
||||
);
|
||||
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("X-Service-Name", "MVNO-PRODUCT-SERVICE");
|
||||
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||
|
||||
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestData, headers);
|
||||
|
||||
// KOS Mock API 호출
|
||||
String kosUrl = kosProperties.getProductChangeUrl();
|
||||
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
|
||||
kosUrl, HttpMethod.POST, requestEntity,
|
||||
new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() {}
|
||||
);
|
||||
|
||||
Map<String, Object> response = responseEntity.getBody();
|
||||
|
||||
if (response == null) {
|
||||
throw KosConnectionException.apiError("KOS-PRODUCT-CHANGE",
|
||||
String.valueOf(responseEntity.getStatusCode().value()), "응답 데이터가 없습니다");
|
||||
}
|
||||
|
||||
log.info("KOS 상품 변경 성공 - 회선: {}", lineNumber);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 상품 변경 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
|
||||
throw new KosConnectionException("KOS-PRODUCT-CHANGE",
|
||||
"KOS 시스템 상품 변경 중 오류가 발생했습니다", "KOS-PRODUCT-CHANGE", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 조회 Circuit Breaker Fallback 메소드
|
||||
*/
|
||||
public List<KosProductInfo> getProductListFallback(Exception ex) {
|
||||
log.warn("KOS 상품 목록 조회 Circuit Breaker 작동 - 오류: {}", ex.getMessage());
|
||||
|
||||
// Circuit Breaker가 Open 상태인 경우
|
||||
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
|
||||
throw CircuitBreakerException.circuitBreakerOpen("KOS-PRODUCT-LIST");
|
||||
}
|
||||
|
||||
// 기본 상품 목록 반환 (빈 목록)
|
||||
log.info("KOS 상품 목록 조회 fallback - 빈 목록 반환");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 변경 Circuit Breaker Fallback 메소드
|
||||
*/
|
||||
public Map<String, Object> changeProductFallback(String lineNumber, String currentProductCode,
|
||||
String targetProductCode, Exception ex) {
|
||||
log.warn("KOS 상품 변경 Circuit Breaker 작동 - 회선: {}, 오류: {}", lineNumber, ex.getMessage());
|
||||
|
||||
// Circuit Breaker가 Open 상태인 경우
|
||||
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
|
||||
throw CircuitBreakerException.circuitBreakerOpen("KOS-PRODUCT-CHANGE");
|
||||
}
|
||||
|
||||
// 실패 응답 반환
|
||||
return Map.of(
|
||||
"success", false,
|
||||
"resultCode", "9999",
|
||||
"resultMessage", "시스템 오류로 인한 상품 변경 실패",
|
||||
"timestamp", LocalDateTime.now().toString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 가입상품 조회
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @return KOS 가입상품 조회 응답
|
||||
*/
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
@CircuitBreaker(name = "kosClient", fallbackMethod = "getProductInquiryFallback")
|
||||
@Retry(name = "kosClient")
|
||||
public KosCommonResponse<KosProductInquiryResponse> getProductInquiry(String lineNumber) {
|
||||
log.info("KOS 가입상품 조회 요청: lineNumber={}", lineNumber);
|
||||
|
||||
try {
|
||||
// 요청 ID 생성
|
||||
String requestId = generateRequestId();
|
||||
|
||||
// 요청 데이터 생성
|
||||
KosProductInquiryRequest request = new KosProductInquiryRequest();
|
||||
request.setLineNumber(lineNumber);
|
||||
request.setRequestId(requestId);
|
||||
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("X-Request-ID", requestId);
|
||||
headers.set("X-Service-Name", "product-service");
|
||||
|
||||
HttpEntity<KosProductInquiryRequest> requestEntity = new HttpEntity<>(request, headers);
|
||||
|
||||
// KOS API 호출
|
||||
String url = kosProperties.getProductInquiryUrl();
|
||||
log.debug("KOS API 호출: url={}, requestId={}", url, requestId);
|
||||
|
||||
ResponseEntity<KosCommonResponse<KosProductInquiryResponse>> response = restTemplate.exchange(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
requestEntity,
|
||||
new ParameterizedTypeReference<KosCommonResponse<KosProductInquiryResponse>>() {}
|
||||
);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
KosCommonResponse<KosProductInquiryResponse> kosResponse = response.getBody();
|
||||
|
||||
if (kosResponse.getSuccess()) {
|
||||
log.info("KOS 가입상품 조회 성공: lineNumber={}, requestId={}", lineNumber, requestId);
|
||||
return kosResponse;
|
||||
} else {
|
||||
log.error("KOS 가입상품 조회 실패: lineNumber={}, requestId={}, resultCode={}, resultMessage={}",
|
||||
lineNumber, requestId, kosResponse.getResultCode(), kosResponse.getResultMessage());
|
||||
throw new RuntimeException("KOS 가입상품 조회 실패: " + kosResponse.getResultMessage());
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("KOS API 호출 실패: HTTP " + response.getStatusCode());
|
||||
}
|
||||
|
||||
} catch (ResourceAccessException e) {
|
||||
log.error("KOS API 호출 중 네트워크 오류: lineNumber={}", lineNumber, e);
|
||||
throw new RuntimeException("KOS 시스템과의 통신 중 오류가 발생했습니다", e);
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 가입상품 조회 중 예상치 못한 오류: lineNumber={}", lineNumber, e);
|
||||
throw new RuntimeException("가입상품 조회 중 시스템 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 가입상품 조회 실패 시 Fallback 메서드
|
||||
*/
|
||||
public KosCommonResponse<KosProductInquiryResponse> getProductInquiryFallback(String lineNumber, Exception ex) {
|
||||
log.error("KOS 가입상품 조회 Fallback 실행: lineNumber={}, reason={}", lineNumber, ex.getMessage());
|
||||
|
||||
// Fallback 응답 생성 (임시 데이터)
|
||||
KosProductInquiryResponse.ProductInfo productInfo = KosProductInquiryResponse.ProductInfo.builder()
|
||||
.lineNumber(lineNumber)
|
||||
.currentProductCode("FALLBACK_PLAN")
|
||||
.currentProductName("임시 플랜 (시스템 점검 중)")
|
||||
.monthlyFee(new java.math.BigDecimal("0"))
|
||||
.dataAllowance("정보 없음")
|
||||
.voiceAllowance("정보 없음")
|
||||
.smsAllowance("정보 없음")
|
||||
.productStatus("UNKNOWN")
|
||||
.contractDate(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
KosProductInquiryResponse.CustomerInfo customerInfo = KosProductInquiryResponse.CustomerInfo.builder()
|
||||
.customerName("임시 고객")
|
||||
.customerId("FALLBACK_CUSTOMER")
|
||||
.operatorCode("UNKNOWN")
|
||||
.lineStatus("UNKNOWN")
|
||||
.build();
|
||||
|
||||
KosProductInquiryResponse data = KosProductInquiryResponse.builder()
|
||||
.requestId(generateRequestId())
|
||||
.procStatus("FALLBACK")
|
||||
.resultCode("9999")
|
||||
.resultMessage("KOS 시스템 일시 점검 중입니다")
|
||||
.productInfo(productInfo)
|
||||
.customerInfo(customerInfo)
|
||||
.build();
|
||||
|
||||
return KosCommonResponse.success(data, "KOS 시스템 일시 점검 중 - 임시 정보 제공");
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 시스템 연결 상태 확인
|
||||
*
|
||||
* @return 연결 가능 여부
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-health-check")
|
||||
public boolean isKosSystemAvailable() {
|
||||
try {
|
||||
String healthUrl = kosProperties.getHealthCheckUrl();
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(healthUrl, String.class);
|
||||
|
||||
boolean available = response.getStatusCode().is2xxSuccessful();
|
||||
log.debug("KOS 시스템 상태 확인 - 사용가능: {}", available);
|
||||
|
||||
return available;
|
||||
} catch (Exception e) {
|
||||
log.warn("KOS 시스템 상태 확인 실패: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Map 데이터를 KosProductInfo 리스트로 변환
|
||||
*/
|
||||
private List<KosProductInfo> convertToKosProductInfoList(Map<String, Object> data) {
|
||||
try {
|
||||
List<KosProductInfo> productList = new ArrayList<>();
|
||||
|
||||
// products 배열 추출
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> products = (List<Map<String, Object>>) data.get("products");
|
||||
if (products != null) {
|
||||
for (Map<String, Object> product : products) {
|
||||
KosProductInfo productInfo = KosProductInfo.builder()
|
||||
.productCode((String) product.get("product_code"))
|
||||
.productName((String) product.get("product_name"))
|
||||
.productType((String) product.get("product_type"))
|
||||
.monthlyFee(convertToInteger(product.get("monthly_fee")))
|
||||
.dataAllowance(convertToInteger(product.get("data_allowance")))
|
||||
.voiceAllowance(convertToInteger(product.get("voice_allowance")))
|
||||
.smsAllowance(convertToInteger(product.get("sms_allowance")))
|
||||
.networkType((String) product.get("network_type"))
|
||||
.status((String) product.get("status"))
|
||||
.description((String) product.get("description"))
|
||||
.build();
|
||||
|
||||
productList.add(productInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return productList;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("상품 목록 데이터 변환 오류: {}", e.getMessage(), e);
|
||||
throw KosConnectionException.dataConversionError("KOS-PRODUCT-LIST", "KosProductInfo", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Object를 Integer로 변환
|
||||
*/
|
||||
private Integer convertToInteger(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Integer) return (Integer) value;
|
||||
if (value instanceof Number) return ((Number) value).intValue();
|
||||
try {
|
||||
return Integer.valueOf(value.toString().split("\\.")[0]); // 소수점 제거
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("숫자 변환 실패: {}", value);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 ID 생성
|
||||
*/
|
||||
private String generateRequestId() {
|
||||
String currentDate = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String uuid = java.util.UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
return String.format("PROD_%s_%s", currentDate, uuid);
|
||||
}
|
||||
}
|
||||
-29
@@ -37,7 +37,6 @@ public class ProductCacheService {
|
||||
private static final String AVAILABLE_PRODUCTS_PREFIX = "availableProducts:";
|
||||
private static final String PRODUCT_STATUS_PREFIX = "productStatus:";
|
||||
private static final String LINE_STATUS_PREFIX = "lineStatus:";
|
||||
private static final String MENU_INFO_PREFIX = "menuInfo:";
|
||||
private static final String PRODUCT_CHANGE_RESULT_PREFIX = "productChangeResult:";
|
||||
|
||||
public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
|
||||
@@ -155,27 +154,6 @@ public class ProductCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 메뉴정보 캐시 (TTL: 6시간) ==========
|
||||
|
||||
/**
|
||||
* 메뉴정보 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "menuInfo", key = "#userId", unless = "#result == null")
|
||||
public Object getMenuInfo(String userId) {
|
||||
logger.debug("메뉴정보 캐시 조회: {}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴정보 캐시 저장
|
||||
*/
|
||||
public void cacheMenuInfo(String userId, Object menuInfo) {
|
||||
if (StringUtils.hasText(userId) && menuInfo != null) {
|
||||
String key = MENU_INFO_PREFIX + userId;
|
||||
redisTemplate.opsForValue().set(key, menuInfo, Duration.ofHours(6));
|
||||
logger.debug("메뉴정보 캐시 저장: {}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 상품변경결과 캐시 (TTL: 1시간) ==========
|
||||
|
||||
@@ -207,9 +185,6 @@ public class ProductCacheService {
|
||||
public void evictCustomerCaches(String lineNumber, String customerId) {
|
||||
evictCustomerProductInfo(lineNumber);
|
||||
evictLineStatus(lineNumber);
|
||||
if (StringUtils.hasText(customerId)) {
|
||||
evictMenuInfo(customerId);
|
||||
}
|
||||
logger.info("고객 관련 캐시 무효화 완료: lineNumber={}, customerId={}", lineNumber, customerId);
|
||||
}
|
||||
|
||||
@@ -271,10 +246,6 @@ public class ProductCacheService {
|
||||
logger.debug("회선상태 캐시 무효화: {}", lineNumber);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "menuInfo", key = "#userId")
|
||||
public void evictMenuInfo(String userId) {
|
||||
logger.debug("메뉴정보 캐시 무효화: {}", userId);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "productChangeResult", key = "#requestId")
|
||||
public void evictProductChangeResult(String requestId) {
|
||||
|
||||
+1
-18
@@ -9,21 +9,12 @@ import java.util.List;
|
||||
* 상품 관리 서비스 인터페이스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 메뉴 조회
|
||||
* - 고객 및 상품 정보 조회
|
||||
* - 상품변경 처리
|
||||
* - 상품변경 이력 관리
|
||||
*/
|
||||
public interface ProductService {
|
||||
|
||||
/**
|
||||
* 상품변경 메뉴 조회
|
||||
* UFR-PROD-010 구현
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return 메뉴 응답
|
||||
*/
|
||||
ProductMenuResponse getProductMenu(String userId);
|
||||
|
||||
/**
|
||||
* 고객 정보 조회
|
||||
@@ -39,10 +30,9 @@ public interface ProductService {
|
||||
* UFR-PROD-020 구현
|
||||
*
|
||||
* @param currentProductCode 현재 상품코드 (필터링용)
|
||||
* @param operatorCode 사업자 코드 (필터링용)
|
||||
* @return 가용 상품 목록 응답
|
||||
*/
|
||||
AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode);
|
||||
AvailableProductsResponse getAvailableProducts(String currentProductCode);
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크
|
||||
@@ -73,13 +63,6 @@ public interface ProductService {
|
||||
*/
|
||||
ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId);
|
||||
|
||||
/**
|
||||
* 상품변경 결과 조회
|
||||
*
|
||||
* @param requestId 상품변경 요청 ID
|
||||
* @return 상품변경 결과 응답
|
||||
*/
|
||||
ProductChangeResultResponse getProductChangeResult(String requestId);
|
||||
|
||||
/**
|
||||
* 상품변경 이력 조회
|
||||
|
||||
+128
-174
@@ -3,8 +3,11 @@ package com.unicorn.phonebill.product.service;
|
||||
import com.unicorn.phonebill.product.dto.*;
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeResult;
|
||||
import com.unicorn.phonebill.product.repository.ProductRepository;
|
||||
import com.unicorn.phonebill.product.repository.ProductChangeHistoryRepository;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosCommonResponse;
|
||||
import com.unicorn.phonebill.product.dto.kos.KosProductInquiryResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -41,49 +44,20 @@ public class ProductServiceImpl implements ProductService {
|
||||
private final ProductChangeHistoryRepository historyRepository;
|
||||
private final ProductValidationService validationService;
|
||||
private final ProductCacheService cacheService;
|
||||
// TODO: KOS 연동 서비스 추가 예정
|
||||
// private final KosClientService kosClientService;
|
||||
private final KosClientService kosClientService;
|
||||
|
||||
public ProductServiceImpl(ProductRepository productRepository,
|
||||
ProductChangeHistoryRepository historyRepository,
|
||||
ProductValidationService validationService,
|
||||
ProductCacheService cacheService) {
|
||||
ProductCacheService cacheService,
|
||||
KosClientService kosClientService) {
|
||||
this.productRepository = productRepository;
|
||||
this.historyRepository = historyRepository;
|
||||
this.validationService = validationService;
|
||||
this.cacheService = cacheService;
|
||||
this.kosClientService = kosClientService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductMenuResponse getProductMenu(String userId) {
|
||||
logger.info("상품변경 메뉴 조회: userId={}", userId);
|
||||
|
||||
try {
|
||||
// 캐시에서 메뉴 정보 조회
|
||||
Object cachedMenu = cacheService.getMenuInfo(userId);
|
||||
if (cachedMenu instanceof ProductMenuResponse) {
|
||||
logger.debug("메뉴 정보 캐시 히트: userId={}", userId);
|
||||
return (ProductMenuResponse) cachedMenu;
|
||||
}
|
||||
|
||||
// 메뉴 정보 생성 (실제로는 사용자 권한에 따라 동적 생성)
|
||||
ProductMenuResponse.MenuData menuData = createMenuData(userId);
|
||||
ProductMenuResponse response = ProductMenuResponse.builder()
|
||||
.success(true)
|
||||
.data(menuData)
|
||||
.build();
|
||||
|
||||
// 캐시에 저장
|
||||
cacheService.cacheMenuInfo(userId, response);
|
||||
|
||||
logger.info("상품변경 메뉴 조회 완료: userId={}", userId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 메뉴 조회 중 오류: userId={}", userId, e);
|
||||
throw new RuntimeException("메뉴 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomerInfoResponse getCustomerInfo(String lineNumber) {
|
||||
@@ -117,36 +91,36 @@ public class ProductServiceImpl implements ProductService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode) {
|
||||
logger.info("가용 상품 목록 조회: currentProductCode={}, operatorCode={}", currentProductCode, operatorCode);
|
||||
public AvailableProductsResponse getAvailableProducts(String currentProductCode) {
|
||||
logger.info("가용 상품 목록 조회: currentProductCode={}", currentProductCode);
|
||||
|
||||
try {
|
||||
// 캐시에서 상품 목록 조회
|
||||
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts(operatorCode);
|
||||
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts("all");
|
||||
if (cachedProducts != null && !cachedProducts.isEmpty()) {
|
||||
logger.debug("상품 목록 캐시 히트: operatorCode={}, count={}", operatorCode, cachedProducts.size());
|
||||
logger.debug("상품 목록 캐시 히트: count={}", cachedProducts.size());
|
||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(cachedProducts, currentProductCode);
|
||||
return AvailableProductsResponse.success(filteredProducts);
|
||||
}
|
||||
|
||||
// 캐시 미스 시 실제 조회
|
||||
List<Product> products = productRepository.findAvailableProductsByOperator(operatorCode);
|
||||
List<Product> products = productRepository.findAvailableProducts();
|
||||
List<ProductInfoDto> productDtos = products.stream()
|
||||
.map(this::convertToDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 캐시에 저장
|
||||
cacheService.cacheAvailableProducts(operatorCode, productDtos);
|
||||
cacheService.cacheAvailableProducts("all", productDtos);
|
||||
|
||||
// 현재 상품 기준 필터링
|
||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(productDtos, currentProductCode);
|
||||
|
||||
logger.info("가용 상품 목록 조회 완료: operatorCode={}, totalCount={}, filteredCount={}",
|
||||
operatorCode, productDtos.size(), filteredProducts.size());
|
||||
logger.info("가용 상품 목록 조회 완료: totalCount={}, filteredCount={}",
|
||||
productDtos.size(), filteredProducts.size());
|
||||
return AvailableProductsResponse.success(filteredProducts);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("가용 상품 목록 조회 중 오류: operatorCode={}", operatorCode, e);
|
||||
logger.error("가용 상품 목록 조회 중 오류", e);
|
||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
@@ -191,12 +165,18 @@ public class ProductServiceImpl implements ProductService {
|
||||
|
||||
// 4. 처리 결과에 따른 이력 업데이트
|
||||
if (changeResult.isSuccess()) {
|
||||
// KOS 응답 데이터를 Map으로 변환
|
||||
Map<String, Object> kosResponseData = Map.of(
|
||||
"resultCode", changeResult.getResultCode(),
|
||||
"resultMessage", changeResult.getResultMessage(),
|
||||
"processedAt", LocalDateTime.now().toString()
|
||||
);
|
||||
// KOS 응답 데이터 사용 (실제 응답 데이터 또는 기본 데이터)
|
||||
Map<String, Object> kosResponseData = changeResult.getKosResponseData();
|
||||
if (kosResponseData == null) {
|
||||
kosResponseData = Map.of(
|
||||
"resultCode", changeResult.getResultCode(),
|
||||
"resultMessage", changeResult.getResultMessage(),
|
||||
"kosOrderNumber", changeResult.getKosOrderNumber() != null ? changeResult.getKosOrderNumber() : "N/A",
|
||||
"effectiveDate", changeResult.getEffectiveDate() != null ? changeResult.getEffectiveDate() : "N/A",
|
||||
"processedAt", LocalDateTime.now().toString()
|
||||
);
|
||||
}
|
||||
|
||||
history = history.markAsCompleted(changeResult.getResultMessage(), kosResponseData);
|
||||
|
||||
// 캐시 무효화
|
||||
@@ -280,41 +260,6 @@ public class ProductServiceImpl implements ProductService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductChangeResultResponse getProductChangeResult(String requestId) {
|
||||
logger.info("상품변경 결과 조회: requestId={}", requestId);
|
||||
|
||||
try {
|
||||
// 캐시에서 결과 조회
|
||||
ProductChangeResultResponse.ProductChangeResult cachedResult = cacheService.getProductChangeResult(requestId);
|
||||
if (cachedResult != null) {
|
||||
logger.debug("상품변경 결과 캐시 히트: requestId={}", requestId);
|
||||
return ProductChangeResultResponse.success(cachedResult);
|
||||
}
|
||||
|
||||
// 캐시 미스 시 DB에서 조회
|
||||
Optional<ProductChangeHistory> historyOpt = historyRepository.findByRequestId(requestId);
|
||||
if (!historyOpt.isPresent()) {
|
||||
throw new RuntimeException("요청 정보를 찾을 수 없습니다: " + requestId);
|
||||
}
|
||||
|
||||
ProductChangeHistory history = historyOpt.get();
|
||||
|
||||
ProductChangeResultResponse.ProductChangeResult result = convertToResultDto(history);
|
||||
|
||||
// 완료된 결과만 캐시에 저장
|
||||
if (history.getProcessStatus().equals("COMPLETED") || history.getProcessStatus().equals("FAILED")) {
|
||||
cacheService.cacheProductChangeResult(requestId, result);
|
||||
}
|
||||
|
||||
logger.info("상품변경 결과 조회 완료: requestId={}, status={}", requestId, history.getProcessStatus());
|
||||
return ProductChangeResultResponse.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 결과 조회 중 오류: requestId={}", requestId, e);
|
||||
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable) {
|
||||
@@ -365,49 +310,49 @@ public class ProductServiceImpl implements ProductService {
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* 메뉴 데이터 생성
|
||||
*/
|
||||
private ProductMenuResponse.MenuData createMenuData(String userId) {
|
||||
// TODO: 실제로는 사용자 권한 및 고객 정보에 따라 동적 생성
|
||||
return ProductMenuResponse.MenuData.builder()
|
||||
.customerId("CUST001") // 임시값
|
||||
.lineNumber("01012345678") // 임시값
|
||||
.menuItems(Arrays.asList(
|
||||
ProductMenuResponse.MenuItem.builder()
|
||||
.menuId("MENU001")
|
||||
.menuName("상품변경")
|
||||
.available(true)
|
||||
.description("현재 이용 중인 상품을 다른 상품으로 변경합니다")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터소스에서 고객 정보 조회
|
||||
* 데이터소스에서 고객 정보 조회 (KOS 연동)
|
||||
*/
|
||||
private CustomerInfoResponse.CustomerInfo getCustomerInfoFromDataSource(String lineNumber) {
|
||||
// TODO: 실제 KOS 연동 또는 DB 조회 구현
|
||||
// 현재는 임시 데이터 반환
|
||||
ProductInfoDto currentProduct = ProductInfoDto.builder()
|
||||
.productCode("PLAN001")
|
||||
.productName("5G 베이직 플랜")
|
||||
.monthlyFee(new java.math.BigDecimal("45000"))
|
||||
.dataAllowance("50GB")
|
||||
.voiceAllowance("무제한")
|
||||
.smsAllowance("기본 무료")
|
||||
.isAvailable(true)
|
||||
.operatorCode("MVNO001")
|
||||
.build();
|
||||
try {
|
||||
logger.debug("KOS 시스템에서 고객 정보 조회: lineNumber={}", lineNumber);
|
||||
|
||||
// KOS 시스템 호출
|
||||
KosCommonResponse<KosProductInquiryResponse> kosResponse = kosClientService.getProductInquiry(lineNumber);
|
||||
|
||||
if (kosResponse.getSuccess() && kosResponse.getData() != null) {
|
||||
KosProductInquiryResponse kosData = kosResponse.getData();
|
||||
|
||||
// KOS 응답을 내부 DTO로 변환
|
||||
ProductInfoDto currentProduct = ProductInfoDto.builder()
|
||||
.productCode(kosData.getProductInfo().getCurrentProductCode())
|
||||
.productName(kosData.getProductInfo().getCurrentProductName())
|
||||
.monthlyFee(kosData.getProductInfo().getMonthlyFee())
|
||||
.dataAllowance(kosData.getProductInfo().getDataAllowance())
|
||||
.voiceAllowance(kosData.getProductInfo().getVoiceAllowance())
|
||||
.smsAllowance(kosData.getProductInfo().getSmsAllowance())
|
||||
.isAvailable("ACTIVE".equals(kosData.getProductInfo().getProductStatus()))
|
||||
.operatorCode(kosData.getCustomerInfo().getOperatorCode())
|
||||
.build();
|
||||
|
||||
return CustomerInfoResponse.CustomerInfo.builder()
|
||||
.customerId("CUST001")
|
||||
.lineNumber(lineNumber)
|
||||
.customerName("홍길동")
|
||||
.currentProduct(currentProduct)
|
||||
.lineStatus("ACTIVE")
|
||||
.build();
|
||||
return CustomerInfoResponse.CustomerInfo.builder()
|
||||
.customerId(kosData.getCustomerInfo().getCustomerId())
|
||||
.lineNumber(lineNumber)
|
||||
.customerName(kosData.getCustomerInfo().getCustomerName())
|
||||
.currentProduct(currentProduct)
|
||||
.lineStatus(kosData.getCustomerInfo().getLineStatus())
|
||||
.build();
|
||||
} else {
|
||||
logger.error("KOS 시스템에서 고객 정보를 찾을 수 없습니다: lineNumber={}, resultCode={}, resultMessage={}",
|
||||
lineNumber, kosResponse.getResultCode(), kosResponse.getResultMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("KOS 연동 중 오류 발생: lineNumber={}", lineNumber, e);
|
||||
throw new RuntimeException("고객 정보 조회 중 시스템 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,7 +389,7 @@ public class ProductServiceImpl implements ProductService {
|
||||
*/
|
||||
private ProductChangeHistory createProductChangeHistory(String requestId, ProductChangeRequest request, String userId) {
|
||||
return ProductChangeHistory.createNew(
|
||||
requestId,
|
||||
requestId, // UUID는 엔티티에서 자동 생성됨
|
||||
request.getLineNumber(),
|
||||
userId, // customerId로 사용
|
||||
request.getCurrentProductCode(),
|
||||
@@ -456,21 +401,71 @@ public class ProductServiceImpl implements ProductService {
|
||||
* KOS 연동 상품변경 처리 (임시 구현)
|
||||
*/
|
||||
private ProductChangeResult processProductChangeWithKos(ProductChangeRequest request, String requestId) {
|
||||
// TODO: 실제 KOS 연동 구현
|
||||
// 현재는 임시 성공 결과 반환
|
||||
logger.info("KOS 상품 변경 처리 시작: requestId={}, lineNumber={}", requestId, request.getLineNumber());
|
||||
|
||||
try {
|
||||
Thread.sleep(100); // 처리 시간 시뮬레이션
|
||||
return ProductChangeResult.builder()
|
||||
.success(true)
|
||||
.resultCode("SUCCESS")
|
||||
.resultMessage("상품 변경이 완료되었습니다")
|
||||
.build();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
// KOS 상품변경 API 호출
|
||||
Map<String, Object> kosResponse = kosClientService.changeProductInKos(
|
||||
request.getLineNumber(),
|
||||
request.getCurrentProductCode(),
|
||||
request.getTargetProductCode()
|
||||
);
|
||||
|
||||
// KOS 응답 분석
|
||||
Boolean success = (Boolean) kosResponse.get("success");
|
||||
String resultCode = (String) kosResponse.get("resultCode");
|
||||
String resultMessage = (String) kosResponse.get("resultMessage");
|
||||
|
||||
if (Boolean.TRUE.equals(success) && "0000".equals(resultCode)) {
|
||||
logger.info("KOS 상품 변경 성공: requestId={}, lineNumber={}", requestId, request.getLineNumber());
|
||||
|
||||
// data 섹션에서 상세 정보 추출
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) kosResponse.get("data");
|
||||
if (data != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> changeInfo = (Map<String, Object>) data.get("changeInfo");
|
||||
if (changeInfo != null) {
|
||||
String kosOrderNumber = (String) changeInfo.get("kosOrderNumber");
|
||||
String effectiveDate = (String) changeInfo.get("effectiveDate");
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.success(true)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.kosOrderNumber(kosOrderNumber)
|
||||
.effectiveDate(effectiveDate)
|
||||
.kosResponseData(kosResponse)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.success(true)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.kosResponseData(kosResponse)
|
||||
.build();
|
||||
|
||||
} else {
|
||||
logger.error("KOS 상품 변경 실패: requestId={}, resultCode={}, resultMessage={}",
|
||||
requestId, resultCode, resultMessage);
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.success(false)
|
||||
.resultCode(resultCode != null ? resultCode : "KOS_ERROR")
|
||||
.failureReason(resultMessage != null ? resultMessage : "KOS 시스템 오류")
|
||||
.kosResponseData(kosResponse)
|
||||
.build();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("KOS 연동 중 예외 발생: requestId={}, lineNumber={}", requestId, request.getLineNumber(), e);
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.success(false)
|
||||
.resultCode("SYSTEM_ERROR")
|
||||
.failureReason("처리 중 시스템 오류 발생")
|
||||
.failureReason("KOS 시스템 연동 중 오류가 발생했습니다: " + e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -531,45 +526,4 @@ public class ProductServiceImpl implements ProductService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 결과 임시 클래스
|
||||
*/
|
||||
private static class ProductChangeResult {
|
||||
private final boolean success;
|
||||
private final String resultCode;
|
||||
private final String resultMessage;
|
||||
private final String failureReason;
|
||||
|
||||
private ProductChangeResult(boolean success, String resultCode, String resultMessage, String failureReason) {
|
||||
this.success = success;
|
||||
this.resultCode = resultCode;
|
||||
this.resultMessage = resultMessage;
|
||||
this.failureReason = failureReason;
|
||||
}
|
||||
|
||||
public static ProductChangeResultBuilder builder() {
|
||||
return new ProductChangeResultBuilder();
|
||||
}
|
||||
|
||||
public boolean isSuccess() { return success; }
|
||||
public String getResultCode() { return resultCode; }
|
||||
public String getResultMessage() { return resultMessage; }
|
||||
public String getFailureReason() { return failureReason; }
|
||||
|
||||
public static class ProductChangeResultBuilder {
|
||||
private boolean success;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private String failureReason;
|
||||
|
||||
public ProductChangeResultBuilder success(boolean success) { this.success = success; return this; }
|
||||
public ProductChangeResultBuilder resultCode(String resultCode) { this.resultCode = resultCode; return this; }
|
||||
public ProductChangeResultBuilder resultMessage(String resultMessage) { this.resultMessage = resultMessage; return this; }
|
||||
public ProductChangeResultBuilder failureReason(String failureReason) { this.failureReason = failureReason; return this; }
|
||||
|
||||
public ProductChangeResult build() {
|
||||
return new ProductChangeResult(success, resultCode, resultMessage, failureReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -59,13 +59,13 @@ public class ProductValidationService {
|
||||
failureReasonBuilder.append("변경 대상 상품이 판매중이 아닙니다. ");
|
||||
}
|
||||
|
||||
// 2. 사업자 일치 확인
|
||||
boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), validationDetails);
|
||||
if (!isOperatorMatch) {
|
||||
overallSuccess = false;
|
||||
failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
|
||||
}
|
||||
// 2. 사업자 일치 확인 (제외됨)
|
||||
// boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
|
||||
// request.getTargetProductCode(), validationDetails);
|
||||
// if (!isOperatorMatch) {
|
||||
// overallSuccess = false;
|
||||
// failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
|
||||
// }
|
||||
|
||||
// 3. 회선 상태 확인
|
||||
boolean isLineStatusValid = validateLineStatus(request.getLineNumber(), validationDetails);
|
||||
|
||||
@@ -1,200 +1,6 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:product_change_db}
|
||||
username: ${DB_USERNAME:phonebill_user}
|
||||
password: ${DB_PASSWORD:phonebill_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
leak-detection-threshold: 60000
|
||||
# JPA 설정
|
||||
jpa:
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
database: ${REDIS_DATABASE:2}
|
||||
|
||||
# Cache 개발 설정 (TTL 단축)
|
||||
cache:
|
||||
redis:
|
||||
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
|
||||
|
||||
# Server 개발 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
error:
|
||||
include-stacktrace: always
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
# Logging 개발 설정
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.phonebill: ${LOG_LEVEL_APP:DEBUG}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
org.springframework.web: DEBUG
|
||||
org.springframework.cache: DEBUG
|
||||
pattern:
|
||||
console: "%clr(%d{HH:mm:ss.SSS}){faint} %clr([%thread]){faint} %clr(%-5level){spring} %clr(%logger{36}){cyan} - %msg%n"
|
||||
|
||||
# Management 개발 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*"
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
show-components: always
|
||||
info:
|
||||
env:
|
||||
enabled: true
|
||||
|
||||
# OpenAPI 개발 설정
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
try-it-out-enabled: true
|
||||
api-docs:
|
||||
enabled: true
|
||||
show-actuator: true
|
||||
|
||||
# Resilience4j 개발 설정 (더 관대한 설정)
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 70
|
||||
minimum-number-of-calls: 3
|
||||
wait-duration-in-open-state: 5s
|
||||
instances:
|
||||
kosClient:
|
||||
failure-rate-threshold: 80
|
||||
wait-duration-in-open-state: 10s
|
||||
|
||||
retry:
|
||||
instances:
|
||||
kosClient:
|
||||
max-attempts: 3
|
||||
wait-duration: 1s
|
||||
|
||||
# KOS Mock 서버 설정 (개발환경용)
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL:http://localhost:9090/kos}
|
||||
connect-timeout: 5s
|
||||
read-timeout: 10s
|
||||
max-retries: 3
|
||||
retry-delay: 1s
|
||||
|
||||
# Mock 모드 설정
|
||||
mock:
|
||||
enabled: ${KOS_MOCK_ENABLED:true}
|
||||
response-delay: 500ms # Mock 응답 지연 시뮬레이션
|
||||
|
||||
endpoints:
|
||||
customer-info: /api/v1/customer/{lineNumber}
|
||||
product-info: /api/v1/product/{productCode}
|
||||
available-products: /api/v1/products/available
|
||||
product-change: /api/v1/product/change
|
||||
|
||||
headers:
|
||||
api-key: ${KOS_API_KEY:dev-api-key}
|
||||
client-id: ${KOS_CLIENT_ID:product-service-dev}
|
||||
|
||||
# 비즈니스 개발 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: ${PRODUCT_CACHE_CUSTOMER_INFO_TTL:600} # 10분 (개발환경에서 단축)
|
||||
product-info-ttl: ${PRODUCT_CACHE_PRODUCT_INFO_TTL:300} # 5분
|
||||
available-products-ttl: ${PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL:1800} # 30분
|
||||
product-status-ttl: ${PRODUCT_CACHE_PRODUCT_STATUS_TTL:300} # 5분
|
||||
line-status-ttl: ${PRODUCT_CACHE_LINE_STATUS_TTL:180} # 3분
|
||||
validation:
|
||||
enabled: ${PRODUCT_VALIDATION_ENABLED:true}
|
||||
strict-mode: ${PRODUCT_VALIDATION_STRICT_MODE:false} # 개발환경에서는 유연하게
|
||||
processing:
|
||||
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:false} # 개발환경에서는 동기 처리
|
||||
|
||||
# 개발용 테스트 데이터
|
||||
test-data:
|
||||
enabled: ${TEST_DATA_ENABLED:true}
|
||||
customers:
|
||||
- lineNumber: "01012345678"
|
||||
customerId: "CUST001"
|
||||
customerName: "홍길동"
|
||||
currentProductCode: "PLAN001"
|
||||
- lineNumber: "01087654321"
|
||||
customerId: "CUST002"
|
||||
customerName: "김철수"
|
||||
currentProductCode: "PLAN002"
|
||||
products:
|
||||
- productCode: "PLAN001"
|
||||
productName: "5G 베이직 플랜"
|
||||
monthlyFee: 45000
|
||||
dataAllowance: "50GB"
|
||||
- productCode: "PLAN002"
|
||||
productName: "5G 프리미엄 플랜"
|
||||
monthlyFee: 65000
|
||||
dataAllowance: "100GB"
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:dev-secret-key-for-testing-only}
|
||||
expiration: ${JWT_EXPIRATION:3600} # 1시간 (개발환경에서 단축)
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:*} # 개발환경에서만 허용
|
||||
|
||||
# DevTools 설정
|
||||
spring.devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
exclude: static/**,public/**,templates/**
|
||||
livereload:
|
||||
enabled: true
|
||||
port: 35729
|
||||
add-properties: true
|
||||
|
||||
# 디버깅 설정
|
||||
debug: false
|
||||
trace: false
|
||||
|
||||
# 개발 환경 정보
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
description: Product-Change Service Development Environment
|
||||
version: ${spring.application.version}
|
||||
encoding: UTF-8
|
||||
java:
|
||||
version: ${java.version}
|
||||
build:
|
||||
artifact: ${project.artifactId:product-service}
|
||||
name: ${project.name:Product Service}
|
||||
version: ${project.version:1.0.0}
|
||||
time: ${build.time:2024-03-15T10:00:00Z}
|
||||
root: INFO
|
||||
com.unicorn.phonebill: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
org.hibernate: DEBUG
|
||||
@@ -1,273 +1,6 @@
|
||||
spring:
|
||||
# Database - 운영환경 (PostgreSQL)
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_product_prod}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
idle-timeout: 300000
|
||||
max-lifetime: 1800000
|
||||
connection-timeout: 20000
|
||||
validation-timeout: 5000
|
||||
leak-detection-threshold: 60000
|
||||
|
||||
# JPA 운영 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
use_sql_comments: false
|
||||
generate_statistics: false
|
||||
|
||||
# Redis - 운영환경 (클러스터)
|
||||
data:
|
||||
redis:
|
||||
cluster:
|
||||
nodes: ${REDIS_CLUSTER_NODES}
|
||||
password: ${REDIS_PASSWORD}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
cluster:
|
||||
refresh:
|
||||
adaptive: true
|
||||
period: 30s
|
||||
pool:
|
||||
max-active: 50
|
||||
max-idle: 20
|
||||
min-idle: 5
|
||||
max-wait: 3000ms
|
||||
|
||||
# Server 운영 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
shutdown: graceful
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 1024
|
||||
tomcat:
|
||||
connection-timeout: 30s
|
||||
max-connections: 8192
|
||||
max-threads: 200
|
||||
min-spare-threads: 10
|
||||
accept-count: 100
|
||||
error:
|
||||
include-stacktrace: never
|
||||
include-message: on-param
|
||||
include-binding-errors: never
|
||||
|
||||
# Graceful Shutdown
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
# Logging 운영 설정
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.unicorn.phonebill: INFO
|
||||
org.springframework.security: WARN
|
||||
org.hibernate: WARN
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||
file:
|
||||
name: /app/logs/product-service.log
|
||||
max-size: 500MB
|
||||
max-history: 30
|
||||
total-size-cap: 10GB
|
||||
logback:
|
||||
rollingpolicy:
|
||||
clean-history-on-start: true
|
||||
|
||||
# Management 운영 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
show-components: never
|
||||
info:
|
||||
enabled: true
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
livenessstate:
|
||||
enabled: true
|
||||
readinessstate:
|
||||
enabled: true
|
||||
metrics:
|
||||
distribution:
|
||||
percentiles:
|
||||
http.server.requests: 0.5, 0.95, 0.99
|
||||
slo:
|
||||
http.server.requests: 50ms, 100ms, 200ms, 500ms, 1s, 2s
|
||||
|
||||
# OpenAPI 운영 설정 (비활성화)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
# Resilience4j 운영 설정
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 50
|
||||
slow-call-duration-threshold: 3s
|
||||
permitted-number-of-calls-in-half-open-state: 5
|
||||
minimum-number-of-calls: 10
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 20
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
failure-rate-threshold: 40
|
||||
wait-duration-in-open-state: 60s
|
||||
minimum-number-of-calls: 20
|
||||
|
||||
retry:
|
||||
configs:
|
||||
default:
|
||||
max-attempts: 3
|
||||
wait-duration: 2s
|
||||
exponential-backoff-multiplier: 2
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
max-attempts: 2
|
||||
wait-duration: 3s
|
||||
|
||||
timelimiter:
|
||||
configs:
|
||||
default:
|
||||
timeout-duration: 8s
|
||||
instances:
|
||||
kosClient:
|
||||
timeout-duration: 15s
|
||||
|
||||
# KOS 서버 설정 (운영환경)
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL}
|
||||
connect-timeout: 10s
|
||||
read-timeout: 30s
|
||||
max-retries: 2
|
||||
retry-delay: 3s
|
||||
|
||||
endpoints:
|
||||
customer-info: /api/v1/customer/{lineNumber}
|
||||
product-info: /api/v1/product/{productCode}
|
||||
available-products: /api/v1/products/available
|
||||
product-change: /api/v1/product/change
|
||||
|
||||
headers:
|
||||
api-key: ${KOS_API_KEY}
|
||||
client-id: ${KOS_CLIENT_ID:product-service}
|
||||
|
||||
# 운영환경 보안 설정
|
||||
ssl:
|
||||
enabled: true
|
||||
trust-store: ${SSL_TRUST_STORE:/app/certs/truststore.jks}
|
||||
trust-store-password: ${SSL_TRUST_STORE_PASSWORD}
|
||||
key-store: ${SSL_KEY_STORE:/app/certs/keystore.jks}
|
||||
key-store-password: ${SSL_KEY_STORE_PASSWORD}
|
||||
|
||||
# 비즈니스 운영 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: 14400 # 4시간
|
||||
product-info-ttl: 7200 # 2시간
|
||||
available-products-ttl: 86400 # 24시간
|
||||
product-status-ttl: 3600 # 1시간
|
||||
line-status-ttl: 1800 # 30분
|
||||
validation:
|
||||
enabled: true
|
||||
strict-mode: true
|
||||
max-retry-attempts: 2
|
||||
validation-timeout: 10s
|
||||
processing:
|
||||
async-enabled: true
|
||||
max-concurrent-requests: 500
|
||||
request-timeout: 60s
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 86400 # 24시간
|
||||
refresh-expiration: 604800 # 7일
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS}
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed-headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Accept
|
||||
- X-Requested-With
|
||||
- X-Forwarded-For
|
||||
- X-Forwarded-Proto
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
|
||||
# 모니터링 설정
|
||||
monitoring:
|
||||
health-check:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
metrics:
|
||||
enabled: true
|
||||
export-interval: 60s
|
||||
alerts:
|
||||
email-enabled: ${ALERT_EMAIL_ENABLED:false}
|
||||
slack-enabled: ${ALERT_SLACK_ENABLED:false}
|
||||
webhook-url: ${ALERT_WEBHOOK_URL:}
|
||||
|
||||
# 운영 환경 정보
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
description: Product-Change Service Production Environment
|
||||
version: ${spring.application.version}
|
||||
environment: production
|
||||
build:
|
||||
artifact: product-service
|
||||
version: ${BUILD_VERSION:1.0.0}
|
||||
time: ${BUILD_TIME}
|
||||
commit: ${GIT_COMMIT:unknown}
|
||||
branch: ${GIT_BRANCH:main}
|
||||
|
||||
# JVM 튜닝 설정 (환경변수로 설정)
|
||||
# JAVA_OPTS=-Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/heapdumps/
|
||||
# -Dspring.profiles.active=prod
|
||||
|
||||
# 외부 의존성 URLs
|
||||
external:
|
||||
auth-service:
|
||||
url: ${AUTH_SERVICE_URL:http://auth-service:8080}
|
||||
bill-inquiry-service:
|
||||
url: ${BILL_INQUIRY_SERVICE_URL:http://bill-inquiry-service:8081}
|
||||
|
||||
# 데이터베이스 마이그레이션 (Flyway)
|
||||
spring:
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
validate-on-migrate: true
|
||||
org.hibernate: WARN
|
||||
@@ -5,69 +5,60 @@ spring:
|
||||
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
|
||||
# Database 기본 설정
|
||||
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:product_change}
|
||||
username: ${DB_USERNAME:product_user}
|
||||
password: ${DB_PASSWORD:product_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
idle-timeout: 300000
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
connection-timeout: 20000
|
||||
validation-timeout: 5000
|
||||
leak-detection-threshold: 60000
|
||||
|
||||
# JPA 기본 설정
|
||||
# JPA 설정
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
use_sql_comments: false
|
||||
jdbc:
|
||||
batch_size: 25
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
connection:
|
||||
provider_disables_autocommit: true
|
||||
|
||||
# Redis 기본 설정
|
||||
provider_disables_autocommit: false
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 2
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
time-between-eviction-runs: 30s
|
||||
|
||||
# Cache 설정
|
||||
database: ${REDIS_DATABASE:2}
|
||||
|
||||
# Cache 개발 설정 (TTL 단축)
|
||||
cache:
|
||||
type: redis
|
||||
cache-names:
|
||||
- customerInfo
|
||||
- productInfo
|
||||
- availableProducts
|
||||
- productStatus
|
||||
- lineStatus
|
||||
redis:
|
||||
time-to-live: 14400000 # 4시간 (ms)
|
||||
cache-null-values: false
|
||||
use-key-prefix: true
|
||||
key-prefix: "product-service:"
|
||||
|
||||
# Security 기본 설정
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: ${JWT_ISSUER_URI:http://localhost:8080/auth}
|
||||
|
||||
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
|
||||
|
||||
# Server 개발 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
error:
|
||||
include-stacktrace: always
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
serialization:
|
||||
@@ -78,22 +69,16 @@ spring:
|
||||
adjust-dates-to-context-time-zone: false
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd'T'HH:mm:ss
|
||||
|
||||
# HTTP 설정
|
||||
webflux: {}
|
||||
|
||||
# Server 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
||||
http2:
|
||||
enabled: true
|
||||
error:
|
||||
include-stacktrace: never
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
# CORS
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
|
||||
|
||||
# JWT 토큰 설정
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800000}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
|
||||
|
||||
# Management & Actuator
|
||||
management:
|
||||
@@ -128,25 +113,6 @@ management:
|
||||
build:
|
||||
enabled: true
|
||||
|
||||
# Logging 설정
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.unicorn.phonebill: ${LOG_LEVEL_APP:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
|
||||
org.hibernate.type: WARN
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE:logs/product-service.log}
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
max-history: 7
|
||||
total-size-cap: 100MB
|
||||
|
||||
# OpenAPI/Swagger 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
@@ -214,42 +180,44 @@ resilience4j:
|
||||
base-config: default
|
||||
timeout-duration: 10s
|
||||
|
||||
# 비즈니스 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: 14400 # 4시간 (초)
|
||||
product-info-ttl: 7200 # 2시간 (초)
|
||||
available-products-ttl: 86400 # 24시간 (초)
|
||||
product-status-ttl: 3600 # 1시간 (초)
|
||||
line-status-ttl: 1800 # 30분 (초)
|
||||
validation:
|
||||
max-retry-attempts: 3
|
||||
validation-timeout: 5s
|
||||
processing:
|
||||
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:true}
|
||||
max-concurrent-requests: ${PRODUCT_PROCESSING_MAX_CONCURRENT_REQUESTS:100}
|
||||
request-timeout: ${PRODUCT_PROCESSING_REQUEST_TIMEOUT:30s}
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:product-service-secret-key-change-in-production}
|
||||
expiration: ${JWT_EXPIRATION:86400} # 24시간
|
||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7일
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
- https://mvno.com
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed-headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Accept
|
||||
- X-Requested-With
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
# KOS Mock 서버 설정
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL:http://localhost:9090}
|
||||
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
|
||||
read-timeout: ${KOS_READ_TIMEOUT:10000}
|
||||
max-retries: ${KOS_MAX_RETRIES:3}
|
||||
retry-delay: ${KOS_RETRY_DELAY:1000}
|
||||
|
||||
# Circuit Breaker 설정
|
||||
circuit-breaker:
|
||||
failure-rate-threshold: ${KOS_CB_FAILURE_RATE:0.5}
|
||||
slow-call-duration-threshold: ${KOS_CB_SLOW_CALL_THRESHOLD:10000}
|
||||
slow-call-rate-threshold: ${KOS_CB_SLOW_CALL_RATE:0.5}
|
||||
sliding-window-size: ${KOS_CB_SLIDING_WINDOW_SIZE:10}
|
||||
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
|
||||
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
|
||||
wait-duration-in-open-state: ${KOS_CB_WAIT_DURATION:60000}
|
||||
|
||||
|
||||
# Logging 운영 설정
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.unicorn.phonebill: INFO
|
||||
com.phonebill.common.security: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
org.hibernate: WARN
|
||||
org.hibernate.resource.transaction: ERROR
|
||||
org.hibernate.internal: ERROR
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||
file:
|
||||
name: logs/product-service.log
|
||||
max-size: 500MB
|
||||
max-history: 30
|
||||
total-size-cap: 10GB
|
||||
logback:
|
||||
rollingpolicy:
|
||||
clean-history-on-start: true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user