mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2025-12-06 08:06:24 +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:
parent
6ca4daed8d
commit
02bcfa5434
18
CLAUDE.md
18
CLAUDE.md
@ -501,13 +501,31 @@ QA Engineer
|
|||||||
- **컴파일**: 최상위 루트에서 `./gradlew {service-name}:compileJava` 명령 사용
|
- **컴파일**: 최상위 루트에서 `./gradlew {service-name}:compileJava` 명령 사용
|
||||||
- **서버 시작**: AI가 직접 서버를 시작하지 말고 반드시 사람에게 요청할것
|
- **서버 시작**: AI가 직접 서버를 시작하지 말고 반드시 사람에게 요청할것
|
||||||
|
|
||||||
|
## JSON 데이터 바인딩 문제
|
||||||
|
- **문제**: DTO에서 JSON 요청 데이터가 바인딩되지 않아 모든 필드가 "필수입니다" 검증 오류 발생
|
||||||
|
- **원인**: Jackson JSON 직렬화/역직렬화 시 명시적 프로퍼티 매핑 누락
|
||||||
|
- **해결책**: DTO 필드에 `@JsonProperty("fieldName")` 어노테이션 추가 필수
|
||||||
|
- **적용**: UserRegistrationRequest, LoginRequest 등 모든 Request DTO에 적용
|
||||||
|
|
||||||
## 실행 프로파일 작성 경험
|
## 실행 프로파일 작성 경험
|
||||||
- **Gradle 실행 프로파일**: Spring Boot가 아닌 Gradle 실행 프로파일 사용 필수
|
- **Gradle 실행 프로파일**: Spring Boot가 아닌 Gradle 실행 프로파일 사용 필수
|
||||||
- **환경변수 매핑**: `<entry key="..." value="..." />` 형태로 환경변수 설정
|
- **환경변수 매핑**: `<entry key="..." value="..." />` 형태로 환경변수 설정
|
||||||
- **컴포넌트 스캔 이슈**: common 모듈의 @Component가 인식되지 않는 경우 발생
|
- **컴포넌트 스캔 이슈**: common 모듈의 @Component가 인식되지 않는 경우 발생
|
||||||
- **의존성 주입 오류**: JwtTokenProvider 빈을 찾을 수 없는 오류 확인됨
|
- **의존성 주입 오류**: JwtTokenProvider 빈을 찾을 수 없는 오류 확인됨
|
||||||
|
|
||||||
|
## Authorization Header 문제
|
||||||
|
- **문제**: Swagger UI에서 생성된 curl 명령에 Authorization 헤더 누락
|
||||||
|
- **원인**: SwaggerConfig의 SecurityRequirement 이름과 Controller의 @SecurityRequirement 이름 불일치
|
||||||
|
- **해결책**: SwaggerConfig의 "Bearer Authentication"을 "bearerAuth"로 통일
|
||||||
|
- **적용**: bill-service, product-service 모두 수정 완료
|
||||||
|
|
||||||
## 백킹서비스 연결 정보
|
## 백킹서비스 연결 정보
|
||||||
- **LoadBalancer External IP**: kubectl 명령으로 실제 IP 확인 후 환경변수 설정
|
- **LoadBalancer External IP**: kubectl 명령으로 실제 IP 확인 후 환경변수 설정
|
||||||
- **DB 연결정보**: 각 서비스별 별도 DB 사용 (auth, bill_inquiry, product_change)
|
- **DB 연결정보**: 각 서비스별 별도 DB 사용 (auth, bill_inquiry, product_change)
|
||||||
- **Redis 공유**: 모든 서비스가 동일한 Redis 인스턴스 사용
|
- **Redis 공유**: 모든 서비스가 동일한 Redis 인스턴스 사용
|
||||||
|
|
||||||
|
## 쿠버네티스 DB 접근 방법
|
||||||
|
- **패스워드 확인**: `kubectl get secret -n {namespace} {secret-name} -o jsonpath='{.data.postgres-password}' | base64 -d`
|
||||||
|
- **환경변수 확인**: `kubectl exec -n {namespace} {pod-name} -c postgresql -- env | grep POSTGRES`
|
||||||
|
- **SQL 실행**: `kubectl exec -n {namespace} {pod-name} -c postgresql -- bash -c 'PGPASSWORD="$POSTGRES_POSTGRES_PASSWORD" psql -U postgres -d {database} -c "{SQL}"'`
|
||||||
|
- **예시**: `kubectl exec -n phonebill-dev product-change-postgres-dev-postgresql-0 -c postgresql -- bash -c 'PGPASSWORD="$POSTGRES_POSTGRES_PASSWORD" psql -U postgres -d product_change_db -c "ALTER TABLE product_change.pc_product_change_history ALTER COLUMN customer_id TYPE VARCHAR(100);"'`
|
||||||
|
|||||||
@ -3,18 +3,15 @@
|
|||||||
<ExternalSystemSettings>
|
<ExternalSystemSettings>
|
||||||
<option name="env">
|
<option name="env">
|
||||||
<map>
|
<map>
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
|
||||||
<entry key="SERVER_PORT" value="8080" />
|
|
||||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
|
||||||
<entry key="AUTH_SERVICE_URL" value="http://localhost:8081" />
|
<entry key="AUTH_SERVICE_URL" value="http://localhost:8081" />
|
||||||
<entry key="BILL_SERVICE_URL" value="http://localhost:8082" />
|
<entry key="BILL_SERVICE_URL" value="http://localhost:8082" />
|
||||||
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
|
||||||
|
<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="PRODUCT_SERVICE_URL" value="http://localhost:8083" />
|
<entry key="PRODUCT_SERVICE_URL" value="http://localhost:8083" />
|
||||||
<entry key="KOS_MOCK_SERVICE_URL" value="http://localhost:8084" />
|
<entry key="SERVER_PORT" value="8080" />
|
||||||
<entry key="LOG_FILE" value="logs/api-gateway.log" />
|
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||||
<entry key="LOG_LEVEL_GATEWAY" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_SPRING_CLOUD_GATEWAY" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_REACTOR_NETTY" value="INFO" />
|
|
||||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
@ -37,4 +34,4 @@
|
|||||||
<RunAsTest>false</RunAsTest>
|
<RunAsTest>false</RunAsTest>
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.unicorn.phonebill.gateway.config;
|
package com.unicorn.phonebill.gateway.config;
|
||||||
|
|
||||||
import com.unicorn.phonebill.gateway.filter.JwtAuthenticationGatewayFilterFactory;
|
import com.unicorn.phonebill.gateway.filter.JwtAuthenticationGatewayFilterFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@ -31,6 +32,9 @@ public class GatewayConfig {
|
|||||||
|
|
||||||
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
|
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-origins")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
public GatewayConfig(JwtAuthenticationGatewayFilterFactory jwtAuthFilter) {
|
public GatewayConfig(JwtAuthenticationGatewayFilterFactory jwtAuthFilter) {
|
||||||
this.jwtAuthFilter = jwtAuthFilter;
|
this.jwtAuthFilter = jwtAuthFilter;
|
||||||
}
|
}
|
||||||
@ -48,28 +52,30 @@ public class GatewayConfig {
|
|||||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||||
return builder.routes()
|
return builder.routes()
|
||||||
// Auth Service 라우팅 (인증 불필요)
|
// Auth Service 라우팅 (인증 불필요)
|
||||||
.route("auth-service", r -> r
|
.route("user-service-login", r -> r
|
||||||
.path("/auth/login", "/auth/refresh")
|
.path("/api/auth/login", "/api/auth/refresh")
|
||||||
.and()
|
.and()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
.uri("lb://auth-service"))
|
.filters(f -> f.rewritePath("/api/auth/(?<segment>.*)", "/auth/${segment}"))
|
||||||
|
.uri("lb://user-service"))
|
||||||
|
|
||||||
// Auth Service 라우팅 (인증 필요)
|
// Auth Service 라우팅 (인증 필요)
|
||||||
.route("auth-service-authenticated", r -> r
|
.route("user-service-authenticated", r -> r
|
||||||
.path("/auth/**")
|
.path("/api/auth/**")
|
||||||
.filters(f -> f
|
.filters(f -> f
|
||||||
|
.rewritePath("/api/auth/(?<segment>.*)", "/auth/${segment}")
|
||||||
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
||||||
.circuitBreaker(cb -> cb
|
.circuitBreaker(cb -> cb
|
||||||
.setName("auth-service-cb")
|
.setName("user-service-cb")
|
||||||
.setFallbackUri("forward:/fallback/auth"))
|
.setFallbackUri("forward:/fallback/auth"))
|
||||||
.retry(retry -> retry
|
.retry(retry -> retry
|
||||||
.setRetries(3)
|
.setRetries(3)
|
||||||
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true)))
|
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true)))
|
||||||
.uri("lb://auth-service"))
|
.uri("lb://user-service"))
|
||||||
|
|
||||||
// Bill-Inquiry Service 라우팅 (인증 필요)
|
// Bill-Inquiry Service 라우팅 (인증 필요)
|
||||||
.route("bill-service", r -> r
|
.route("bill-service", r -> r
|
||||||
.path("/bills/**")
|
.path("/api/v1/bills/**")
|
||||||
.filters(f -> f
|
.filters(f -> f
|
||||||
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
||||||
.circuitBreaker(cb -> cb
|
.circuitBreaker(cb -> cb
|
||||||
@ -131,15 +137,11 @@ public class GatewayConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsWebFilter corsWebFilter() {
|
public CorsWebFilter corsWebFilter() {
|
||||||
CorsConfiguration corsConfig = new CorsConfiguration();
|
CorsConfiguration corsConfig = new CorsConfiguration();
|
||||||
|
|
||||||
// 허용할 Origin 설정
|
// 환경변수에서 허용할 Origin 패턴 설정
|
||||||
corsConfig.setAllowedOriginPatterns(Arrays.asList(
|
String[] origins = allowedOrigins.split(",");
|
||||||
"http://localhost:3000", // React 개발 서버
|
corsConfig.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||||
"http://localhost:3001", // Next.js 개발 서버
|
|
||||||
"https://*.unicorn.com", // 운영 도메인
|
|
||||||
"https://*.phonebill.com" // 운영 도메인
|
|
||||||
));
|
|
||||||
|
|
||||||
// 허용할 HTTP 메서드
|
// 허용할 HTTP 메서드
|
||||||
corsConfig.setAllowedMethods(Arrays.asList(
|
corsConfig.setAllowedMethods(Arrays.asList(
|
||||||
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
|
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
|
||||||
|
|||||||
@ -44,9 +44,9 @@ public class JwtTokenService {
|
|||||||
private final long refreshTokenValidityInSeconds;
|
private final long refreshTokenValidityInSeconds;
|
||||||
|
|
||||||
public JwtTokenService(
|
public JwtTokenService(
|
||||||
@Value("${app.jwt.secret}") String jwtSecret,
|
@Value("${jwt.secret}") String jwtSecret,
|
||||||
@Value("${app.jwt.access-token-validity-in-seconds:1800}") long accessTokenValidityInSeconds,
|
@Value("${jwt.access-token-validity:1800}") long accessTokenValidityInSeconds,
|
||||||
@Value("${app.jwt.refresh-token-validity-in-seconds:86400}") long refreshTokenValidityInSeconds) {
|
@Value("${jwt.refresh-token-validity:86400}") long refreshTokenValidityInSeconds) {
|
||||||
|
|
||||||
this.secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
this.secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||||
this.accessTokenValidityInSeconds = accessTokenValidityInSeconds;
|
this.accessTokenValidityInSeconds = accessTokenValidityInSeconds;
|
||||||
|
|||||||
@ -1,128 +1,10 @@
|
|||||||
# API Gateway 개발 환경 설정
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
spring:
|
|
||||||
|
|
||||||
# Cloud Gateway 개발환경 설정
|
|
||||||
cloud:
|
|
||||||
gateway:
|
|
||||||
default-filters: []
|
|
||||||
globalcors:
|
|
||||||
cors-configurations:
|
|
||||||
'[/**]':
|
|
||||||
allowed-origin-patterns:
|
|
||||||
- "http://localhost:*"
|
|
||||||
- "http://127.0.0.1:*"
|
|
||||||
- "https://localhost:*"
|
|
||||||
allowed-methods: "*"
|
|
||||||
allowed-headers: "*"
|
|
||||||
allow-credentials: true
|
|
||||||
max-age: 86400 # 24시간
|
|
||||||
|
|
||||||
# 개발도구 설정
|
|
||||||
devtools:
|
|
||||||
restart:
|
|
||||||
enabled: true
|
|
||||||
additional-paths: src/main/java,src/main/resources
|
|
||||||
livereload:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# JWT 설정 (개발용 - 더 긴 유효시간)
|
|
||||||
app:
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET:dev-phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-for-development}
|
|
||||||
access-token-validity-in-seconds: 3600 # 1시간 (개발편의성)
|
|
||||||
refresh-token-validity-in-seconds: 172800 # 48시간 (개발편의성)
|
|
||||||
|
|
||||||
# Circuit Breaker 설정 (개발환경 - 더 관대한 설정)
|
|
||||||
resilience4j:
|
|
||||||
circuitbreaker:
|
|
||||||
instances:
|
|
||||||
auth-service-cb:
|
|
||||||
failure-rate-threshold: 80 # 개발환경은 더 관대한 임계값
|
|
||||||
wait-duration-in-open-state: 10s
|
|
||||||
sliding-window-size: 5
|
|
||||||
minimum-number-of-calls: 3
|
|
||||||
bill-service-cb:
|
|
||||||
failure-rate-threshold: 80
|
|
||||||
wait-duration-in-open-state: 10s
|
|
||||||
sliding-window-size: 5
|
|
||||||
minimum-number-of-calls: 3
|
|
||||||
product-service-cb:
|
|
||||||
failure-rate-threshold: 80
|
|
||||||
wait-duration-in-open-state: 10s
|
|
||||||
sliding-window-size: 5
|
|
||||||
minimum-number-of-calls: 3
|
|
||||||
kos-mock-cb:
|
|
||||||
failure-rate-threshold: 90
|
|
||||||
wait-duration-in-open-state: 5s
|
|
||||||
sliding-window-size: 5
|
|
||||||
minimum-number-of-calls: 2
|
|
||||||
|
|
||||||
# Actuator 설정 (개발환경 - 모든 엔드포인트 노출)
|
|
||||||
management:
|
|
||||||
endpoints:
|
|
||||||
web:
|
|
||||||
exposure:
|
|
||||||
include: "*" # 개발환경에서는 모든 엔드포인트 노출
|
|
||||||
base-path: /actuator
|
|
||||||
endpoint:
|
|
||||||
health:
|
|
||||||
show-details: always # 개발환경에서는 상세 정보 항상 표시
|
|
||||||
shutdown:
|
|
||||||
enabled: true # 개발환경에서만 활성화
|
|
||||||
beans:
|
|
||||||
enabled: true
|
|
||||||
env:
|
|
||||||
enabled: true
|
|
||||||
configprops:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# 로깅 설정 (개발환경 - 더 상세한 로그)
|
# 로깅 설정 (개발환경 - 더 상세한 로그)
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:DEBUG}
|
com.unicorn.phonebill.gateway: DEBUG
|
||||||
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
|
org.springframework.cloud.gateway: DEBUG
|
||||||
org.springframework.data.redis: ${LOG_LEVEL_SPRING_DATA_REDIS:DEBUG}
|
org.springframework.data.redis: DEBUG
|
||||||
org.springframework.web.reactive: ${LOG_LEVEL_SPRING_WEB_REACTIVE:DEBUG}
|
org.springframework.web.reactive: DEBUG
|
||||||
reactor.netty.http.client: ${LOG_LEVEL_REACTOR_NETTY_HTTP_CLIENT:DEBUG}
|
reactor.netty.http.client: DEBUG
|
||||||
io.netty.handler.ssl: ${LOG_LEVEL_IO_NETTY_HANDLER_SSL:WARN}
|
io.netty.handler.ssl: DEBUG
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
root: DEBUG
|
||||||
file:
|
|
||||||
name: ${LOG_FILE:logs/api-gateway.log}
|
|
||||||
max-size: ${LOG_FILE_MAX_SIZE:100MB}
|
|
||||||
max-history: ${LOG_FILE_MAX_HISTORY:7}
|
|
||||||
|
|
||||||
# OpenAPI 설정 (개발환경)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
enabled: true
|
|
||||||
path: /v3/api-docs
|
|
||||||
swagger-ui:
|
|
||||||
enabled: true
|
|
||||||
path: /swagger-ui.html
|
|
||||||
try-it-out-enabled: true # 개발환경에서 Try it out 활성화
|
|
||||||
urls:
|
|
||||||
- name: Auth Service (Dev)
|
|
||||||
url: http://localhost:8081/v3/api-docs
|
|
||||||
- name: Bill Service (Dev)
|
|
||||||
url: http://localhost:8082/v3/api-docs
|
|
||||||
- name: Product Service (Dev)
|
|
||||||
url: http://localhost:8083/v3/api-docs
|
|
||||||
- name: KOS Mock Service (Dev)
|
|
||||||
url: http://localhost:8084/v3/api-docs
|
|
||||||
|
|
||||||
# CORS 설정 (개발환경 - 더 관대한 설정) - 이미 위에서 설정됨
|
|
||||||
|
|
||||||
# 개발환경 특성 설정
|
|
||||||
debug: false
|
|
||||||
trace: false
|
|
||||||
|
|
||||||
# 애플리케이션 정보 (개발환경)
|
|
||||||
info:
|
|
||||||
app:
|
|
||||||
environment: development
|
|
||||||
debug-mode: enabled
|
|
||||||
hot-reload: enabled
|
|
||||||
|
|||||||
@ -1,219 +1,9 @@
|
|||||||
# API Gateway 운영 환경 설정
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 8080
|
|
||||||
netty:
|
|
||||||
connection-timeout: 20s
|
|
||||||
idle-timeout: 30s
|
|
||||||
compression:
|
|
||||||
enabled: true
|
|
||||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
|
||||||
http2:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
spring:
|
|
||||||
profiles:
|
|
||||||
active: prod
|
|
||||||
|
|
||||||
# Redis 설정 (운영용) - 현재 사용하지 않음
|
|
||||||
# data:
|
|
||||||
# redis:
|
|
||||||
# host: ${REDIS_HOST:redis-cluster.unicorn.com}
|
|
||||||
# port: ${REDIS_PORT:6379}
|
|
||||||
# database: ${REDIS_DATABASE:0}
|
|
||||||
# password: ${REDIS_PASSWORD}
|
|
||||||
# timeout: 2000ms
|
|
||||||
# ssl: true # 운영환경에서는 SSL 사용
|
|
||||||
# lettuce:
|
|
||||||
# pool:
|
|
||||||
# max-active: 20
|
|
||||||
# max-wait: 2000ms
|
|
||||||
# max-idle: 10
|
|
||||||
# min-idle: 5
|
|
||||||
# cluster:
|
|
||||||
# refresh:
|
|
||||||
# adaptive: true
|
|
||||||
# period: 30s
|
|
||||||
|
|
||||||
# Cloud Gateway 운영환경 설정
|
|
||||||
cloud:
|
|
||||||
gateway:
|
|
||||||
default-filters:
|
|
||||||
# - name: RequestRateLimiter # Redis 사용하지 않으므로 주석처리
|
|
||||||
# args:
|
|
||||||
# redis-rate-limiter.replenishRate: 500 # 운영환경 적정 한도
|
|
||||||
# redis-rate-limiter.burstCapacity: 1000
|
|
||||||
# key-resolver: "#{@userKeyResolver}"
|
|
||||||
- name: RequestSize
|
|
||||||
args:
|
|
||||||
maxSize: 5MB # 요청 크기 제한
|
|
||||||
globalcors:
|
|
||||||
cors-configurations:
|
|
||||||
'[/**]':
|
|
||||||
allowed-origins:
|
|
||||||
- "https://app.phonebill.com"
|
|
||||||
- "https://admin.phonebill.com"
|
|
||||||
- "https://*.unicorn.com"
|
|
||||||
allowed-methods:
|
|
||||||
- GET
|
|
||||||
- POST
|
|
||||||
- PUT
|
|
||||||
- DELETE
|
|
||||||
allowed-headers:
|
|
||||||
- Authorization
|
|
||||||
- Content-Type
|
|
||||||
- X-Requested-With
|
|
||||||
allow-credentials: true
|
|
||||||
max-age: 3600
|
|
||||||
|
|
||||||
# JWT 설정 (운영용 - 보안 강화)
|
|
||||||
app:
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET} # 환경변수에서 주입 (필수)
|
|
||||||
access-token-validity-in-seconds: 1800 # 30분 (보안 강화)
|
|
||||||
refresh-token-validity-in-seconds: 86400 # 24시간
|
|
||||||
|
|
||||||
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
|
|
||||||
resilience4j:
|
|
||||||
circuitbreaker:
|
|
||||||
instances:
|
|
||||||
auth-service-cb:
|
|
||||||
failure-rate-threshold: 50
|
|
||||||
slow-call-rate-threshold: 60
|
|
||||||
slow-call-duration-threshold: 3s
|
|
||||||
wait-duration-in-open-state: 30s
|
|
||||||
sliding-window-size: 100
|
|
||||||
minimum-number-of-calls: 20
|
|
||||||
permitted-number-of-calls-in-half-open-state: 10
|
|
||||||
bill-service-cb:
|
|
||||||
failure-rate-threshold: 50
|
|
||||||
slow-call-rate-threshold: 60
|
|
||||||
slow-call-duration-threshold: 5s
|
|
||||||
wait-duration-in-open-state: 30s
|
|
||||||
sliding-window-size: 100
|
|
||||||
minimum-number-of-calls: 20
|
|
||||||
product-service-cb:
|
|
||||||
failure-rate-threshold: 50
|
|
||||||
slow-call-rate-threshold: 60
|
|
||||||
slow-call-duration-threshold: 5s
|
|
||||||
wait-duration-in-open-state: 30s
|
|
||||||
sliding-window-size: 100
|
|
||||||
minimum-number-of-calls: 20
|
|
||||||
kos-mock-cb:
|
|
||||||
failure-rate-threshold: 60
|
|
||||||
slow-call-rate-threshold: 70
|
|
||||||
slow-call-duration-threshold: 10s
|
|
||||||
wait-duration-in-open-state: 60s
|
|
||||||
sliding-window-size: 50
|
|
||||||
minimum-number-of-calls: 10
|
|
||||||
|
|
||||||
retry:
|
|
||||||
instances:
|
|
||||||
default:
|
|
||||||
max-attempts: 3
|
|
||||||
wait-duration: 1s
|
|
||||||
exponential-backoff-multiplier: 2
|
|
||||||
retry-exceptions:
|
|
||||||
- java.net.ConnectException
|
|
||||||
- java.net.SocketTimeoutException
|
|
||||||
- org.springframework.web.client.ResourceAccessException
|
|
||||||
|
|
||||||
# Actuator 설정 (운영환경 - 보안 강화)
|
|
||||||
management:
|
|
||||||
endpoints:
|
|
||||||
web:
|
|
||||||
exposure:
|
|
||||||
include: health,info,metrics,prometheus,gateway # 필요한 것만 노출
|
|
||||||
base-path: /actuator
|
|
||||||
endpoint:
|
|
||||||
health:
|
|
||||||
show-details: never # 운영환경에서는 상세 정보 숨김
|
|
||||||
show-components: never
|
|
||||||
gateway:
|
|
||||||
enabled: true
|
|
||||||
shutdown:
|
|
||||||
enabled: false # 운영환경에서는 비활성화
|
|
||||||
health:
|
|
||||||
redis:
|
|
||||||
enabled: true
|
|
||||||
circuitbreakers:
|
|
||||||
enabled: true
|
|
||||||
info:
|
|
||||||
env:
|
|
||||||
enabled: false # 환경 정보 숨김
|
|
||||||
java:
|
|
||||||
enabled: true
|
|
||||||
build:
|
|
||||||
enabled: true
|
|
||||||
metrics:
|
|
||||||
export:
|
|
||||||
prometheus:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# 로깅 설정 (운영환경 - 성능 고려)
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.unicorn.phonebill.gateway: INFO
|
com.unicorn.phonebill.gateway: INFO
|
||||||
org.springframework.cloud.gateway: WARN
|
org.springframework.cloud.gateway: INFO
|
||||||
reactor.netty: WARN
|
org.springframework.data.redis: INFO
|
||||||
io.netty: WARN
|
org.springframework.web.reactive: WARN
|
||||||
|
reactor.netty.http.client: WARN
|
||||||
|
io.netty.handler.ssl: WARN
|
||||||
root: WARN
|
root: WARN
|
||||||
file:
|
|
||||||
name: /var/log/api-gateway/api-gateway.log
|
|
||||||
max-size: 500MB
|
|
||||||
max-history: 30
|
|
||||||
pattern:
|
|
||||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
|
||||||
loggers:
|
|
||||||
org.springframework.security: WARN
|
|
||||||
org.springframework.web: WARN
|
|
||||||
|
|
||||||
# OpenAPI 설정 (운영환경 - 비활성화)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
enabled: false # 운영환경에서는 비활성화
|
|
||||||
swagger-ui:
|
|
||||||
enabled: false # 운영환경에서는 비활성화
|
|
||||||
|
|
||||||
# CORS 설정은 위의 spring.cloud.gateway 섹션에서 설정됨
|
|
||||||
|
|
||||||
# 보안 설정
|
|
||||||
security:
|
|
||||||
headers:
|
|
||||||
frame:
|
|
||||||
deny: true
|
|
||||||
content-type:
|
|
||||||
nosniff: true
|
|
||||||
xss:
|
|
||||||
protection: true
|
|
||||||
|
|
||||||
# JVM 튜닝 (운영환경)
|
|
||||||
jvm:
|
|
||||||
heap:
|
|
||||||
initial: 512m
|
|
||||||
maximum: 1024m
|
|
||||||
gc:
|
|
||||||
algorithm: G1GC
|
|
||||||
options:
|
|
||||||
- "-server"
|
|
||||||
- "-XX:+UseG1GC"
|
|
||||||
- "-XX:G1HeapRegionSize=16m"
|
|
||||||
- "-XX:+UseStringDeduplication"
|
|
||||||
- "-XX:+OptimizeStringConcat"
|
|
||||||
- "-XX:+UnlockExperimentalVMOptions"
|
|
||||||
- "-XX:+UseJVMCICompiler"
|
|
||||||
|
|
||||||
# 모니터링 및 트레이싱
|
|
||||||
tracing:
|
|
||||||
enabled: true
|
|
||||||
sampling:
|
|
||||||
probability: 0.1 # 10% 샘플링
|
|
||||||
zipkin:
|
|
||||||
base-url: ${ZIPKIN_BASE_URL:http://zipkin.monitoring.unicorn.com:9411}
|
|
||||||
|
|
||||||
# 애플리케이션 정보 (운영환경)
|
|
||||||
info:
|
|
||||||
app:
|
|
||||||
environment: production
|
|
||||||
security-level: high
|
|
||||||
monitoring: enabled
|
|
||||||
@ -7,15 +7,15 @@ server:
|
|||||||
connection-timeout: ${SERVER_NETTY_CONNECTION_TIMEOUT:30s}
|
connection-timeout: ${SERVER_NETTY_CONNECTION_TIMEOUT:30s}
|
||||||
idle-timeout: ${SERVER_NETTY_IDLE_TIMEOUT:60s}
|
idle-timeout: ${SERVER_NETTY_IDLE_TIMEOUT:60s}
|
||||||
http2:
|
http2:
|
||||||
enabled: ${SERVER_HTTP2_ENABLED:true}
|
enabled: true
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: api-gateway
|
name: api-gateway
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
active: dev
|
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
|
|
||||||
# Spring Cloud Gateway 설정
|
# Spring Cloud Gateway 설정
|
||||||
cloud:
|
cloud:
|
||||||
gateway:
|
gateway:
|
||||||
@ -59,12 +59,15 @@ spring:
|
|||||||
deserialization:
|
deserialization:
|
||||||
fail-on-unknown-properties: false
|
fail-on-unknown-properties: false
|
||||||
|
|
||||||
# JWT 설정
|
# CORS
|
||||||
app:
|
cors:
|
||||||
jwt:
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
|
||||||
secret: ${JWT_SECRET:phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-required}
|
|
||||||
access-token-validity-in-seconds: 1800 # 30분
|
# JWT 토큰 설정
|
||||||
refresh-token-validity-in-seconds: 86400 # 24시간
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:}
|
||||||
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
||||||
|
refresh-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:86400}
|
||||||
|
|
||||||
# 서비스 URL 설정
|
# 서비스 URL 설정
|
||||||
services:
|
services:
|
||||||
@ -142,14 +145,8 @@ management:
|
|||||||
|
|
||||||
# 로깅 설정
|
# 로깅 설정
|
||||||
logging:
|
logging:
|
||||||
level:
|
|
||||||
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:INFO}
|
|
||||||
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
|
|
||||||
reactor.netty: ${LOG_LEVEL_REACTOR_NETTY:INFO}
|
|
||||||
io.netty: ${LOG_LEVEL_IO_NETTY:WARN}
|
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
|
||||||
file:
|
file:
|
||||||
name: ${LOG_FILE:logs/api-gateway.log}
|
name: logs/api-gateway.log
|
||||||
logback:
|
logback:
|
||||||
rollingpolicy:
|
rollingpolicy:
|
||||||
max-file-size: 10MB
|
max-file-size: 10MB
|
||||||
|
|||||||
@ -3,64 +3,35 @@
|
|||||||
<ExternalSystemSettings>
|
<ExternalSystemSettings>
|
||||||
<option name="env">
|
<option name="env">
|
||||||
<map>
|
<map>
|
||||||
<!-- Database Connection -->
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
|
||||||
<entry key="DB_HOST" value="20.249.175.46" />
|
|
||||||
<entry key="DB_PORT" value="5432" />
|
|
||||||
<entry key="DB_NAME" value="bill_inquiry_db" />
|
|
||||||
<entry key="DB_USERNAME" value="bill_inquiry_user" />
|
|
||||||
<entry key="DB_PASSWORD" value="BillUser2025!" />
|
|
||||||
<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="REDIS_DATABASE" value="1" />
|
|
||||||
|
|
||||||
<!-- Server Configuration -->
|
|
||||||
<entry key="SERVER_PORT" value="8082" />
|
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
|
||||||
|
|
||||||
<!-- JWT Configuration -->
|
|
||||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
|
||||||
|
|
||||||
<!-- JPA Configuration -->
|
|
||||||
<entry key="JPA_DDL_AUTO" value="update" />
|
|
||||||
<entry key="SHOW_SQL" value="true" />
|
|
||||||
|
|
||||||
<!-- Logging Configuration -->
|
|
||||||
<entry key="LOG_FILE_NAME" value="logs/bill-service.log" />
|
|
||||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
|
||||||
|
|
||||||
<!-- KOS Mock URL -->
|
|
||||||
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
|
|
||||||
|
|
||||||
<!-- Development optimized settings -->
|
|
||||||
<entry key="JPA_SHOW_SQL" value="true" />
|
|
||||||
<entry key="JPA_FORMAT_SQL" value="true" />
|
|
||||||
<entry key="JPA_SQL_COMMENTS" value="true" />
|
|
||||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
|
||||||
<entry key="LOG_LEVEL_SERVICE" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_REPOSITORY" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_WEB" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_CACHE" value="DEBUG" />
|
|
||||||
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
|
||||||
|
|
||||||
<!-- Connection Pool Settings -->
|
|
||||||
<entry key="DB_MIN_IDLE" value="5" />
|
|
||||||
<entry key="DB_MAX_POOL" value="20" />
|
|
||||||
<entry key="DB_CONNECTION_TIMEOUT" value="30000" />
|
<entry key="DB_CONNECTION_TIMEOUT" value="30000" />
|
||||||
|
<entry key="DB_HOST" value="20.249.175.46" />
|
||||||
<entry key="DB_IDLE_TIMEOUT" value="600000" />
|
<entry key="DB_IDLE_TIMEOUT" value="600000" />
|
||||||
<entry key="DB_MAX_LIFETIME" value="1800000" />
|
<entry key="DB_KIND" value="postgresql" />
|
||||||
<entry key="DB_LEAK_DETECTION" value="60000" />
|
<entry key="DB_LEAK_DETECTION" value="60000" />
|
||||||
|
<entry key="DB_MAX_LIFETIME" value="1800000" />
|
||||||
<!-- Redis Pool Settings -->
|
<entry key="DB_MAX_POOL" value="20" />
|
||||||
|
<entry key="DB_MIN_IDLE" value="5" />
|
||||||
|
<entry key="DB_NAME" value="bill_inquiry_db" />
|
||||||
|
<entry key="DB_PASSWORD" value="BillUser2025!" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_USERNAME" value="bill_inquiry_user" />
|
||||||
|
<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_BASE_URL" value="http://localhost:8084" />
|
||||||
|
<entry key="LOG_FILE_NAME" value="logs/bill-service.log" />
|
||||||
|
<entry key="REDIS_DATABASE" value="1" />
|
||||||
|
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||||
<entry key="REDIS_MAX_ACTIVE" value="8" />
|
<entry key="REDIS_MAX_ACTIVE" value="8" />
|
||||||
<entry key="REDIS_MAX_IDLE" value="8" />
|
<entry key="REDIS_MAX_IDLE" value="8" />
|
||||||
<entry key="REDIS_MIN_IDLE" value="0" />
|
|
||||||
<entry key="REDIS_MAX_WAIT" value="-1" />
|
<entry key="REDIS_MAX_WAIT" value="-1" />
|
||||||
|
<entry key="REDIS_MIN_IDLE" value="0" />
|
||||||
|
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
<entry key="REDIS_TIMEOUT" value="2000" />
|
<entry key="REDIS_TIMEOUT" value="2000" />
|
||||||
|
<entry key="SERVER_PORT" value="8082" />
|
||||||
|
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
@ -80,7 +51,7 @@
|
|||||||
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
<DebugAllEnabled>false</DebugAllEnabled>
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
<ForceTestExec>false</ForceTestExec>
|
<RunAsTest>false</RunAsTest>
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@ -28,6 +28,7 @@ dependencies {
|
|||||||
|
|
||||||
// Common modules (로컬 의존성)
|
// Common modules (로컬 의존성)
|
||||||
implementation project(':common')
|
implementation project(':common')
|
||||||
|
implementation project(':kos-mock')
|
||||||
|
|
||||||
// Test Dependencies (bill service specific)
|
// Test Dependencies (bill service specific)
|
||||||
testImplementation 'org.testcontainers:postgresql'
|
testImplementation 'org.testcontainers:postgresql'
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.phonebill.bill.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA Auditing 설정
|
||||||
|
*
|
||||||
|
* BaseTimeEntity의 @CreatedDate, @LastModifiedDate 자동 설정을 위한 구성
|
||||||
|
* - 엔티티 저장/수정 시 자동으로 시간 정보 설정
|
||||||
|
* - 모든 엔티티의 생성/수정 시간 추적 가능
|
||||||
|
*
|
||||||
|
* @author 이개발(백엔더)
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-09-09
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableJpaAuditing
|
||||||
|
public class JpaAuditingConfig {
|
||||||
|
// JPA Auditing 활성화를 위한 설정 클래스
|
||||||
|
// 별도의 Bean 정의 없이 @EnableJpaAuditing만으로 충분
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.phonebill.bill.config;
|
||||||
|
|
||||||
|
import com.phonebill.common.security.JwtTokenProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 설정
|
||||||
|
*
|
||||||
|
* Bill Service의 JWT 토큰 검증을 위한 설정
|
||||||
|
* User Service와 동일한 시크릿 키를 사용하여 토큰 검증
|
||||||
|
*
|
||||||
|
* @author 이개발(백엔더)
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-09-09
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class JwtConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JwtTokenProvider 빈 생성
|
||||||
|
*
|
||||||
|
* @param secret JWT 시크릿 키
|
||||||
|
* @param expirationInSeconds JWT 만료 시간 (초)
|
||||||
|
* @return JwtTokenProvider 인스턴스
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public JwtTokenProvider jwtTokenProvider(
|
||||||
|
@Value("${jwt.secret:dev-jwt-secret-key-for-development-only}") String secret,
|
||||||
|
@Value("${jwt.access-token-validity:86400000}") long expirationInMillis) {
|
||||||
|
|
||||||
|
// 만료 시간을 초 단위로 변환
|
||||||
|
long expirationInSeconds = expirationInMillis / 1000;
|
||||||
|
|
||||||
|
return new JwtTokenProvider(secret, expirationInSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -98,8 +98,8 @@ public class RedisConfig {
|
|||||||
template.setKeySerializer(new StringRedisSerializer());
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
template.setHashKeySerializer(new StringRedisSerializer());
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
|
||||||
// Value 직렬화: JSON 사용
|
// Value 직렬화: Redis 전용 ObjectMapper 사용
|
||||||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());
|
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(redisObjectMapper());
|
||||||
template.setValueSerializer(jsonSerializer);
|
template.setValueSerializer(jsonSerializer);
|
||||||
template.setHashValueSerializer(jsonSerializer);
|
template.setHashValueSerializer(jsonSerializer);
|
||||||
|
|
||||||
@ -128,8 +128,8 @@ public class RedisConfig {
|
|||||||
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||||
.fromSerializer(new StringRedisSerializer()))
|
.fromSerializer(new StringRedisSerializer()))
|
||||||
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||||
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())))
|
.fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper())));
|
||||||
.disableCachingNullValues(); // null 값 캐싱 비활성화
|
// null 값 캐싱은 @Cacheable unless 조건으로 처리
|
||||||
|
|
||||||
// 캐시별 개별 설정
|
// 캐시별 개별 설정
|
||||||
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
|
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
|
||||||
@ -163,25 +163,24 @@ public class RedisConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ObjectMapper 구성
|
* Redis 전용 ObjectMapper 구성
|
||||||
*
|
*
|
||||||
* @return JSON 직렬화용 ObjectMapper
|
* @return Redis 직렬화용 ObjectMapper (다형성 타입 정보 포함)
|
||||||
*/
|
*/
|
||||||
@Bean
|
private ObjectMapper redisObjectMapper() {
|
||||||
public ObjectMapper objectMapper() {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
// Java Time 모듈 등록 (LocalDateTime 등 지원)
|
// Java Time 모듈 등록 (LocalDateTime 등 지원)
|
||||||
mapper.registerModule(new JavaTimeModule());
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
// 타입 정보 포함 (다형성 지원)
|
// 타입 정보 포함 (다형성 지원) - Redis 캐싱에만 필요
|
||||||
mapper.activateDefaultTyping(
|
mapper.activateDefaultTyping(
|
||||||
LaissezFaireSubTypeValidator.instance,
|
LaissezFaireSubTypeValidator.instance,
|
||||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||||
JsonTypeInfo.As.PROPERTY
|
JsonTypeInfo.As.PROPERTY
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug("ObjectMapper 구성 완료");
|
log.debug("Redis 전용 ObjectMapper 구성 완료");
|
||||||
return mapper;
|
return mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package com.phonebill.bill.config;
|
package com.phonebill.bill.config;
|
||||||
|
|
||||||
|
import com.phonebill.common.security.JwtAuthenticationFilter;
|
||||||
|
import com.phonebill.common.security.JwtTokenProvider;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@ -21,6 +24,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
|||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security 설정
|
* Spring Security 설정
|
||||||
@ -42,6 +46,11 @@ import java.util.Arrays;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-origins")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 보안 필터 체인 구성
|
* 보안 필터 체인 구성
|
||||||
*
|
*
|
||||||
@ -53,76 +62,55 @@ public class SecurityConfig {
|
|||||||
log.info("Security Filter Chain 구성 시작");
|
log.info("Security Filter Chain 구성 시작");
|
||||||
|
|
||||||
http
|
http
|
||||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
// CSRF 비활성화 (JWT 사용으로 불필요)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(csrf -> csrf.disable())
|
||||||
|
|
||||||
// CORS 설정 활성화
|
// CORS 설정
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
|
||||||
// 세션 관리 - Stateless (JWT 사용)
|
// 세션 비활성화 (JWT 기반 Stateless)
|
||||||
.sessionManagement(session ->
|
.sessionManagement(session ->
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
|
||||||
// 요청별 인증/인가 설정
|
// 권한 설정
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(authz -> authz
|
||||||
// 공개 엔드포인트 - 인증 불필요
|
// Public endpoints (인증 불필요)
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
// Health Check
|
"/actuator/health",
|
||||||
"/actuator/**",
|
"/actuator/info",
|
||||||
// Swagger UI
|
"/actuator/prometheus",
|
||||||
"/swagger-ui/**",
|
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
|
"/api-docs/**",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html",
|
||||||
"/swagger-resources/**",
|
"/swagger-resources/**",
|
||||||
"/webjars/**",
|
"/webjars/**"
|
||||||
// 정적 리소스
|
|
||||||
"/favicon.ico",
|
|
||||||
"/error"
|
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
|
||||||
// OPTIONS 요청은 모두 허용 (CORS Preflight)
|
// OPTIONS 요청은 모두 허용 (CORS Preflight)
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
|
||||||
|
// Actuator endpoints (관리용)
|
||||||
|
.requestMatchers("/actuator/**").hasRole("ADMIN")
|
||||||
|
|
||||||
// 요금 조회 API - 인증 필요
|
// 나머지 모든 요청 인증 필요
|
||||||
.requestMatchers("/api/bills/**").authenticated()
|
|
||||||
|
|
||||||
// 나머지 모든 요청 - 인증 필요
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWT 인증 필터 추가
|
// JWT 필터 추가
|
||||||
// TODO: JWT 필터 구현 후 활성화
|
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||||
// .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
|
||||||
|
|
||||||
// 예외 처리
|
// Exception 처리
|
||||||
.exceptionHandling(exception -> exception
|
.exceptionHandling(exceptions -> exceptions
|
||||||
// 인증 실패 시 처리
|
|
||||||
.authenticationEntryPoint((request, response, authException) -> {
|
.authenticationEntryPoint((request, response, authException) -> {
|
||||||
log.warn("인증 실패 - URI: {}, 오류: {}",
|
|
||||||
request.getRequestURI(), authException.getMessage());
|
|
||||||
response.setStatus(401);
|
response.setStatus(401);
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
response.getWriter().write("""
|
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "인증이 필요합니다",
|
|
||||||
"timestamp": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(java.time.LocalDateTime.now()));
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 권한 부족 시 처리
|
|
||||||
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||||
log.warn("접근 거부 - URI: {}, 오류: {}",
|
|
||||||
request.getRequestURI(), accessDeniedException.getMessage());
|
|
||||||
response.setStatus(403);
|
response.setStatus(403);
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
response.getWriter().write("""
|
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
|
||||||
{
|
|
||||||
"success": false,
|
|
||||||
"message": "접근 권한이 없습니다",
|
|
||||||
"timestamp": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(java.time.LocalDateTime.now()));
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -130,99 +118,37 @@ public class SecurityConfig {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Bean
|
||||||
* CORS 설정
|
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||||
*
|
return new JwtAuthenticationFilter(jwtTokenProvider);
|
||||||
* @return CORS 설정 소스
|
}
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
log.debug("CORS 설정 구성 시작");
|
|
||||||
|
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
// 허용할 Origin 설정 (개발환경)
|
|
||||||
configuration.setAllowedOriginPatterns(Arrays.asList(
|
|
||||||
"http://localhost:*",
|
|
||||||
"https://localhost:*",
|
|
||||||
"http://127.0.0.1:*",
|
|
||||||
"https://127.0.0.1:*"
|
|
||||||
// TODO: 운영환경 도메인 추가
|
|
||||||
));
|
|
||||||
|
|
||||||
// 허용할 HTTP 메소드
|
|
||||||
configuration.setAllowedMethods(Arrays.asList(
|
|
||||||
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
|
|
||||||
));
|
|
||||||
|
|
||||||
// 허용할 헤더
|
|
||||||
configuration.setAllowedHeaders(Arrays.asList(
|
|
||||||
"Authorization",
|
|
||||||
"Content-Type",
|
|
||||||
"X-Requested-With",
|
|
||||||
"Accept",
|
|
||||||
"Origin",
|
|
||||||
"Access-Control-Request-Method",
|
|
||||||
"Access-Control-Request-Headers"
|
|
||||||
));
|
|
||||||
|
|
||||||
// 자격 증명 허용 (쿠키, Authorization 헤더 등)
|
|
||||||
configuration.setAllowCredentials(true);
|
|
||||||
|
|
||||||
// Preflight 요청 캐시 시간 (초)
|
|
||||||
configuration.setMaxAge(3600L);
|
|
||||||
|
|
||||||
|
// 환경변수에서 허용할 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);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", configuration);
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
|
||||||
log.debug("CORS 설정 구성 완료");
|
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 인코더 구성
|
|
||||||
*
|
|
||||||
* @return BCrypt 패스워드 인코더
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
log.debug("Password Encoder 구성 - BCrypt 사용");
|
return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
|
||||||
return new BCryptPasswordEncoder();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 매니저 구성
|
|
||||||
*
|
|
||||||
* @param config 인증 설정
|
|
||||||
* @return 인증 매니저
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
log.debug("Authentication Manager 구성");
|
|
||||||
return config.getAuthenticationManager();
|
return config.getAuthenticationManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 인증 필터 구성
|
|
||||||
*
|
|
||||||
* TODO: JWT 토큰 검증 필터 구현
|
|
||||||
*
|
|
||||||
* @return JWT 인증 필터
|
|
||||||
*/
|
|
||||||
// @Bean
|
|
||||||
// public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
|
||||||
// return new JwtAuthenticationFilter();
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 토큰 제공자 구성
|
|
||||||
*
|
|
||||||
* TODO: JWT 토큰 생성/검증 서비스 구현
|
|
||||||
*
|
|
||||||
* @return JWT 토큰 제공자
|
|
||||||
*/
|
|
||||||
// @Bean
|
|
||||||
// public JwtTokenProvider jwtTokenProvider() {
|
|
||||||
// return new JwtTokenProvider();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
@ -39,9 +39,9 @@ public class SwaggerConfig {
|
|||||||
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
._default("8082")
|
._default("8082")
|
||||||
.description("Server port"))))
|
.description("Server port"))))
|
||||||
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
|
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
|
||||||
.components(new Components()
|
.components(new Components()
|
||||||
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
|
.addSecuritySchemes("bearerAuth", createAPIKeyScheme()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Info apiInfo() {
|
private Info apiInfo() {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.phonebill.bill.controller;
|
package com.phonebill.bill.controller;
|
||||||
|
|
||||||
import com.phonebill.bill.dto.*;
|
import com.phonebill.bill.dto.*;
|
||||||
|
import com.phonebill.kosmock.dto.KosCommonResponse;
|
||||||
|
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
|
||||||
import com.phonebill.bill.service.BillInquiryService;
|
import com.phonebill.bill.service.BillInquiryService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@ -32,7 +34,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/bills")
|
@RequestMapping("/api/v1/bills")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Validated
|
@Validated
|
||||||
@Tag(name = "Bill Inquiry", description = "요금조회 관련 API")
|
@Tag(name = "Bill Inquiry", description = "요금조회 관련 API")
|
||||||
@ -115,63 +117,22 @@ public class BillController {
|
|||||||
description = "KOS 시스템 장애 (Circuit Breaker Open)"
|
description = "KOS 시스템 장애 (Circuit Breaker Open)"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
public ResponseEntity<ApiResponse<BillInquiryResponse>> inquireBill(
|
public ResponseEntity<KosCommonResponse<KosBillInquiryResponse>> inquireBill(
|
||||||
@Valid @RequestBody BillInquiryRequest request) {
|
@Valid @RequestBody BillInquiryRequest request) {
|
||||||
log.info("요금조회 요청 - 회선번호: {}, 조회월: {}",
|
log.info("요금조회 요청 - 회선번호: {}, 조회월: {}",
|
||||||
request.getLineNumber(), request.getInquiryMonth());
|
request.getLineNumber(), request.getInquiryMonth());
|
||||||
|
|
||||||
BillInquiryResponse response = billInquiryService.inquireBill(request);
|
KosBillInquiryResponse response = billInquiryService.inquireBill(request);
|
||||||
|
|
||||||
if (response.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
|
||||||
log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
|
response.getRequestId(), request.getLineNumber());
|
||||||
response.getRequestId(), request.getLineNumber());
|
|
||||||
return ResponseEntity.ok(
|
|
||||||
ApiResponse.success(response, "요금조회가 완료되었습니다")
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log.info("요금조회 비동기 처리 - 요청ID: {}, 상태: {}",
|
|
||||||
response.getRequestId(), response.getStatus());
|
|
||||||
return ResponseEntity.accepted().body(
|
|
||||||
ApiResponse.success(response, "요금조회 요청이 접수되었습니다")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요금조회 결과 확인
|
|
||||||
*
|
|
||||||
* 비동기로 처리된 요금조회 결과를 확인합니다.
|
|
||||||
* requestId를 통해 조회 상태와 결과를 반환합니다.
|
|
||||||
*/
|
|
||||||
@GetMapping("/inquiry/{requestId}")
|
|
||||||
@Operation(
|
|
||||||
summary = "요금조회 결과 확인",
|
|
||||||
description = "비동기로 처리된 요금조회의 상태와 결과를 확인합니다.",
|
|
||||||
security = @SecurityRequirement(name = "bearerAuth")
|
|
||||||
)
|
|
||||||
@ApiResponses(value = {
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "요금조회 결과 조회 성공"
|
|
||||||
),
|
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
|
||||||
responseCode = "404",
|
|
||||||
description = "요청 ID를 찾을 수 없음"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
public ResponseEntity<ApiResponse<BillInquiryResponse>> getBillInquiryResult(
|
|
||||||
@Parameter(description = "요금조회 요청 ID", example = "REQ_20240308_001")
|
|
||||||
@PathVariable String requestId) {
|
|
||||||
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
|
|
||||||
|
|
||||||
BillInquiryResponse response = billInquiryService.getBillInquiryResult(requestId);
|
|
||||||
|
|
||||||
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
ApiResponse.success(response, "요금조회 결과를 조회했습니다")
|
KosCommonResponse.success(response, "요금 조회가 완료되었습니다")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요금조회 이력 조회
|
* 요금조회 이력 조회
|
||||||
*
|
*
|
||||||
|
|||||||
@ -13,10 +13,12 @@ import java.time.LocalDateTime;
|
|||||||
*
|
*
|
||||||
* 모든 API 응답에 대한 공통 구조를 제공
|
* 모든 API 응답에 대한 공통 구조를 제공
|
||||||
* - success: 성공/실패 여부
|
* - success: 성공/실패 여부
|
||||||
|
* - resultCode: 결과 코드
|
||||||
|
* - resultMessage: 결과 메시지
|
||||||
* - data: 실제 응답 데이터 (성공시)
|
* - data: 실제 응답 데이터 (성공시)
|
||||||
* - error: 오류 정보 (실패시)
|
* - error: 오류 정보 (실패시)
|
||||||
* - message: 응답 메시지
|
|
||||||
* - timestamp: 응답 시간
|
* - timestamp: 응답 시간
|
||||||
|
* - traceId: 추적 ID
|
||||||
*
|
*
|
||||||
* @param <T> 응답 데이터 타입
|
* @param <T> 응답 데이터 타입
|
||||||
* @author 이개발(백엔더)
|
* @author 이개발(백엔더)
|
||||||
@ -35,6 +37,16 @@ public class ApiResponse<T> {
|
|||||||
*/
|
*/
|
||||||
private boolean success;
|
private boolean success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결과 코드
|
||||||
|
*/
|
||||||
|
private String resultCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결과 메시지
|
||||||
|
*/
|
||||||
|
private String resultMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 응답 데이터 (성공시에만 포함)
|
* 응답 데이터 (성공시에만 포함)
|
||||||
*/
|
*/
|
||||||
@ -45,17 +57,17 @@ public class ApiResponse<T> {
|
|||||||
*/
|
*/
|
||||||
private ErrorDetail error;
|
private ErrorDetail error;
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답 메시지
|
|
||||||
*/
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 응답 시간
|
* 응답 시간
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private LocalDateTime timestamp = LocalDateTime.now();
|
private LocalDateTime timestamp = LocalDateTime.now();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추적 ID
|
||||||
|
*/
|
||||||
|
private String traceId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 성공 응답 생성
|
* 성공 응답 생성
|
||||||
*
|
*
|
||||||
@ -67,8 +79,9 @@ public class ApiResponse<T> {
|
|||||||
public static <T> ApiResponse<T> success(T data, String message) {
|
public static <T> ApiResponse<T> success(T data, String message) {
|
||||||
return ApiResponse.<T>builder()
|
return ApiResponse.<T>builder()
|
||||||
.success(true)
|
.success(true)
|
||||||
|
.resultCode("0000")
|
||||||
|
.resultMessage(message)
|
||||||
.data(data)
|
.data(data)
|
||||||
.message(message)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,8 +106,9 @@ public class ApiResponse<T> {
|
|||||||
public static ApiResponse<Void> failure(ErrorDetail error, String message) {
|
public static ApiResponse<Void> failure(ErrorDetail error, String message) {
|
||||||
return ApiResponse.<Void>builder()
|
return ApiResponse.<Void>builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
|
.resultCode(error.getCode())
|
||||||
|
.resultMessage(message)
|
||||||
.error(error)
|
.error(error)
|
||||||
.message(message)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +124,12 @@ public class ApiResponse<T> {
|
|||||||
.code(code)
|
.code(code)
|
||||||
.message(message)
|
.message(message)
|
||||||
.build();
|
.build();
|
||||||
return failure(error, message);
|
return ApiResponse.<Void>builder()
|
||||||
|
.success(false)
|
||||||
|
.resultCode(code)
|
||||||
|
.resultMessage(message)
|
||||||
|
.error(error)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.phonebill.bill.dto;
|
package com.phonebill.bill.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@ -28,6 +29,7 @@ public class BillInquiryRequest {
|
|||||||
* 조회할 회선번호 (필수)
|
* 조회할 회선번호 (필수)
|
||||||
* 010-XXXX-XXXX 형식만 허용
|
* 010-XXXX-XXXX 형식만 허용
|
||||||
*/
|
*/
|
||||||
|
@JsonProperty("lineNumber")
|
||||||
@NotBlank(message = "회선번호는 필수입니다")
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
@Pattern(
|
@Pattern(
|
||||||
regexp = "^010-\\d{4}-\\d{4}$",
|
regexp = "^010-\\d{4}-\\d{4}$",
|
||||||
@ -37,11 +39,12 @@ public class BillInquiryRequest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 조회월 (선택)
|
* 조회월 (선택)
|
||||||
* YYYY-MM 형식, 미입력시 당월 조회
|
* YYYYMM 형식, 미입력시 당월 조회
|
||||||
*/
|
*/
|
||||||
|
@JsonProperty("inquiryMonth")
|
||||||
@Pattern(
|
@Pattern(
|
||||||
regexp = "^\\d{4}-\\d{2}$",
|
regexp = "^\\d{6}$",
|
||||||
message = "조회월은 YYYY-MM 형식이어야 합니다"
|
message = "조회월은 YYYYMM 형식이어야 합니다"
|
||||||
)
|
)
|
||||||
private String inquiryMonth;
|
private String inquiryMonth;
|
||||||
}
|
}
|
||||||
@ -108,7 +108,7 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.<Map<String, String>>builder()
|
.body(ApiResponse.<Map<String, String>>builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
.data(errors)
|
.data(errors)
|
||||||
.message("입력값이 올바르지 않습니다")
|
.resultMessage("입력값이 올바르지 않습니다")
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
@ -129,7 +129,7 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.<Map<String, String>>builder()
|
.body(ApiResponse.<Map<String, String>>builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
.data(errors)
|
.data(errors)
|
||||||
.message("입력값이 올바르지 않습니다")
|
.resultMessage("입력값이 올바르지 않습니다")
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.<Map<String, String>>builder()
|
.body(ApiResponse.<Map<String, String>>builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
.data(errors)
|
.data(errors)
|
||||||
.message("입력값이 올바르지 않습니다")
|
.resultMessage("입력값이 올바르지 않습니다")
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,17 +27,23 @@ import java.time.LocalDateTime;
|
|||||||
public class KosRequest {
|
public class KosRequest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회선번호 (KOS 필드명: lineNum)
|
* 회선번호 (KOS 필드명: lineNumber)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("lineNum")
|
@JsonProperty("lineNumber")
|
||||||
private String lineNumber;
|
private String lineNumber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조회월 (KOS 필드명: searchMonth, YYYY-MM 형식)
|
* 조회월 (KOS 필드명: billingMonth, YYYYMM 형식)
|
||||||
*/
|
*/
|
||||||
@JsonProperty("searchMonth")
|
@JsonProperty("billingMonth")
|
||||||
private String inquiryMonth;
|
private String inquiryMonth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 ID (KOS 필드명: requestId)
|
||||||
|
*/
|
||||||
|
@JsonProperty("requestId")
|
||||||
|
private String requestId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 구분 코드 (KOS 필드명: svcDiv)
|
* 서비스 구분 코드 (KOS 필드명: svcDiv)
|
||||||
* - BILL_INQ: 요금조회
|
* - BILL_INQ: 요금조회
|
||||||
@ -122,9 +128,14 @@ public class KosRequest {
|
|||||||
* @return KOS 요청 객체
|
* @return KOS 요청 객체
|
||||||
*/
|
*/
|
||||||
public static KosRequest createBillInquiryRequest(String lineNumber, String inquiryMonth, String requestUserId) {
|
public static KosRequest createBillInquiryRequest(String lineNumber, String inquiryMonth, String requestUserId) {
|
||||||
|
// YYYY-MM 형식을 YYYYMM 형식으로 변환
|
||||||
|
String billingMonth = inquiryMonth.replace("-", "");
|
||||||
|
String requestId = "REQ_" + java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
|
||||||
|
|
||||||
return KosRequest.builder()
|
return KosRequest.builder()
|
||||||
.lineNumber(lineNumber)
|
.lineNumber(lineNumber.replace("-", "")) // 하이픈 제거
|
||||||
.inquiryMonth(inquiryMonth)
|
.inquiryMonth(billingMonth)
|
||||||
|
.requestId(requestId)
|
||||||
.requestUserId(requestUserId)
|
.requestUserId(requestUserId)
|
||||||
.requestTime(LocalDateTime.now())
|
.requestTime(LocalDateTime.now())
|
||||||
.requestSequenceNumber(generateSequenceNumber())
|
.requestSequenceNumber(generateSequenceNumber())
|
||||||
|
|||||||
@ -95,12 +95,92 @@ public interface BillInquiryHistoryRepository extends JpaRepository<BillInquiryH
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
"h.lineNumber IN :lineNumbers " +
|
"h.lineNumber IN :lineNumbers " +
|
||||||
"AND (:lineNumber IS NULL OR h.lineNumber = :lineNumber) " +
|
|
||||||
"AND (:startTime IS NULL OR h.requestTime >= :startTime) " +
|
|
||||||
"AND (:endTime IS NULL OR h.requestTime <= :endTime) " +
|
|
||||||
"AND (:status IS NULL OR h.status = :status) " +
|
|
||||||
"ORDER BY h.requestTime DESC")
|
"ORDER BY h.requestTime DESC")
|
||||||
Page<BillInquiryHistoryEntity> findBillHistoryWithFilters(
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbers(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.lineNumber = :lineNumber " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumber(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("lineNumber") String lineNumber,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.requestTime >= :startTime " +
|
||||||
|
"AND h.requestTime <= :endTime " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndDateRange(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.status = :status " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndStatus(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("status") String status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.lineNumber = :lineNumber " +
|
||||||
|
"AND h.requestTime >= :startTime " +
|
||||||
|
"AND h.requestTime <= :endTime " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumberAndDateRange(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("lineNumber") String lineNumber,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.lineNumber = :lineNumber " +
|
||||||
|
"AND h.status = :status " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndLineNumberAndStatus(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("lineNumber") String lineNumber,
|
||||||
|
@Param("status") String status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.requestTime >= :startTime " +
|
||||||
|
"AND h.requestTime <= :endTime " +
|
||||||
|
"AND h.status = :status " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryByLineNumbersAndDateRangeAndStatus(
|
||||||
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime,
|
||||||
|
@Param("status") String status,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||||
|
"h.lineNumber IN :lineNumbers " +
|
||||||
|
"AND h.lineNumber = :lineNumber " +
|
||||||
|
"AND h.requestTime >= :startTime " +
|
||||||
|
"AND h.requestTime <= :endTime " +
|
||||||
|
"AND h.status = :status " +
|
||||||
|
"ORDER BY h.requestTime DESC")
|
||||||
|
Page<BillInquiryHistoryEntity> findBillHistoryWithAllFilters(
|
||||||
@Param("lineNumbers") List<String> lineNumbers,
|
@Param("lineNumbers") List<String> lineNumbers,
|
||||||
@Param("lineNumber") String lineNumber,
|
@Param("lineNumber") String lineNumber,
|
||||||
@Param("startTime") LocalDateTime startTime,
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
|||||||
@ -55,7 +55,7 @@ public class BillCacheService {
|
|||||||
* @param inquiryMonth 조회월
|
* @param inquiryMonth 조회월
|
||||||
* @return 캐시된 요금 데이터 (없으면 null)
|
* @return 캐시된 요금 데이터 (없으면 null)
|
||||||
*/
|
*/
|
||||||
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth")
|
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth", unless = "#result == null")
|
||||||
public BillInquiryResponse getCachedBillData(String lineNumber, String inquiryMonth) {
|
public BillInquiryResponse getCachedBillData(String lineNumber, String inquiryMonth) {
|
||||||
log.debug("요금 데이터 캐시 조회 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
log.debug("요금 데이터 캐시 조회 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||||
|
|
||||||
@ -90,6 +90,12 @@ public class BillCacheService {
|
|||||||
public void cacheBillData(String lineNumber, String inquiryMonth, BillInquiryResponse billData) {
|
public void cacheBillData(String lineNumber, String inquiryMonth, BillInquiryResponse billData) {
|
||||||
log.debug("요금 데이터 캐시 저장 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
log.debug("요금 데이터 캐시 저장 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||||
|
|
||||||
|
// null 값은 캐시하지 않음
|
||||||
|
if (billData == null) {
|
||||||
|
log.debug("요금 데이터가 null이므로 캐시 저장을 건너뜀 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -194,11 +194,9 @@ public class BillHistoryService {
|
|||||||
endDateTime = LocalDate.parse(endDate).atTime(23, 59, 59);
|
endDateTime = LocalDate.parse(endDate).atTime(23, 59, 59);
|
||||||
}
|
}
|
||||||
|
|
||||||
String statusFilter = status != null ? status.name() : null;
|
// 조건에 따라 적절한 쿼리 선택
|
||||||
|
Page<BillInquiryHistoryEntity> historyPage = getBillHistoryByConditions(
|
||||||
// 이력 조회
|
userLineNumbers, lineNumber, startDateTime, endDateTime, status, pageable
|
||||||
Page<BillInquiryHistoryEntity> historyPage = historyRepository.findBillHistoryWithFilters(
|
|
||||||
userLineNumbers, lineNumber, startDateTime, endDateTime, statusFilter, pageable
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 응답 데이터 변환
|
// 응답 데이터 변환
|
||||||
@ -233,6 +231,64 @@ public class BillHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따라 적절한 쿼리를 선택하여 이력 조회
|
||||||
|
*/
|
||||||
|
private Page<BillInquiryHistoryEntity> getBillHistoryByConditions(
|
||||||
|
List<String> userLineNumbers, String lineNumber,
|
||||||
|
LocalDateTime startDateTime, LocalDateTime endDateTime,
|
||||||
|
BillInquiryResponse.ProcessStatus status, Pageable pageable) {
|
||||||
|
|
||||||
|
boolean hasLineNumber = lineNumber != null && !lineNumber.trim().isEmpty();
|
||||||
|
boolean hasDateRange = startDateTime != null && endDateTime != null;
|
||||||
|
boolean hasStatus = status != null;
|
||||||
|
|
||||||
|
String statusFilter = hasStatus ? status.name() : null;
|
||||||
|
|
||||||
|
// 8가지 경우의 수에 따라 적절한 쿼리 선택
|
||||||
|
if (hasLineNumber && hasDateRange && hasStatus) {
|
||||||
|
// 모든 필터 적용
|
||||||
|
return historyRepository.findBillHistoryWithAllFilters(
|
||||||
|
userLineNumbers, lineNumber, startDateTime, endDateTime, statusFilter, pageable
|
||||||
|
);
|
||||||
|
} else if (hasLineNumber && hasDateRange) {
|
||||||
|
// 회선번호 + 날짜 범위
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndLineNumberAndDateRange(
|
||||||
|
userLineNumbers, lineNumber, startDateTime, endDateTime, pageable
|
||||||
|
);
|
||||||
|
} else if (hasLineNumber && hasStatus) {
|
||||||
|
// 회선번호 + 상태
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndLineNumberAndStatus(
|
||||||
|
userLineNumbers, lineNumber, statusFilter, pageable
|
||||||
|
);
|
||||||
|
} else if (hasDateRange && hasStatus) {
|
||||||
|
// 날짜 범위 + 상태
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndDateRangeAndStatus(
|
||||||
|
userLineNumbers, startDateTime, endDateTime, statusFilter, pageable
|
||||||
|
);
|
||||||
|
} else if (hasLineNumber) {
|
||||||
|
// 회선번호만
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndLineNumber(
|
||||||
|
userLineNumbers, lineNumber, pageable
|
||||||
|
);
|
||||||
|
} else if (hasDateRange) {
|
||||||
|
// 날짜 범위만
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndDateRange(
|
||||||
|
userLineNumbers, startDateTime, endDateTime, pageable
|
||||||
|
);
|
||||||
|
} else if (hasStatus) {
|
||||||
|
// 상태만
|
||||||
|
return historyRepository.findBillHistoryByLineNumbersAndStatus(
|
||||||
|
userLineNumbers, statusFilter, pageable
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 필터 없음 (기본)
|
||||||
|
return historyRepository.findBillHistoryByLineNumbers(
|
||||||
|
userLineNumbers, pageable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 엔티티를 이력 아이템으로 변환
|
* 엔티티를 이력 아이템으로 변환
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.phonebill.bill.service;
|
package com.phonebill.bill.service;
|
||||||
|
|
||||||
import com.phonebill.bill.dto.*;
|
import com.phonebill.bill.dto.*;
|
||||||
|
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요금조회 서비스 인터페이스
|
* 요금조회 서비스 인터페이스
|
||||||
@ -41,20 +42,8 @@ public interface BillInquiryService {
|
|||||||
* @param request 요금조회 요청 데이터
|
* @param request 요금조회 요청 데이터
|
||||||
* @return 요금조회 응답 데이터
|
* @return 요금조회 응답 데이터
|
||||||
*/
|
*/
|
||||||
BillInquiryResponse inquireBill(BillInquiryRequest request);
|
KosBillInquiryResponse inquireBill(BillInquiryRequest request);
|
||||||
|
|
||||||
/**
|
|
||||||
* 요금조회 결과 확인
|
|
||||||
*
|
|
||||||
* 비동기로 처리된 요금조회의 상태와 결과를 반환
|
|
||||||
* - PROCESSING: 처리 중 상태
|
|
||||||
* - COMPLETED: 처리 완료 (요금 정보 포함)
|
|
||||||
* - FAILED: 처리 실패 (오류 메시지 포함)
|
|
||||||
*
|
|
||||||
* @param requestId 요금조회 요청 ID
|
|
||||||
* @return 요금조회 응답 데이터
|
|
||||||
*/
|
|
||||||
BillInquiryResponse getBillInquiryResult(String requestId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요금조회 이력 조회
|
* 요금조회 이력 조회
|
||||||
|
|||||||
@ -2,8 +2,12 @@ package com.phonebill.bill.service;
|
|||||||
|
|
||||||
import com.phonebill.bill.dto.*;
|
import com.phonebill.bill.dto.*;
|
||||||
import com.phonebill.bill.exception.BillInquiryException;
|
import com.phonebill.bill.exception.BillInquiryException;
|
||||||
|
import com.phonebill.common.security.UserPrincipal;
|
||||||
|
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -47,12 +51,11 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
log.info("요금조회 메뉴 조회 시작");
|
log.info("요금조회 메뉴 조회 시작");
|
||||||
|
|
||||||
// 현재 인증된 사용자의 고객 정보 조회 (JWT에서 추출)
|
// 현재 인증된 사용자의 고객 정보 조회 (JWT에서 추출)
|
||||||
// TODO: SecurityContext에서 사용자 정보 추출 로직 구현
|
|
||||||
String customerId = getCurrentCustomerId();
|
String customerId = getCurrentCustomerId();
|
||||||
String lineNumber = getCurrentLineNumber();
|
String lineNumber = getCurrentLineNumber();
|
||||||
|
|
||||||
// 조회 가능한 월 목록 생성 (최근 12개월)
|
// 실제 요금 데이터가 있는 월 목록 조회
|
||||||
List<String> availableMonths = generateAvailableMonths();
|
List<String> availableMonths = getAvailableMonthsWithData(lineNumber);
|
||||||
|
|
||||||
// 현재 월
|
// 현재 월
|
||||||
String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||||
@ -77,7 +80,7 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public BillInquiryResponse inquireBill(BillInquiryRequest request) {
|
public KosBillInquiryResponse inquireBill(BillInquiryRequest request) {
|
||||||
log.info("요금조회 요청 처리 시작 - 회선: {}, 조회월: {}",
|
log.info("요금조회 요청 처리 시작 - 회선: {}, 조회월: {}",
|
||||||
request.getLineNumber(), request.getInquiryMonth());
|
request.getLineNumber(), request.getInquiryMonth());
|
||||||
|
|
||||||
@ -87,125 +90,33 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
// 조회월 기본값 설정 (미입력시 당월)
|
// 조회월 기본값 설정 (미입력시 당월)
|
||||||
String inquiryMonth = request.getInquiryMonth();
|
String inquiryMonth = request.getInquiryMonth();
|
||||||
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
|
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
|
||||||
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1단계: 캐시에서 데이터 확인 (Cache-Aside 패턴)
|
// KOS Mock 서비스 직접 호출
|
||||||
BillInquiryResponse cachedResponse = billCacheService.getCachedBillData(
|
KosBillInquiryResponse response = kosClientService.inquireBillFromKosDirect(
|
||||||
request.getLineNumber(), inquiryMonth
|
request.getLineNumber(), inquiryMonth
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cachedResponse != null) {
|
log.info("KOS Mock 요금조회 완료 - 요청ID: {}, 상태: {}",
|
||||||
log.info("캐시에서 요금 데이터 조회 완료 - 요청ID: {}", requestId);
|
response.getRequestId(), response.getProcStatus());
|
||||||
cachedResponse = BillInquiryResponse.builder()
|
return response;
|
||||||
.requestId(requestId)
|
|
||||||
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
|
|
||||||
.billInfo(cachedResponse.getBillInfo())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 이력 저장 (비동기)
|
|
||||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, cachedResponse);
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2단계: KOS 시스템 연동 (Circuit Breaker 적용)
|
|
||||||
CompletableFuture<BillInquiryResponse> kosResponseFuture = kosClientService.inquireBillFromKos(
|
|
||||||
request.getLineNumber(), inquiryMonth
|
|
||||||
);
|
|
||||||
BillInquiryResponse kosResponse;
|
|
||||||
try {
|
|
||||||
kosResponse = kosResponseFuture.get();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new BillInquiryException("요금조회 처리가 중단되었습니다", e);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new BillInquiryException("요금조회 처리 중 오류가 발생했습니다", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kosResponse != null && kosResponse.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
|
||||||
// 3단계: 캐시에 저장 (1시간 TTL)
|
|
||||||
billCacheService.cacheBillData(request.getLineNumber(), inquiryMonth, kosResponse);
|
|
||||||
|
|
||||||
// 응답 데이터 구성
|
|
||||||
BillInquiryResponse response = BillInquiryResponse.builder()
|
|
||||||
.requestId(requestId)
|
|
||||||
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
|
|
||||||
.billInfo(kosResponse.getBillInfo())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 이력 저장 (비동기)
|
|
||||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
|
|
||||||
|
|
||||||
log.info("KOS 연동을 통한 요금조회 완료 - 요청ID: {}", requestId);
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
// KOS에서 비동기 처리 중인 경우
|
|
||||||
BillInquiryResponse response = BillInquiryResponse.builder()
|
|
||||||
.requestId(requestId)
|
|
||||||
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 이력 저장 (처리 중 상태)
|
|
||||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
|
|
||||||
|
|
||||||
log.info("KOS 연동 비동기 처리 - 요청ID: {}", requestId);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("요금조회 처리 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
log.error("KOS Mock 요금조회 실패 - 회선: {}, 오류: {}",
|
||||||
|
request.getLineNumber(), e.getMessage(), e);
|
||||||
|
|
||||||
// 실패 응답 생성
|
// 실패 시 기본 응답 반환
|
||||||
BillInquiryResponse errorResponse = BillInquiryResponse.builder()
|
return KosBillInquiryResponse.builder()
|
||||||
.requestId(requestId)
|
.requestId(requestId)
|
||||||
.status(BillInquiryResponse.ProcessStatus.FAILED)
|
.procStatus("FAILED")
|
||||||
|
.resultCode("9999")
|
||||||
|
.resultMessage("요금 조회 중 오류가 발생했습니다")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// 이력 저장 (실패 상태)
|
|
||||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, errorResponse);
|
|
||||||
|
|
||||||
// 비즈니스 예외는 그대로 던지고, 시스템 예외는 래핑
|
|
||||||
if (e instanceof BillInquiryException) {
|
|
||||||
throw e;
|
|
||||||
} else {
|
|
||||||
throw new BillInquiryException("요금조회 처리 중 시스템 오류가 발생했습니다", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 요금조회 결과 확인
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public BillInquiryResponse getBillInquiryResult(String requestId) {
|
|
||||||
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
|
|
||||||
|
|
||||||
// 이력에서 요청 정보 조회
|
|
||||||
BillInquiryResponse response = billHistoryService.getBillInquiryResult(requestId);
|
|
||||||
|
|
||||||
if (response == null) {
|
|
||||||
throw BillInquiryException.billDataNotFound(requestId, "요청 ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 처리 중인 경우 KOS에서 최신 상태 확인
|
|
||||||
if (response.getStatus() == BillInquiryResponse.ProcessStatus.PROCESSING) {
|
|
||||||
try {
|
|
||||||
BillInquiryResponse latestResponse = kosClientService.checkInquiryStatus(requestId);
|
|
||||||
if (latestResponse != null) {
|
|
||||||
// 상태 업데이트
|
|
||||||
billHistoryService.updateInquiryStatus(requestId, latestResponse);
|
|
||||||
response = latestResponse;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("KOS 상태 확인 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage());
|
|
||||||
// 상태 확인 실패해도 기존 상태 그대로 반환
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요금조회 이력 조회
|
* 요금조회 이력 조회
|
||||||
@ -244,8 +155,21 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
* 현재 인증된 사용자의 고객 ID 조회
|
* 현재 인증된 사용자의 고객 ID 조회
|
||||||
*/
|
*/
|
||||||
private String getCurrentCustomerId() {
|
private String getCurrentCustomerId() {
|
||||||
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 고객 ID 추출
|
// JWT에서 인증된 사용자의 고객 ID 추출
|
||||||
// 현재는 더미 데이터 반환
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
|
||||||
|
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
|
||||||
|
String customerId = userPrincipal.getCustomerId();
|
||||||
|
|
||||||
|
if (customerId != null && !customerId.trim().isEmpty()) {
|
||||||
|
log.debug("사용자 {}의 고객 ID: {}", userPrincipal.getUserId(), customerId);
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 정보가 없거나 고객 ID가 없는 경우 기본값 반환
|
||||||
|
log.warn("사용자의 고객 ID 정보를 찾을 수 없습니다. 기본값 사용");
|
||||||
return "CUST001";
|
return "CUST001";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,8 +177,21 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
* 현재 인증된 사용자의 회선번호 조회
|
* 현재 인증된 사용자의 회선번호 조회
|
||||||
*/
|
*/
|
||||||
private String getCurrentLineNumber() {
|
private String getCurrentLineNumber() {
|
||||||
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 회선번호 추출
|
// JWT에서 인증된 사용자의 회선번호 추출
|
||||||
// 현재는 더미 데이터 반환
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
|
||||||
|
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
|
||||||
|
String lineNumber = userPrincipal.getLineNumber();
|
||||||
|
|
||||||
|
if (lineNumber != null && !lineNumber.trim().isEmpty()) {
|
||||||
|
log.debug("사용자 {}의 회선번호: {}", userPrincipal.getUserId(), lineNumber);
|
||||||
|
return lineNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 정보가 없거나 회선번호가 없는 경우 기본값 반환
|
||||||
|
log.warn("사용자의 회선번호 정보를 찾을 수 없습니다. 기본값 사용");
|
||||||
return "010-1234-5678";
|
return "010-1234-5678";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,22 +199,60 @@ public class BillInquiryServiceImpl implements BillInquiryService {
|
|||||||
* 현재 사용자의 모든 회선번호 목록 조회
|
* 현재 사용자의 모든 회선번호 목록 조회
|
||||||
*/
|
*/
|
||||||
private List<String> getCurrentUserLineNumbers() {
|
private List<String> getCurrentUserLineNumbers() {
|
||||||
// TODO: 사용자 권한에 따른 회선번호 목록 조회
|
// JWT에서 인증된 사용자의 회선번호 추출
|
||||||
// 현재는 더미 데이터 반환
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
List<String> lineNumbers = new ArrayList<>();
|
|
||||||
lineNumbers.add("010-1234-5678");
|
if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal) {
|
||||||
return lineNumbers;
|
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
|
||||||
|
String lineNumber = userPrincipal.getLineNumber();
|
||||||
|
|
||||||
|
if (lineNumber != null) {
|
||||||
|
List<String> lineNumbers = new ArrayList<>();
|
||||||
|
lineNumbers.add(lineNumber);
|
||||||
|
log.debug("사용자 {}의 회선번호: {}", userPrincipal.getUserId(), lineNumber);
|
||||||
|
return lineNumbers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 정보가 없거나 회선번호가 없는 경우 빈 목록 반환
|
||||||
|
log.warn("사용자의 회선번호 정보를 찾을 수 없습니다");
|
||||||
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조회 가능한 월 목록 생성 (최근 12개월)
|
* 실제 요금 데이터가 있는 월 목록 조회
|
||||||
*/
|
*/
|
||||||
private List<String> generateAvailableMonths() {
|
private List<String> getAvailableMonthsWithData(String lineNumber) {
|
||||||
|
try {
|
||||||
|
log.info("회선 {}의 실제 요금 데이터가 있는 월 목록 조회", lineNumber);
|
||||||
|
|
||||||
|
// KOS Mock 서비스를 통해 실제 데이터가 있는 월 목록 조회
|
||||||
|
List<String> availableMonths = kosClientService.getAvailableMonths(lineNumber);
|
||||||
|
|
||||||
|
if (availableMonths == null || availableMonths.isEmpty()) {
|
||||||
|
log.warn("KOS에서 조회 가능한 월 정보가 없음. 기본 최근 3개월 반환");
|
||||||
|
return generateDefaultMonths(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("KOS에서 조회된 데이터 보유 월: {}", availableMonths);
|
||||||
|
return availableMonths;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS 시스템에서 조회 가능한 월 정보 조회 실패: {}", e.getMessage(), e);
|
||||||
|
// 실패 시 기본 최근 3개월 반환
|
||||||
|
return generateDefaultMonths(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 월 목록 생성 (fallback용)
|
||||||
|
*/
|
||||||
|
private List<String> generateDefaultMonths(int monthCount) {
|
||||||
List<String> months = new ArrayList<>();
|
List<String> months = new ArrayList<>();
|
||||||
LocalDate currentDate = LocalDate.now();
|
LocalDate currentDate = LocalDate.now();
|
||||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||||
|
|
||||||
for (int i = 0; i < 12; i++) {
|
for (int i = 0; i < monthCount; i++) {
|
||||||
LocalDate monthDate = currentDate.minusMonths(i);
|
LocalDate monthDate = currentDate.minusMonths(i);
|
||||||
months.add(monthDate.format(formatter));
|
months.add(monthDate.format(formatter));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import com.phonebill.bill.exception.CircuitBreakerException;
|
|||||||
import com.phonebill.bill.exception.KosConnectionException;
|
import com.phonebill.bill.exception.KosConnectionException;
|
||||||
import com.phonebill.bill.external.KosRequest;
|
import com.phonebill.bill.external.KosRequest;
|
||||||
import com.phonebill.bill.external.KosResponse;
|
import com.phonebill.bill.external.KosResponse;
|
||||||
|
import com.phonebill.kosmock.dto.KosCommonResponse;
|
||||||
|
import com.phonebill.kosmock.dto.KosBillInquiryResponse;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.Map;
|
||||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
import io.github.resilience4j.retry.annotation.Retry;
|
||||||
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
|
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
|
||||||
@ -48,7 +52,146 @@ public class KosClientService {
|
|||||||
private final KosProperties kosProperties;
|
private final KosProperties kosProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOS 시스템에서 요금 정보 조회
|
* KOS Mock 시스템에서 요금 정보 조회 (KosBillInquiryResponse 직접 반환)
|
||||||
|
*
|
||||||
|
* @param lineNumber 회선번호
|
||||||
|
* @param inquiryMonth 조회월
|
||||||
|
* @return KOS 원본 응답 데이터
|
||||||
|
*/
|
||||||
|
@CircuitBreaker(name = "kos-bill-inquiry-direct", fallbackMethod = "inquireBillDirectFallback")
|
||||||
|
@Retry(name = "kos-bill-inquiry-direct")
|
||||||
|
public KosBillInquiryResponse inquireBillFromKosDirect(String lineNumber, String inquiryMonth) {
|
||||||
|
log.info("KOS Mock 직접 호출 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 회선번호 형식 변환 (010-1234-5678 → 01012345678)
|
||||||
|
String formattedLineNumber = lineNumber.replaceAll("-", "");
|
||||||
|
|
||||||
|
// KOS Mock 요청 데이터 구성 (KosBillInquiryRequest 형식)
|
||||||
|
Map<String, Object> kosRequest = Map.of(
|
||||||
|
"lineNumber", formattedLineNumber,
|
||||||
|
"billingMonth", inquiryMonth,
|
||||||
|
"requestId", generateRequestId()
|
||||||
|
);
|
||||||
|
|
||||||
|
// HTTP 헤더 설정
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
|
||||||
|
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(kosRequest, headers);
|
||||||
|
|
||||||
|
// KOS Mock API 호출
|
||||||
|
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
|
||||||
|
ResponseEntity<Map> responseEntity = restTemplate.exchange(
|
||||||
|
kosUrl, HttpMethod.POST, requestEntity, Map.class
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> response = responseEntity.getBody();
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||||
|
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// KosCommonResponse의 data 부분에서 KosBillInquiryResponse 추출
|
||||||
|
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||||
|
if (data == null) {
|
||||||
|
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||||
|
"NO_DATA", "응답에서 data를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// KosBillInquiryResponse 객체 구성
|
||||||
|
KosBillInquiryResponse result = convertMapToKosBillInquiryResponse(data);
|
||||||
|
|
||||||
|
log.info("KOS Mock 직접 호출 성공 - 요청ID: {}", result.getRequestId());
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS Mock 직접 호출 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
|
||||||
|
throw new KosConnectionException("KOS-BILL-INQUIRY-DIRECT",
|
||||||
|
"KOS Mock 시스템 연동 중 오류가 발생했습니다", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 시스템에서 요금 정보 조회 (동기 처리)
|
||||||
|
*
|
||||||
|
* Circuit Breaker, Retry 패턴 적용
|
||||||
|
*
|
||||||
|
* @param lineNumber 회선번호
|
||||||
|
* @param inquiryMonth 조회월
|
||||||
|
* @return 요금조회 응답
|
||||||
|
*/
|
||||||
|
@CircuitBreaker(name = "kos-bill-inquiry", fallbackMethod = "inquireBillSyncFallback")
|
||||||
|
@Retry(name = "kos-bill-inquiry")
|
||||||
|
public BillInquiryResponse inquireBillFromKosSync(String lineNumber, String inquiryMonth) {
|
||||||
|
log.info("KOS 요금조회 요청 (동기) - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// KOS 요청 데이터 구성
|
||||||
|
KosRequest kosRequest = KosRequest.createBillInquiryRequest(lineNumber, inquiryMonth, "system");
|
||||||
|
|
||||||
|
log.info("KOS Mock으로 전송하는 요청: lineNumber={}, inquiryMonth={}, requestId={}",
|
||||||
|
kosRequest.getLineNumber(), kosRequest.getInquiryMonth(), kosRequest.getRequestId());
|
||||||
|
|
||||||
|
// HTTP 헤더 설정
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
|
||||||
|
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
|
||||||
|
|
||||||
|
// KOS API 호출 (KOS Mock 응답 구조에 맞게 수정)
|
||||||
|
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
|
||||||
|
ResponseEntity<Map> responseEntity = restTemplate.exchange(
|
||||||
|
kosUrl, HttpMethod.POST, requestEntity, Map.class
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> kosCommonResponse = responseEntity.getBody();
|
||||||
|
|
||||||
|
log.info("KOS Mock 응답 받음: {}", kosCommonResponse);
|
||||||
|
|
||||||
|
if (kosCommonResponse == null) {
|
||||||
|
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||||
|
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// KOS Mock 응답을 내부 모델로 변환
|
||||||
|
BillInquiryResponse response = convertKosMockResponseToBillResponse(kosCommonResponse);
|
||||||
|
|
||||||
|
log.info("KOS 요금조회 성공 (동기) - 회선: {}, 조회월: {}, 상태: {}",
|
||||||
|
lineNumber, inquiryMonth, response.getStatus());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (HttpClientErrorException e) {
|
||||||
|
log.error("KOS API 클라이언트 오류 - 회선: {}, 상태: {}, 응답: {}",
|
||||||
|
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||||
|
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||||
|
|
||||||
|
} catch (HttpServerErrorException e) {
|
||||||
|
log.error("KOS API 서버 오류 - 회선: {}, 상태: {}, 응답: {}",
|
||||||
|
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||||
|
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||||
|
|
||||||
|
} catch (ResourceAccessException e) {
|
||||||
|
log.error("KOS 네트워크 연결 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
|
||||||
|
throw KosConnectionException.networkError("KOS-BILL-INQUIRY", e);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS 연동 중 예상치 못한 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
|
||||||
|
throw new KosConnectionException("KOS-BILL-INQUIRY",
|
||||||
|
"KOS 시스템 연동 중 오류가 발생했습니다", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 시스템에서 요금 정보 조회 (비동기 처리)
|
||||||
*
|
*
|
||||||
* Circuit Breaker, Retry, TimeLimiter 패턴 적용
|
* Circuit Breaker, Retry, TimeLimiter 패턴 적용
|
||||||
*
|
*
|
||||||
@ -65,11 +208,7 @@ public class KosClientService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// KOS 요청 데이터 구성
|
// KOS 요청 데이터 구성
|
||||||
KosRequest kosRequest = KosRequest.builder()
|
KosRequest kosRequest = KosRequest.createBillInquiryRequest(lineNumber, inquiryMonth, "system");
|
||||||
.lineNumber(lineNumber)
|
|
||||||
.inquiryMonth(inquiryMonth)
|
|
||||||
.requestTime(LocalDateTime.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// HTTP 헤더 설정
|
// HTTP 헤더 설정
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@ -80,7 +219,7 @@ public class KosClientService {
|
|||||||
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
|
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
|
||||||
|
|
||||||
// KOS API 호출
|
// KOS API 호출
|
||||||
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/inquiry";
|
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/inquiry";
|
||||||
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
|
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
|
||||||
kosUrl, HttpMethod.POST, requestEntity, KosResponse.class
|
kosUrl, HttpMethod.POST, requestEntity, KosResponse.class
|
||||||
);
|
);
|
||||||
@ -124,6 +263,27 @@ public class KosClientService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 요금조회 동기 처리 Circuit Breaker Fallback 메소드
|
||||||
|
*/
|
||||||
|
public BillInquiryResponse inquireBillSyncFallback(String lineNumber, String inquiryMonth, Exception ex) {
|
||||||
|
log.warn("KOS 요금조회 동기 처리 Circuit Breaker 작동 - 회선: {}, 조회월: {}, 오류: {}",
|
||||||
|
lineNumber, inquiryMonth, ex.getMessage());
|
||||||
|
|
||||||
|
// Circuit Breaker가 Open 상태인 경우
|
||||||
|
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
|
||||||
|
throw CircuitBreakerException.circuitBreakerOpen("KOS-BILL-INQUIRY");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 오류의 경우 실패 처리로 전환
|
||||||
|
BillInquiryResponse fallbackResponse = BillInquiryResponse.builder()
|
||||||
|
.status(BillInquiryResponse.ProcessStatus.FAILED)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("KOS 요금조회 동기 처리 fallback 응답 - 실패 처리로 전환");
|
||||||
|
return fallbackResponse;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOS 요금조회 Circuit Breaker Fallback 메소드
|
* KOS 요금조회 Circuit Breaker Fallback 메소드
|
||||||
*/
|
*/
|
||||||
@ -203,29 +363,147 @@ public class KosClientService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS Mock 응답을 내부 응답 모델로 변환
|
||||||
|
*/
|
||||||
|
private BillInquiryResponse convertKosMockResponseToBillResponse(Map<String, Object> kosCommonResponse) {
|
||||||
|
try {
|
||||||
|
// KosCommonResponse에서 success와 data 추출
|
||||||
|
Boolean success = (Boolean) kosCommonResponse.get("success");
|
||||||
|
String resultCode = (String) kosCommonResponse.get("resultCode");
|
||||||
|
Map<String, Object> data = (Map<String, Object>) kosCommonResponse.get("data");
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(success) || !"0000".equals(resultCode) || data == null) {
|
||||||
|
log.warn("KOS Mock 요금조회 실패 - success: {}, resultCode: {}", success, resultCode);
|
||||||
|
return BillInquiryResponse.builder()
|
||||||
|
.requestId(data != null ? (String) data.get("requestId") : null)
|
||||||
|
.status(BillInquiryResponse.ProcessStatus.FAILED)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// data에서 실제 요금 정보 추출
|
||||||
|
String procStatus = (String) data.get("procStatus");
|
||||||
|
Map<String, Object> billInfo = (Map<String, Object>) data.get("billInfo");
|
||||||
|
|
||||||
|
// 상태 변환
|
||||||
|
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.COMPLETED;
|
||||||
|
if ("SUCCESS".equalsIgnoreCase(procStatus)) {
|
||||||
|
status = BillInquiryResponse.ProcessStatus.COMPLETED;
|
||||||
|
} else if ("PROCESSING".equalsIgnoreCase(procStatus)) {
|
||||||
|
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||||
|
} else if ("FAILED".equalsIgnoreCase(procStatus)) {
|
||||||
|
status = BillInquiryResponse.ProcessStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
BillInquiryResponse.BillInfo convertedBillInfo = null;
|
||||||
|
if (billInfo != null && status == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
||||||
|
// 할인 정보 처리
|
||||||
|
List<BillInquiryResponse.DiscountInfo> discounts = new ArrayList<>();
|
||||||
|
Object discountAmount = billInfo.get("discountAmount");
|
||||||
|
if (discountAmount != null && convertToInteger(discountAmount) > 0) {
|
||||||
|
discounts.add(BillInquiryResponse.DiscountInfo.builder()
|
||||||
|
.name("기본 할인")
|
||||||
|
.amount(convertToInteger(discountAmount))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
convertedBillInfo = BillInquiryResponse.BillInfo.builder()
|
||||||
|
.productName((String) billInfo.get("productName"))
|
||||||
|
.contractInfo((String) billInfo.get("lineNumber"))
|
||||||
|
.billingMonth((String) billInfo.get("billingMonth"))
|
||||||
|
.totalAmount(convertToInteger(billInfo.get("totalFee")))
|
||||||
|
.discountInfo(discounts)
|
||||||
|
.usage(BillInquiryResponse.UsageInfo.builder()
|
||||||
|
.voice((String) billInfo.get("voiceUsage"))
|
||||||
|
.sms((String) billInfo.get("smsUsage"))
|
||||||
|
.data((String) billInfo.get("dataUsage"))
|
||||||
|
.build())
|
||||||
|
.terminationFee(0) // KOS Mock에서 기본값
|
||||||
|
.deviceInstallment(0) // KOS Mock에서 기본값
|
||||||
|
.paymentInfo(BillInquiryResponse.PaymentInfo.builder()
|
||||||
|
.billingDate((String) billInfo.get("dueDate"))
|
||||||
|
.paymentStatus(getBillPaymentStatus((String) billInfo.get("billStatus")))
|
||||||
|
.paymentMethod("자동이체")
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return BillInquiryResponse.builder()
|
||||||
|
.requestId((String) data.get("requestId"))
|
||||||
|
.status(status)
|
||||||
|
.billInfo(convertedBillInfo)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS Mock 응답 변환 오류: {}", e.getMessage(), e);
|
||||||
|
throw KosConnectionException.dataConversionError("KOS-BILL-INQUIRY", "BillInquiryResponse", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BigDecimal이나 다른 타입을 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS Mock의 billStatus를 PaymentStatus로 변환
|
||||||
|
*/
|
||||||
|
private BillInquiryResponse.PaymentStatus getBillPaymentStatus(String billStatus) {
|
||||||
|
if (billStatus == null) return BillInquiryResponse.PaymentStatus.UNPAID;
|
||||||
|
|
||||||
|
switch (billStatus.toUpperCase()) {
|
||||||
|
case "PAID":
|
||||||
|
case "CONFIRMED":
|
||||||
|
return BillInquiryResponse.PaymentStatus.PAID;
|
||||||
|
case "UNPAID":
|
||||||
|
return BillInquiryResponse.PaymentStatus.UNPAID;
|
||||||
|
case "OVERDUE":
|
||||||
|
return BillInquiryResponse.PaymentStatus.OVERDUE;
|
||||||
|
default:
|
||||||
|
return BillInquiryResponse.PaymentStatus.UNPAID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOS 응답을 내부 응답 모델로 변환
|
* KOS 응답을 내부 응답 모델로 변환
|
||||||
*/
|
*/
|
||||||
private BillInquiryResponse convertKosResponseToBillResponse(KosResponse kosResponse) {
|
private BillInquiryResponse convertKosResponseToBillResponse(KosResponse kosResponse) {
|
||||||
try {
|
try {
|
||||||
// 상태 변환
|
// 상태 변환 - null 체크 추가
|
||||||
BillInquiryResponse.ProcessStatus status;
|
BillInquiryResponse.ProcessStatus status;
|
||||||
switch (kosResponse.getStatus().toUpperCase()) {
|
String kosStatus = kosResponse.getStatus();
|
||||||
case "SUCCESS":
|
if (kosStatus == null || kosStatus.trim().isEmpty()) {
|
||||||
case "COMPLETED":
|
log.warn("KOS 응답 상태가 null이거나 빈 문자열입니다. 기본값(PROCESSING)으로 설정합니다.");
|
||||||
status = BillInquiryResponse.ProcessStatus.COMPLETED;
|
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||||
break;
|
} else {
|
||||||
case "PROCESSING":
|
switch (kosStatus.toUpperCase()) {
|
||||||
case "PENDING":
|
case "SUCCESS":
|
||||||
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
case "COMPLETED":
|
||||||
break;
|
status = BillInquiryResponse.ProcessStatus.COMPLETED;
|
||||||
case "FAILED":
|
break;
|
||||||
case "ERROR":
|
case "PROCESSING":
|
||||||
status = BillInquiryResponse.ProcessStatus.FAILED;
|
case "PENDING":
|
||||||
break;
|
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||||
default:
|
break;
|
||||||
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
case "FAILED":
|
||||||
break;
|
case "ERROR":
|
||||||
|
status = BillInquiryResponse.ProcessStatus.FAILED;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.warn("알 수 없는 KOS 상태: {}. 기본값(PROCESSING)으로 설정합니다.", kosStatus);
|
||||||
|
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BillInquiryResponse.BillInfo billInfo = null;
|
BillInquiryResponse.BillInfo billInfo = null;
|
||||||
@ -304,6 +582,165 @@ public class KosClientService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS Mock 직접 호출 Circuit Breaker Fallback 메소드
|
||||||
|
*/
|
||||||
|
public KosBillInquiryResponse inquireBillDirectFallback(String lineNumber, String inquiryMonth, Exception ex) {
|
||||||
|
log.warn("KOS Mock 직접 호출 Circuit Breaker 작동 - 회선: {}, 조회월: {}, 오류: {}",
|
||||||
|
lineNumber, inquiryMonth, ex.getMessage());
|
||||||
|
|
||||||
|
// 기본 실패 응답 생성
|
||||||
|
return KosBillInquiryResponse.builder()
|
||||||
|
.requestId(generateRequestId())
|
||||||
|
.procStatus("FAILED")
|
||||||
|
.resultCode("9999")
|
||||||
|
.resultMessage("시스템 오류로 인한 조회 실패")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map을 KosBillInquiryResponse로 변환
|
||||||
|
*/
|
||||||
|
private KosBillInquiryResponse convertMapToKosBillInquiryResponse(Map<String, Object> data) {
|
||||||
|
try {
|
||||||
|
// billInfo 데이터 처리
|
||||||
|
KosBillInquiryResponse.BillInfo billInfo = null;
|
||||||
|
Map<String, Object> billInfoMap = (Map<String, Object>) data.get("billInfo");
|
||||||
|
if (billInfoMap != null) {
|
||||||
|
billInfo = KosBillInquiryResponse.BillInfo.builder()
|
||||||
|
.lineNumber((String) billInfoMap.get("lineNumber"))
|
||||||
|
.billingMonth((String) billInfoMap.get("billingMonth"))
|
||||||
|
.productCode((String) billInfoMap.get("productCode"))
|
||||||
|
.productName((String) billInfoMap.get("productName"))
|
||||||
|
.monthlyFee(convertToBigDecimal(billInfoMap.get("monthlyFee")))
|
||||||
|
.usageFee(convertToBigDecimal(billInfoMap.get("usageFee")))
|
||||||
|
.discountAmount(convertToBigDecimal(billInfoMap.get("discountAmount")))
|
||||||
|
.totalFee(convertToBigDecimal(billInfoMap.get("totalFee")))
|
||||||
|
.dataUsage((String) billInfoMap.get("dataUsage"))
|
||||||
|
.voiceUsage((String) billInfoMap.get("voiceUsage"))
|
||||||
|
.smsUsage((String) billInfoMap.get("smsUsage"))
|
||||||
|
.billStatus((String) billInfoMap.get("billStatus"))
|
||||||
|
.dueDate((String) billInfoMap.get("dueDate"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// customerInfo 데이터 처리
|
||||||
|
KosBillInquiryResponse.CustomerInfo customerInfo = null;
|
||||||
|
Map<String, Object> customerInfoMap = (Map<String, Object>) data.get("customerInfo");
|
||||||
|
if (customerInfoMap != null) {
|
||||||
|
customerInfo = KosBillInquiryResponse.CustomerInfo.builder()
|
||||||
|
.customerName((String) customerInfoMap.get("customerName"))
|
||||||
|
.customerId((String) customerInfoMap.get("customerId"))
|
||||||
|
.operatorCode((String) customerInfoMap.get("operatorCode"))
|
||||||
|
.lineStatus((String) customerInfoMap.get("lineStatus"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return KosBillInquiryResponse.builder()
|
||||||
|
.requestId((String) data.get("requestId"))
|
||||||
|
.procStatus((String) data.get("procStatus"))
|
||||||
|
.resultCode((String) data.get("resultCode"))
|
||||||
|
.resultMessage((String) data.get("resultMessage"))
|
||||||
|
.billInfo(billInfo)
|
||||||
|
.customerInfo(customerInfo)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Map을 KosBillInquiryResponse로 변환 실패: {}", e.getMessage(), e);
|
||||||
|
throw new RuntimeException("응답 데이터 변환 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object를 BigDecimal로 변환
|
||||||
|
*/
|
||||||
|
private java.math.BigDecimal convertToBigDecimal(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value instanceof java.math.BigDecimal) return (java.math.BigDecimal) value;
|
||||||
|
if (value instanceof Number) return java.math.BigDecimal.valueOf(((Number) value).doubleValue());
|
||||||
|
try {
|
||||||
|
return new java.math.BigDecimal(value.toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("BigDecimal 변환 실패: {}", value);
|
||||||
|
return java.math.BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 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("REQ_%s_%s", currentDate, uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호의 실제 요금 데이터가 있는 월 목록 조회
|
||||||
|
*
|
||||||
|
* @param lineNumber 회선번호
|
||||||
|
* @return 데이터가 있는 월 목록 (yyyy-MM 형식)
|
||||||
|
*/
|
||||||
|
@CircuitBreaker(name = "kos-available-months", fallbackMethod = "getAvailableMonthsFallback")
|
||||||
|
@Retry(name = "kos-available-months")
|
||||||
|
public List<String> getAvailableMonths(String lineNumber) {
|
||||||
|
log.info("KOS에서 회선 {}의 데이터 보유 월 조회", lineNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 회선번호 형식 변환 (010-1234-5678 → 01012345678)
|
||||||
|
String formattedLineNumber = lineNumber.replaceAll("-", "");
|
||||||
|
|
||||||
|
// HTTP 헤더 설정
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
|
||||||
|
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
|
||||||
|
|
||||||
|
// KOS Mock API 호출 - 월 목록 조회
|
||||||
|
String kosUrl = kosProperties.getBaseUrl() + "/api/v1/kos/bill/available-months/" + formattedLineNumber;
|
||||||
|
ResponseEntity<Map> responseEntity = restTemplate.exchange(
|
||||||
|
kosUrl, HttpMethod.GET, requestEntity, Map.class
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> response = responseEntity.getBody();
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("KOS에서 월 목록 응답이 없음");
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// KosCommonResponse의 data 부분에서 월 목록 추출
|
||||||
|
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||||
|
if (data == null) {
|
||||||
|
log.warn("KOS 응답에서 data를 찾을 수 없음");
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> availableMonths = (List<String>) data.get("availableMonths");
|
||||||
|
if (availableMonths == null) {
|
||||||
|
availableMonths = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("KOS에서 조회된 데이터 보유 월: {} (총 {}개월)", availableMonths, availableMonths.size());
|
||||||
|
return availableMonths;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS에서 데이터 보유 월 조회 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
|
||||||
|
throw new KosConnectionException("KOS-AVAILABLE-MONTHS",
|
||||||
|
"KOS 시스템에서 데이터 보유 월 조회 중 오류가 발생했습니다", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 보유 월 조회 Circuit Breaker Fallback 메소드
|
||||||
|
*/
|
||||||
|
public List<String> getAvailableMonthsFallback(String lineNumber, Exception ex) {
|
||||||
|
log.warn("KOS 데이터 보유 월 조회 Circuit Breaker 작동 - 회선: {}, 오류: {}", lineNumber, ex.getMessage());
|
||||||
|
return new ArrayList<>(); // 빈 목록 반환
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOS 시스템 연결 상태 확인
|
* KOS 시스템 연결 상태 확인
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,169 +1,6 @@
|
|||||||
# 통신요금 관리 서비스 - Bill Service 개발환경 설정
|
# 로깅 설정
|
||||||
# 개발자 편의성과 디버깅을 위한 설정
|
|
||||||
#
|
|
||||||
# @author 이개발(백엔더)
|
|
||||||
# @version 1.0.0
|
|
||||||
# @since 2025-09-08
|
|
||||||
|
|
||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:bill_inquiry_db}
|
|
||||||
username: ${DB_USERNAME:bill_inquiry_user}
|
|
||||||
password: ${DB_PASSWORD:BillUser2025!}
|
|
||||||
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: ${JPA_DDL_AUTO:update}
|
|
||||||
|
|
||||||
# Redis 설정
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: ${REDIS_HOST:localhost}
|
|
||||||
port: ${REDIS_PORT:6379}
|
|
||||||
password: ${REDIS_PASSWORD:Redis2025Dev!}
|
|
||||||
timeout: 2000ms
|
|
||||||
lettuce:
|
|
||||||
pool:
|
|
||||||
max-active: 8
|
|
||||||
max-idle: 8
|
|
||||||
min-idle: 0
|
|
||||||
max-wait: -1ms
|
|
||||||
database: ${REDIS_DATABASE:1}
|
|
||||||
|
|
||||||
# 캐시 설정 (개발환경 - 짧은 TTL)
|
|
||||||
cache:
|
|
||||||
redis:
|
|
||||||
time-to-live: 300000 # 5분
|
|
||||||
|
|
||||||
# 서버 설정 (개발환경)
|
|
||||||
server:
|
|
||||||
port: ${SERVER_PORT:8082}
|
|
||||||
error:
|
|
||||||
include-message: always
|
|
||||||
include-binding-errors: always
|
|
||||||
include-stacktrace: always # 개발환경에서는 스택트레이스 포함
|
|
||||||
include-exception: true # 예외 정보 포함
|
|
||||||
|
|
||||||
# 액추에이터 설정 (개발환경)
|
|
||||||
management:
|
|
||||||
endpoints:
|
|
||||||
web:
|
|
||||||
exposure:
|
|
||||||
include: "*" # 개발환경에서는 모든 엔드포인트 노출
|
|
||||||
endpoint:
|
|
||||||
health:
|
|
||||||
show-details: always
|
|
||||||
show-components: always
|
|
||||||
security:
|
|
||||||
enabled: false # 개발환경에서는 액추에이터 보안 비활성화
|
|
||||||
|
|
||||||
# KOS 시스템 연동 설정 (개발환경)
|
|
||||||
kos:
|
|
||||||
base-url: http://localhost:9090 # 로컬 KOS Mock 서버
|
|
||||||
connect-timeout: 3000
|
|
||||||
read-timeout: 10000
|
|
||||||
max-retries: 2
|
|
||||||
retry-delay: 500
|
|
||||||
|
|
||||||
# Circuit Breaker 설정 (개발환경 - 관대한 설정)
|
|
||||||
circuit-breaker:
|
|
||||||
failure-rate-threshold: 0.7 # 70% 실패율
|
|
||||||
slow-call-duration-threshold: 15000 # 15초
|
|
||||||
slow-call-rate-threshold: 0.7 # 70% 느린 호출
|
|
||||||
sliding-window-size: 5 # 작은 윈도우
|
|
||||||
minimum-number-of-calls: 3 # 적은 최소 호출
|
|
||||||
permitted-number-of-calls-in-half-open-state: 2
|
|
||||||
wait-duration-in-open-state: 30000 # 30초
|
|
||||||
|
|
||||||
# 인증 설정 (개발환경)
|
|
||||||
authentication:
|
|
||||||
enabled: false # 개발환경에서는 인증 비활성화
|
|
||||||
api-key: dev-api-key
|
|
||||||
secret-key: dev-secret-key
|
|
||||||
token-expiration-seconds: 7200 # 2시간
|
|
||||||
|
|
||||||
# 모니터링 설정 (개발환경)
|
|
||||||
monitoring:
|
|
||||||
performance-logging-enabled: true
|
|
||||||
slow-request-threshold: 1000 # 1초 (더 민감한 감지)
|
|
||||||
metrics-enabled: true
|
|
||||||
health-check-interval: 10000 # 10초
|
|
||||||
|
|
||||||
# 로깅 설정 (개발환경)
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
com.phonebill: DEBUG
|
||||||
com.phonebill: ${LOG_LEVEL_APP:DEBUG} # 애플리케이션 로그 디버그 레벨
|
|
||||||
com.phonebill.bill.service: DEBUG
|
com.phonebill.bill.service: DEBUG
|
||||||
com.phonebill.bill.repository: DEBUG
|
com.phonebill.bill.repository: DEBUG
|
||||||
org.springframework.cache: DEBUG
|
|
||||||
org.springframework.web: DEBUG
|
|
||||||
org.springframework.security: DEBUG
|
|
||||||
org.hibernate.SQL: DEBUG # SQL 쿼리 로그
|
|
||||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # SQL 파라미터 로그
|
|
||||||
io.github.resilience4j: DEBUG
|
|
||||||
redis.clients.jedis: DEBUG
|
|
||||||
org.springframework.web.client.RestTemplate: DEBUG
|
|
||||||
pattern:
|
|
||||||
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
|
|
||||||
file:
|
|
||||||
name: ${LOG_FILE_NAME:logs/bill-service.log}
|
|
||||||
max-size: 50MB
|
|
||||||
max-history: 7 # 개발환경에서는 7일만 보관
|
|
||||||
|
|
||||||
# Swagger 설정 (개발환경)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
enabled: true
|
|
||||||
swagger-ui:
|
|
||||||
enabled: true
|
|
||||||
tags-sorter: alpha
|
|
||||||
operations-sorter: alpha
|
|
||||||
display-request-duration: true
|
|
||||||
default-models-expand-depth: 2
|
|
||||||
default-model-expand-depth: 2
|
|
||||||
try-it-out-enabled: true
|
|
||||||
filter: true
|
|
||||||
doc-expansion: list
|
|
||||||
show-actuator: true
|
|
||||||
|
|
||||||
# 개발환경 전용 설정
|
|
||||||
debug: false # Spring Boot 디버그 모드
|
|
||||||
|
|
||||||
# 개발편의를 위한 프로파일 정보
|
|
||||||
---
|
|
||||||
spring:
|
|
||||||
config:
|
|
||||||
activate:
|
|
||||||
on-profile: dev
|
|
||||||
|
|
||||||
# 개발환경 정보
|
|
||||||
info:
|
|
||||||
environment: development
|
|
||||||
debug:
|
|
||||||
enabled: true
|
|
||||||
database:
|
|
||||||
name: bill_service_dev
|
|
||||||
host: localhost
|
|
||||||
port: 3306
|
|
||||||
redis:
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
database: 1
|
|
||||||
kos:
|
|
||||||
host: localhost
|
|
||||||
port: 9090
|
|
||||||
mock: true
|
|
||||||
@ -1,237 +1,6 @@
|
|||||||
# 통신요금 관리 서비스 - Bill Service 운영환경 설정
|
# 로깅 설정
|
||||||
# 운영환경 안정성과 보안을 위한 설정
|
|
||||||
#
|
|
||||||
# @author 이개발(백엔더)
|
|
||||||
# @version 1.0.0
|
|
||||||
# @since 2025-09-08
|
|
||||||
|
|
||||||
spring:
|
|
||||||
# 데이터베이스 설정 (운영환경)
|
|
||||||
datasource:
|
|
||||||
url: ${DB_URL:jdbc:mysql://prod-db-host:3306/bill_service_prod?useUnicode=true&characterEncoding=utf8&useSSL=true&requireSSL=true&serverTimezone=Asia/Seoul}
|
|
||||||
username: ${DB_USERNAME}
|
|
||||||
password: ${DB_PASSWORD}
|
|
||||||
hikari:
|
|
||||||
minimum-idle: 10
|
|
||||||
maximum-pool-size: 50
|
|
||||||
idle-timeout: 600000 # 10분
|
|
||||||
max-lifetime: 1800000 # 30분
|
|
||||||
connection-timeout: 30000 # 30초
|
|
||||||
validation-timeout: 5000 # 5초
|
|
||||||
leak-detection-threshold: 60000 # 1분
|
|
||||||
|
|
||||||
# JPA 설정 (운영환경)
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: validate # 운영환경에서는 스키마 검증만
|
|
||||||
show-sql: false # 운영환경에서는 SQL 로그 비활성화
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
format_sql: false
|
|
||||||
use_sql_comments: false
|
|
||||||
default_batch_fetch_size: 100
|
|
||||||
jdbc:
|
|
||||||
batch_size: 50
|
|
||||||
connection:
|
|
||||||
provider_disables_autocommit: true
|
|
||||||
|
|
||||||
# Redis 설정 (운영환경)
|
|
||||||
redis:
|
|
||||||
host: ${REDIS_HOST:prod-redis-host}
|
|
||||||
port: ${REDIS_PORT:6379}
|
|
||||||
password: ${REDIS_PASSWORD}
|
|
||||||
database: ${REDIS_DATABASE:0}
|
|
||||||
timeout: 5000
|
|
||||||
ssl: ${REDIS_SSL:true}
|
|
||||||
lettuce:
|
|
||||||
pool:
|
|
||||||
max-active: 50
|
|
||||||
max-idle: 20
|
|
||||||
min-idle: 5
|
|
||||||
max-wait: 5000
|
|
||||||
cluster:
|
|
||||||
refresh:
|
|
||||||
adaptive: true
|
|
||||||
period: 30s
|
|
||||||
|
|
||||||
# 캐시 설정 (운영환경)
|
|
||||||
cache:
|
|
||||||
redis:
|
|
||||||
time-to-live: 3600000 # 1시간
|
|
||||||
|
|
||||||
# 서버 설정 (운영환경)
|
|
||||||
server:
|
|
||||||
port: ${SERVER_PORT:8081}
|
|
||||||
error:
|
|
||||||
include-message: never
|
|
||||||
include-binding-errors: never
|
|
||||||
include-stacktrace: never # 운영환경에서는 스택트레이스 숨김
|
|
||||||
include-exception: false # 예외 정보 숨김
|
|
||||||
tomcat:
|
|
||||||
max-connections: 10000
|
|
||||||
accept-count: 200
|
|
||||||
threads:
|
|
||||||
max: 300
|
|
||||||
min-spare: 20
|
|
||||||
connection-timeout: 20000
|
|
||||||
compression:
|
|
||||||
enabled: true
|
|
||||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css
|
|
||||||
min-response-size: 1024
|
|
||||||
|
|
||||||
# 액추에이터 설정 (운영환경)
|
|
||||||
management:
|
|
||||||
endpoints:
|
|
||||||
web:
|
|
||||||
exposure:
|
|
||||||
include: health,info,metrics,prometheus # 제한적 노출
|
|
||||||
base-path: /actuator
|
|
||||||
endpoint:
|
|
||||||
health:
|
|
||||||
show-details: when_authorized # 인증된 사용자에게만 상세 정보 제공
|
|
||||||
show-components: when_authorized
|
|
||||||
probes:
|
|
||||||
enabled: true
|
|
||||||
info:
|
|
||||||
enabled: true
|
|
||||||
metrics:
|
|
||||||
enabled: true
|
|
||||||
prometheus:
|
|
||||||
enabled: true
|
|
||||||
security:
|
|
||||||
enabled: true
|
|
||||||
health:
|
|
||||||
redis:
|
|
||||||
enabled: true
|
|
||||||
db:
|
|
||||||
enabled: true
|
|
||||||
diskspace:
|
|
||||||
enabled: true
|
|
||||||
threshold: 500MB
|
|
||||||
metrics:
|
|
||||||
export:
|
|
||||||
prometheus:
|
|
||||||
enabled: true
|
|
||||||
descriptions: false
|
|
||||||
distribution:
|
|
||||||
percentiles-histogram:
|
|
||||||
http.server.requests: true
|
|
||||||
percentiles:
|
|
||||||
http.server.requests: 0.95, 0.99
|
|
||||||
sla:
|
|
||||||
http.server.requests: 100ms, 500ms, 1s, 2s
|
|
||||||
|
|
||||||
# KOS 시스템 연동 설정 (운영환경)
|
|
||||||
kos:
|
|
||||||
base-url: ${KOS_BASE_URL}
|
|
||||||
connect-timeout: 5000
|
|
||||||
read-timeout: 30000
|
|
||||||
max-retries: 3
|
|
||||||
retry-delay: 1000
|
|
||||||
|
|
||||||
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
|
|
||||||
circuit-breaker:
|
|
||||||
failure-rate-threshold: 0.5 # 50% 실패율
|
|
||||||
slow-call-duration-threshold: 10000 # 10초
|
|
||||||
slow-call-rate-threshold: 0.5 # 50% 느린 호출
|
|
||||||
sliding-window-size: 20 # 큰 윈도우로 정확한 측정
|
|
||||||
minimum-number-of-calls: 10 # 충분한 샘플
|
|
||||||
permitted-number-of-calls-in-half-open-state: 5
|
|
||||||
wait-duration-in-open-state: 60000 # 60초
|
|
||||||
|
|
||||||
# 인증 설정 (운영환경)
|
|
||||||
authentication:
|
|
||||||
enabled: true
|
|
||||||
api-key: ${KOS_API_KEY}
|
|
||||||
secret-key: ${KOS_SECRET_KEY}
|
|
||||||
token-expiration-seconds: 3600 # 1시간
|
|
||||||
token-refresh-threshold-seconds: 300 # 5분
|
|
||||||
|
|
||||||
# 모니터링 설정 (운영환경)
|
|
||||||
monitoring:
|
|
||||||
performance-logging-enabled: true
|
|
||||||
slow-request-threshold: 3000 # 3초
|
|
||||||
metrics-enabled: true
|
|
||||||
health-check-interval: 30000 # 30초
|
|
||||||
|
|
||||||
# 로깅 설정 (운영환경)
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: WARN
|
com.phonebill: INFO
|
||||||
com.phonebill: INFO # 애플리케이션 로그는 INFO 레벨
|
|
||||||
com.phonebill.bill.service: INFO
|
com.phonebill.bill.service: INFO
|
||||||
com.phonebill.bill.repository: WARN
|
com.phonebill.bill.repository: INFO
|
||||||
org.springframework.cache: WARN
|
|
||||||
org.springframework.web: WARN
|
|
||||||
org.springframework.security: WARN
|
|
||||||
org.hibernate.SQL: WARN # SQL 로그 비활성화
|
|
||||||
org.hibernate.type.descriptor.sql.BasicBinder: WARN
|
|
||||||
io.github.resilience4j: INFO
|
|
||||||
redis.clients.jedis: WARN
|
|
||||||
org.springframework.web.client.RestTemplate: WARN
|
|
||||||
pattern:
|
|
||||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
|
|
||||||
file:
|
|
||||||
name: ${LOG_FILE:/app/logs/bill-service.log}
|
|
||||||
max-size: 200MB
|
|
||||||
max-history: 30 # 30일 보관
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
total-size-cap: 5GB
|
|
||||||
appender:
|
|
||||||
console:
|
|
||||||
enabled: false # 운영환경에서는 콘솔 로그 비활성화
|
|
||||||
|
|
||||||
# Swagger 설정 (운영환경 - 보안상 비활성화)
|
|
||||||
springdoc:
|
|
||||||
api-docs:
|
|
||||||
enabled: false
|
|
||||||
swagger-ui:
|
|
||||||
enabled: false
|
|
||||||
show-actuator: false
|
|
||||||
|
|
||||||
# 운영환경 보안 설정
|
|
||||||
security:
|
|
||||||
require-ssl: true
|
|
||||||
headers:
|
|
||||||
frame:
|
|
||||||
deny: true
|
|
||||||
content-type:
|
|
||||||
nosniff: true
|
|
||||||
xss-protection:
|
|
||||||
and-block: true
|
|
||||||
|
|
||||||
# 운영환경 전용 설정
|
|
||||||
debug: false
|
|
||||||
|
|
||||||
---
|
|
||||||
spring:
|
|
||||||
config:
|
|
||||||
activate:
|
|
||||||
on-profile: prod
|
|
||||||
|
|
||||||
# 운영환경 정보
|
|
||||||
info:
|
|
||||||
environment: production
|
|
||||||
debug:
|
|
||||||
enabled: false
|
|
||||||
security:
|
|
||||||
ssl-enabled: true
|
|
||||||
database:
|
|
||||||
name: bill_service_prod
|
|
||||||
ssl-enabled: true
|
|
||||||
redis:
|
|
||||||
ssl-enabled: true
|
|
||||||
cluster-enabled: true
|
|
||||||
kos:
|
|
||||||
ssl-enabled: true
|
|
||||||
authentication-enabled: true
|
|
||||||
|
|
||||||
# 운영환경 JVM 옵션 권장사항
|
|
||||||
# -Xms2g -Xmx4g
|
|
||||||
# -XX:+UseG1GC
|
|
||||||
# -XX:MaxGCPauseMillis=200
|
|
||||||
# -XX:+HeapDumpOnOutOfMemoryError
|
|
||||||
# -XX:HeapDumpPath=/app/logs/heap-dump.hprof
|
|
||||||
# -Djava.security.egd=file:/dev/./urandom
|
|
||||||
# -Dspring.profiles.active=prod
|
|
||||||
|
|||||||
@ -11,59 +11,50 @@ spring:
|
|||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
include:
|
|
||||||
- common
|
|
||||||
|
|
||||||
# 데이터베이스 설정
|
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/bill_inquiry_db}
|
url: jdbc:postgresql://${DB_HOST:20.249.107.185}:${DB_PORT:5432}/${DB_NAME:product_change}
|
||||||
username: ${DB_USERNAME:bill_user}
|
username: ${DB_USERNAME:product_user}
|
||||||
password: ${DB_PASSWORD:bill_pass}
|
password: ${DB_PASSWORD:product_pass}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
minimum-idle: ${DB_MIN_IDLE:5}
|
maximum-pool-size: 20
|
||||||
maximum-pool-size: ${DB_MAX_POOL:20}
|
minimum-idle: 5
|
||||||
idle-timeout: ${DB_IDLE_TIMEOUT:300000}
|
connection-timeout: 30000
|
||||||
max-lifetime: ${DB_MAX_LIFETIME:1800000}
|
idle-timeout: 600000
|
||||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
|
max-lifetime: 1800000
|
||||||
validation-timeout: ${DB_VALIDATION_TIMEOUT:5000}
|
leak-detection-threshold: 60000
|
||||||
leak-detection-threshold: ${DB_LEAK_DETECTION:60000}
|
|
||||||
|
|
||||||
# JPA 설정
|
# JPA 설정
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
show-sql: ${SHOW_SQL:true}
|
||||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
|
||||||
naming:
|
|
||||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
|
||||||
show-sql: ${JPA_SHOW_SQL:false}
|
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
format_sql: true
|
||||||
format_sql: ${JPA_FORMAT_SQL:false}
|
use_sql_comments: true
|
||||||
use_sql_comments: ${JPA_SQL_COMMENTS:false}
|
|
||||||
default_batch_fetch_size: ${JPA_BATCH_SIZE:100}
|
|
||||||
jdbc:
|
|
||||||
batch_size: ${JPA_JDBC_BATCH_SIZE:20}
|
|
||||||
order_inserts: true
|
|
||||||
order_updates: true
|
|
||||||
connection:
|
connection:
|
||||||
provider_disables_autocommit: true
|
provider_disables_autocommit: false
|
||||||
open-in-view: false
|
hibernate:
|
||||||
|
ddl-auto: ${DDL_AUTO:update}
|
||||||
|
|
||||||
# Redis 설정
|
# Redis 설정
|
||||||
redis:
|
data:
|
||||||
host: ${REDIS_HOST:localhost}
|
redis:
|
||||||
port: ${REDIS_PORT:6379}
|
host: ${REDIS_HOST:localhost}
|
||||||
password: ${REDIS_PASSWORD:}
|
port: ${REDIS_PORT:6379}
|
||||||
database: ${REDIS_DATABASE:0}
|
password: ${REDIS_PASSWORD:}
|
||||||
timeout: ${REDIS_TIMEOUT:5000}
|
timeout: 2000ms
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
max-active: ${REDIS_MAX_ACTIVE:20}
|
max-active: 8
|
||||||
max-idle: ${REDIS_MAX_IDLE:8}
|
max-idle: 8
|
||||||
min-idle: ${REDIS_MIN_IDLE:0}
|
min-idle: 0
|
||||||
max-wait: ${REDIS_MAX_WAIT:-1}
|
max-wait: -1ms
|
||||||
|
database: ${REDIS_DATABASE:2}
|
||||||
|
|
||||||
|
# Cache 개발 설정 (TTL 단축)
|
||||||
|
cache:
|
||||||
|
redis:
|
||||||
|
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
|
||||||
|
|
||||||
# Jackson 설정
|
# Jackson 설정
|
||||||
jackson:
|
jackson:
|
||||||
default-property-inclusion: non_null
|
default-property-inclusion: non_null
|
||||||
@ -75,22 +66,6 @@ spring:
|
|||||||
accept-single-value-as-array: true
|
accept-single-value-as-array: true
|
||||||
time-zone: Asia/Seoul
|
time-zone: Asia/Seoul
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
|
|
||||||
# Servlet 설정
|
|
||||||
servlet:
|
|
||||||
multipart:
|
|
||||||
max-file-size: ${SERVLET_MAX_FILE_SIZE:10MB}
|
|
||||||
max-request-size: ${SERVLET_MAX_REQUEST_SIZE:100MB}
|
|
||||||
|
|
||||||
# 비동기 처리 설정
|
|
||||||
task:
|
|
||||||
execution:
|
|
||||||
pool:
|
|
||||||
core-size: ${ASYNC_CORE_SIZE:5}
|
|
||||||
max-size: ${ASYNC_MAX_SIZE:20}
|
|
||||||
queue-capacity: ${ASYNC_QUEUE_CAPACITY:100}
|
|
||||||
keep-alive: ${ASYNC_KEEP_ALIVE:60s}
|
|
||||||
thread-name-prefix: "bill-async-"
|
|
||||||
|
|
||||||
# 서버 설정
|
# 서버 설정
|
||||||
server:
|
server:
|
||||||
@ -105,13 +80,6 @@ server:
|
|||||||
include-binding-errors: always
|
include-binding-errors: always
|
||||||
include-stacktrace: on_param
|
include-stacktrace: on_param
|
||||||
include-exception: false
|
include-exception: false
|
||||||
tomcat:
|
|
||||||
uri-encoding: UTF-8
|
|
||||||
max-connections: ${TOMCAT_MAX_CONNECTIONS:8192}
|
|
||||||
accept-count: ${TOMCAT_ACCEPT_COUNT:100}
|
|
||||||
threads:
|
|
||||||
max: ${TOMCAT_MAX_THREADS:200}
|
|
||||||
min-spare: ${TOMCAT_MIN_THREADS:10}
|
|
||||||
|
|
||||||
# 액추에이터 설정 (모니터링)
|
# 액추에이터 설정 (모니터링)
|
||||||
management:
|
management:
|
||||||
@ -126,7 +94,7 @@ management:
|
|||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
enabled: true
|
enabled: true
|
||||||
show-details: ${ACTUATOR_HEALTH_DETAILS:when_authorized}
|
show-details: always
|
||||||
show-components: always
|
show-components: always
|
||||||
probes:
|
probes:
|
||||||
enabled: true
|
enabled: true
|
||||||
@ -159,7 +127,7 @@ management:
|
|||||||
|
|
||||||
# KOS 시스템 연동 설정
|
# KOS 시스템 연동 설정
|
||||||
kos:
|
kos:
|
||||||
base-url: ${KOS_BASE_URL:http://localhost:9090}
|
base-url: ${KOS_BASE_URL:http://localhost:8084}
|
||||||
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
|
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
|
||||||
read-timeout: ${KOS_READ_TIMEOUT:30000}
|
read-timeout: ${KOS_READ_TIMEOUT:30000}
|
||||||
max-retries: ${KOS_MAX_RETRIES:3}
|
max-retries: ${KOS_MAX_RETRIES:3}
|
||||||
@ -174,82 +142,40 @@ kos:
|
|||||||
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
|
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
|
||||||
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
|
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
|
||||||
wait-duration-in-open-state: ${KOS_CB_OPEN_DURATION:60000}
|
wait-duration-in-open-state: ${KOS_CB_OPEN_DURATION:60000}
|
||||||
|
|
||||||
# 인증 설정
|
|
||||||
authentication:
|
|
||||||
enabled: ${KOS_AUTH_ENABLED:true}
|
|
||||||
api-key: ${KOS_API_KEY:}
|
|
||||||
secret-key: ${KOS_SECRET_KEY:}
|
|
||||||
token-expiration-seconds: ${KOS_TOKEN_EXPIRATION:3600}
|
|
||||||
token-refresh-threshold-seconds: ${KOS_TOKEN_REFRESH_THRESHOLD:300}
|
|
||||||
|
|
||||||
# 모니터링 설정
|
|
||||||
monitoring:
|
|
||||||
performance-logging-enabled: ${KOS_PERF_LOGGING:true}
|
|
||||||
slow-request-threshold: ${KOS_SLOW_THRESHOLD:3000}
|
|
||||||
metrics-enabled: ${KOS_METRICS_ENABLED:true}
|
|
||||||
health-check-interval: ${KOS_HEALTH_INTERVAL:30000}
|
|
||||||
|
|
||||||
# 로깅 설정
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
root: ${LOG_LEVEL_ROOT:INFO}
|
|
||||||
com.phonebill: ${LOG_LEVEL_APP:INFO}
|
|
||||||
com.phonebill.bill.service: ${LOG_LEVEL_SERVICE:INFO}
|
|
||||||
com.phonebill.bill.repository: ${LOG_LEVEL_REPOSITORY:INFO}
|
|
||||||
org.springframework.cache: ${LOG_LEVEL_CACHE:INFO}
|
|
||||||
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
|
||||||
org.springframework.security: ${LOG_LEVEL_SECURITY:INFO}
|
|
||||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
|
|
||||||
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:WARN}
|
|
||||||
io.github.resilience4j: ${LOG_LEVEL_RESILIENCE4J:INFO}
|
|
||||||
redis.clients.jedis: ${LOG_LEVEL_REDIS:INFO}
|
|
||||||
pattern:
|
|
||||||
console: "${LOG_PATTERN_CONSOLE:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
|
||||||
file: "${LOG_PATTERN_FILE:%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
|
||||||
file:
|
|
||||||
name: ${LOG_FILE_NAME:logs/bill-service.log}
|
|
||||||
max-size: ${LOG_FILE_MAX_SIZE:100MB}
|
|
||||||
max-history: ${LOG_FILE_MAX_HISTORY:30}
|
|
||||||
|
|
||||||
# Swagger/OpenAPI 설정
|
# Swagger/OpenAPI 설정
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
enabled: ${SWAGGER_ENABLED:true}
|
enabled: true
|
||||||
path: /v3/api-docs
|
path: /v3/api-docs
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: ${SWAGGER_UI_ENABLED:true}
|
enabled: true
|
||||||
path: /swagger-ui.html
|
path: /swagger-ui.html
|
||||||
tags-sorter: alpha
|
tags-sorter: alpha
|
||||||
operations-sorter: alpha
|
operations-sorter: alpha
|
||||||
display-request-duration: true
|
display-request-duration: true
|
||||||
default-models-expand-depth: 1
|
default-models-expand-depth: 1
|
||||||
default-model-expand-depth: 1
|
default-model-expand-depth: 1
|
||||||
show-actuator: ${SWAGGER_SHOW_ACTUATOR:false}
|
show-actuator: false
|
||||||
writer-with-default-pretty-printer: true
|
writer-with-default-pretty-printer: true
|
||||||
|
paths-to-exclude: /actuator/**
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
|
||||||
|
|
||||||
# JWT 보안 설정
|
# JWT 보안 설정
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
|
secret: ${JWT_SECRET:}
|
||||||
expiration: ${JWT_EXPIRATION:86400000} # 24시간 (밀리초)
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
||||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 (밀리초)
|
refresh-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:86400}
|
||||||
header: ${JWT_HEADER:Authorization}
|
|
||||||
prefix: ${JWT_PREFIX:Bearer }
|
|
||||||
|
|
||||||
# 애플리케이션 정보
|
# 로깅 설정
|
||||||
info:
|
logging:
|
||||||
app:
|
pattern:
|
||||||
name: ${spring.application.name}
|
console: "${LOG_PATTERN_CONSOLE:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
||||||
description: 통신요금 조회 및 관리 서비스
|
file: "${LOG_PATTERN_FILE:%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
||||||
version: ${BUILD_VERSION:1.0.0}
|
file:
|
||||||
author: 이개발(백엔더)
|
name: logs/bill-service.log
|
||||||
contact: dev@phonebill.com
|
max-size: ${LOG_FILE_MAX_SIZE:100MB}
|
||||||
build:
|
max-history: ${LOG_FILE_MAX_HISTORY:30}
|
||||||
time: ${BUILD_TIME:@project.build.time@}
|
|
||||||
artifact: ${BUILD_ARTIFACT:@project.artifactId@}
|
|
||||||
group: ${BUILD_GROUP:@project.groupId@}
|
|
||||||
java:
|
|
||||||
version: ${java.version}
|
|
||||||
git:
|
|
||||||
branch: ${GIT_BRANCH:unknown}
|
|
||||||
commit: ${GIT_COMMIT:unknown}
|
|
||||||
|
|||||||
@ -17,9 +17,14 @@ allprojects {
|
|||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
apply plugin: 'java'
|
apply plugin: 'java'
|
||||||
apply plugin: 'org.springframework.boot'
|
|
||||||
apply plugin: 'io.spring.dependency-management'
|
|
||||||
apply plugin: 'io.freefair.lombok'
|
apply plugin: 'io.freefair.lombok'
|
||||||
|
|
||||||
|
if (it.name == 'common') {
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
} else {
|
||||||
|
apply plugin: 'org.springframework.boot'
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
|
|||||||
@ -37,6 +37,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String userId = jwtTokenProvider.getUserId(token);
|
String userId = jwtTokenProvider.getUserId(token);
|
||||||
String username = null;
|
String username = null;
|
||||||
String authority = null;
|
String authority = null;
|
||||||
|
String customerId = null;
|
||||||
|
String lineNumber = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
username = jwtTokenProvider.getUsername(token);
|
username = jwtTokenProvider.getUsername(token);
|
||||||
@ -50,12 +52,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
|
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
customerId = jwtTokenProvider.getCustomerId(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("JWT에 customerId 클레임이 없음: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
lineNumber = jwtTokenProvider.getLineNumber(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("JWT에 lineNumber 클레임이 없음: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.hasText(userId)) {
|
if (StringUtils.hasText(userId)) {
|
||||||
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
|
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
|
||||||
UserPrincipal userPrincipal = UserPrincipal.builder()
|
UserPrincipal userPrincipal = UserPrincipal.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.username(username != null ? username : "unknown")
|
.username(username != null ? username : "unknown")
|
||||||
.authority(authority != null ? authority : "USER")
|
.authority(authority != null ? authority : "USER")
|
||||||
|
.customerId(customerId)
|
||||||
|
.lineNumber(lineNumber)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
|||||||
@ -28,8 +28,8 @@ public class JwtTokenProvider {
|
|||||||
private final SecretKey secretKey;
|
private final SecretKey secretKey;
|
||||||
private final long tokenValidityInMilliseconds;
|
private final long tokenValidityInMilliseconds;
|
||||||
|
|
||||||
public JwtTokenProvider(@Value("${security.jwt.secret:}") String secret,
|
public JwtTokenProvider(@Value("${jwt.secret:}") String secret,
|
||||||
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
|
@Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) {
|
||||||
if (StringUtils.hasText(secret)) {
|
if (StringUtils.hasText(secret)) {
|
||||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
} else {
|
} else {
|
||||||
@ -112,6 +112,32 @@ public class JwtTokenProvider {
|
|||||||
return claims.get("authority", String.class);
|
return claims.get("authority", String.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰에서 고객 ID 추출
|
||||||
|
*/
|
||||||
|
public String getCustomerId(String token) {
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
|
||||||
|
return claims.get("customerId", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰에서 회선번호 추출
|
||||||
|
*/
|
||||||
|
public String getLineNumber(String token) {
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
|
||||||
|
return claims.get("lineNumber", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰 만료 시간 확인
|
* 토큰 만료 시간 확인
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -28,6 +28,16 @@ public class UserPrincipal {
|
|||||||
*/
|
*/
|
||||||
private final String authority;
|
private final String authority;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 ID
|
||||||
|
*/
|
||||||
|
private final String customerId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호
|
||||||
|
*/
|
||||||
|
private final String lineNumber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID 반환 (별칭)
|
* 사용자 ID 반환 (별칭)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,28 +3,8 @@
|
|||||||
<ExternalSystemSettings>
|
<ExternalSystemSettings>
|
||||||
<option name="env">
|
<option name="env">
|
||||||
<map>
|
<map>
|
||||||
<!-- Server Configuration -->
|
|
||||||
<entry key="SERVER_PORT" value="8084" />
|
<entry key="SERVER_PORT" value="8084" />
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||||
|
|
||||||
<!-- Logging Configuration -->
|
|
||||||
<entry key="LOG_FILE" value="logs/kos-mock.log" />
|
|
||||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
|
||||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
|
||||||
|
|
||||||
<!-- Redis Configuration (Optional - KOS Mock에서는 캐시 기능 비활성화 상태) -->
|
|
||||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
|
||||||
<entry key="REDIS_PORT" value="6379" />
|
|
||||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
|
||||||
<entry key="REDIS_DATABASE" value="4" />
|
|
||||||
|
|
||||||
<!-- KOS Mock Specific Settings -->
|
|
||||||
<entry key="KOS_MOCK_DELAY_MIN" value="100" />
|
|
||||||
<entry key="KOS_MOCK_DELAY_MAX" value="500" />
|
|
||||||
<entry key="KOS_MOCK_ERROR_RATE" value="0.05" />
|
|
||||||
|
|
||||||
<!-- Development Settings -->
|
|
||||||
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
|
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
|
|||||||
@ -11,10 +11,7 @@ import org.springframework.cache.annotation.EnableCaching;
|
|||||||
/**
|
/**
|
||||||
* KOS Mock Service 메인 애플리케이션 클래스
|
* KOS Mock Service 메인 애플리케이션 클래스
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(exclude = {
|
@SpringBootApplication
|
||||||
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class,
|
|
||||||
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class
|
|
||||||
})
|
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -32,9 +29,6 @@ public class KosMockApplication implements CommandLineRunner {
|
|||||||
log.info("Mock 데이터 초기화를 시작합니다...");
|
log.info("Mock 데이터 초기화를 시작합니다...");
|
||||||
|
|
||||||
mockDataService.initializeMockData();
|
mockDataService.initializeMockData();
|
||||||
|
|
||||||
log.info("KOS Mock Service가 성공적으로 시작되었습니다.");
|
|
||||||
log.info("Swagger UI: http://localhost:8080/kos-mock/swagger-ui.html");
|
|
||||||
log.info("Health Check: http://localhost:8080/kos-mock/actuator/health");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.phonebill.kosmock.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RestTemplate 설정
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RestTemplate Bean 등록
|
||||||
|
* @return RestTemplate 인스턴스
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -95,77 +95,114 @@ public class KosMockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 처리 상태 조회 API
|
* 상품 정보 목록 조회 API
|
||||||
*/
|
*/
|
||||||
@GetMapping("/status/{requestId}")
|
@GetMapping("/product/list")
|
||||||
@Operation(summary = "처리 상태 조회", description = "요청의 처리 상태를 조회합니다.")
|
@Operation(summary = "상품 목록 조회", description = "등록된 통신 상품들의 목록을 조회합니다.")
|
||||||
@ApiResponses(value = {
|
@ApiResponses(value = {
|
||||||
@ApiResponse(responseCode = "200", description = "조회 성공"),
|
@ApiResponse(responseCode = "200", description = "조회 성공",
|
||||||
@ApiResponse(responseCode = "404", description = "요청 ID를 찾을 수 없음"),
|
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
|
||||||
@ApiResponse(responseCode = "500", description = "서버 오류")
|
@ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
})
|
})
|
||||||
public ResponseEntity<KosCommonResponse<Object>> getProcessingStatus(
|
public ResponseEntity<KosCommonResponse<KosProductListResponse>> getProductList() {
|
||||||
@Parameter(description = "요청 ID", example = "REQ_20250108_001")
|
|
||||||
@PathVariable String requestId) {
|
|
||||||
|
|
||||||
log.info("처리 상태 조회 요청 - RequestId: {}", requestId);
|
log.info("상품 목록 조회 요청 수신");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mock 데이터에서 처리 결과 조회 로직은 간단하게 구현
|
KosProductListResponse response = kosMockService.getProductList();
|
||||||
// 실제로는 mockDataService.getProcessingResult(requestId) 사용
|
|
||||||
|
if ("0000".equals(response.getResultCode())) {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.success(response, "상품 목록 조회가 완료되었습니다"));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.failure(
|
||||||
|
response.getResultCode(), response.getResultMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(KosCommonResponse.success(
|
|
||||||
"PROCESSING 상태 - 처리 중입니다.",
|
|
||||||
"처리 상태 조회가 완료되었습니다"));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("처리 상태 조회 중 오류 발생 - RequestId: {}", requestId, e);
|
log.error("상품 목록 조회 처리 중 오류 발생", e);
|
||||||
return ResponseEntity.ok(KosCommonResponse.systemError());
|
return ResponseEntity.ok(KosCommonResponse.systemError());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 상태 체크 API
|
* 데이터 보유 월 목록 조회 API
|
||||||
*/
|
*/
|
||||||
@GetMapping("/health")
|
@GetMapping("/bill/available-months/{lineNumber}")
|
||||||
@Operation(summary = "서비스 상태 체크", description = "KOS Mock 서비스의 상태를 확인합니다.")
|
@Operation(summary = "데이터 보유 월 목록 조회", description = "회선번호의 실제 요금 데이터가 있는 월 목록을 조회합니다.")
|
||||||
public ResponseEntity<KosCommonResponse<Object>> healthCheck() {
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공",
|
||||||
|
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
|
})
|
||||||
|
public ResponseEntity<KosCommonResponse<KosAvailableMonthsResponse>> getAvailableMonths(
|
||||||
|
@Parameter(description = "회선번호 (하이픈 제거된 형태)", example = "01012345678")
|
||||||
|
@PathVariable String lineNumber) {
|
||||||
|
|
||||||
log.debug("KOS Mock 서비스 상태 체크 요청");
|
log.info("데이터 보유 월 목록 조회 요청 수신 - LineNumber: {}", lineNumber);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(KosCommonResponse.success(
|
// 하이픈 없는 형태 그대로 사용 (MockDataService와 일치)
|
||||||
"KOS Mock Service is running normally",
|
KosAvailableMonthsResponse response = kosMockService.getAvailableMonths(lineNumber);
|
||||||
"서비스가 정상 동작 중입니다"));
|
|
||||||
|
if ("0000".equals(response.getResultCode())) {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.success(response, "데이터 보유 월 목록 조회가 완료되었습니다"));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.failure(
|
||||||
|
response.getResultCode(), response.getResultMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("서비스 상태 체크 중 오류 발생", e);
|
log.error("데이터 보유 월 목록 조회 처리 중 오류 발생 - LineNumber: {}", lineNumber, e);
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.systemError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입상품 조회 API
|
||||||
|
*/
|
||||||
|
@PostMapping("/product/inquiry")
|
||||||
|
@Operation(summary = "가입상품 조회", description = "고객의 가입상품 정보를 조회합니다.")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공",
|
||||||
|
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
|
||||||
|
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
|
})
|
||||||
|
public ResponseEntity<KosCommonResponse<KosProductInquiryResponse>> inquireProduct(
|
||||||
|
@Valid @RequestBody KosProductInquiryRequest request) {
|
||||||
|
|
||||||
|
log.info("가입상품 조회 요청 수신 - RequestId: {}, LineNumber: {}",
|
||||||
|
request.getRequestId(), request.getLineNumber());
|
||||||
|
|
||||||
|
try {
|
||||||
|
KosProductInquiryResponse response = kosMockService.processProductInquiry(request);
|
||||||
|
|
||||||
|
if ("0000".equals(response.getResultCode())) {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.success(response, "가입상품 조회가 완료되었습니다"));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(KosCommonResponse.failure(
|
||||||
|
response.getResultCode(), response.getResultMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("가입상품 조회 처리 중 오류 발생 - RequestId: {}", request.getRequestId(), e);
|
||||||
return ResponseEntity.ok(KosCommonResponse.systemError());
|
return ResponseEntity.ok(KosCommonResponse.systemError());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 설정 조회 API (개발/테스트용)
|
* 회선번호 형식 변환 (01012345678 → 010-1234-5678)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/mock/config")
|
private String formatLineNumber(String lineNumber) {
|
||||||
@Operation(summary = "Mock 설정 조회", description = "현재 Mock 서비스의 설정을 조회합니다. (개발/테스트용)")
|
if (lineNumber == null || lineNumber.length() != 11) {
|
||||||
public ResponseEntity<KosCommonResponse<Object>> getMockConfig() {
|
return lineNumber;
|
||||||
|
|
||||||
log.info("Mock 설정 조회 요청");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Mock 설정 정보를 간단히 반환
|
|
||||||
String configInfo = String.format(
|
|
||||||
"Response Delay: %dms, Failure Rate: %.2f%%, Service Status: ACTIVE",
|
|
||||||
500, 1.0); // 하드코딩된 값 (실제로는 MockConfig에서 가져올 수 있음)
|
|
||||||
|
|
||||||
return ResponseEntity.ok(KosCommonResponse.success(
|
|
||||||
configInfo,
|
|
||||||
"Mock 설정 조회가 완료되었습니다"));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Mock 설정 조회 중 오류 발생", e);
|
|
||||||
return ResponseEntity.ok(KosCommonResponse.systemError());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return lineNumber.substring(0, 3) + "-" +
|
||||||
|
lineNumber.substring(3, 7) + "-" +
|
||||||
|
lineNumber.substring(7, 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
package com.phonebill.kosmock.controller;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.dto.MockDataCreateRequest;
|
||||||
|
import com.phonebill.kosmock.dto.MockDataCreateResponse;
|
||||||
|
import com.phonebill.kosmock.service.MockDataCreateService;
|
||||||
|
import com.phonebill.common.dto.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 데이터 생성 및 조회 API 컨트롤러
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/mock-datas")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Mock Data Management", description = "Mock 데이터 생성, 조회 및 관리 API")
|
||||||
|
public class MockDataController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MockDataController.class);
|
||||||
|
private final MockDataCreateService mockDataCreateService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Mock 데이터 생성", description = "고객 정보와 요금 정보 Mock 데이터를 생성합니다")
|
||||||
|
public ResponseEntity<ApiResponse<MockDataCreateResponse>> createMockData(
|
||||||
|
@Valid @RequestBody MockDataCreateRequest request) {
|
||||||
|
|
||||||
|
log.info("Mock 데이터 생성 요청 - CustomerId: {}, LineNumber: {}",
|
||||||
|
request.getCustomerId(), request.getLineNumber());
|
||||||
|
|
||||||
|
try {
|
||||||
|
MockDataCreateResponse response = mockDataCreateService.createMockData(request);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mock 데이터가 성공적으로 생성되었습니다", response));
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Mock 데이터 생성 실패 - 잘못된 요청: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(ApiResponse.error(e.getMessage(), "4000"));
|
||||||
|
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
log.error("Mock 데이터 생성 실패 - 시스템 상태 오류: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error(e.getMessage(), "5000"));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Mock 데이터 생성 실패 - 예기치 못한 오류", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("Mock 데이터 생성 중 오류가 발생했습니다", "5000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입상품정보 조회 API
|
||||||
|
*/
|
||||||
|
@GetMapping("/customer/product")
|
||||||
|
@Operation(summary = "가입상품정보 조회", description = "고객 ID와 회선번호로 가입상품정보를 조회합니다.")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
|
||||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "고객 정보를 찾을 수 없음"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
|
})
|
||||||
|
public ResponseEntity<ApiResponse<Object>> getCustomerProduct(
|
||||||
|
@Parameter(description = "고객 ID", example = "CUST_001", required = true)
|
||||||
|
@RequestParam String customerId,
|
||||||
|
@Parameter(description = "회선번호", example = "01012345679", required = true)
|
||||||
|
@RequestParam String lineNumber) {
|
||||||
|
|
||||||
|
log.info("가입상품정보 조회 요청 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object productInfo = mockDataCreateService.getCustomerProduct(customerId, lineNumber);
|
||||||
|
|
||||||
|
if (productInfo != null) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("가입상품정보 조회가 완료되었습니다", productInfo));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("해당 고객의 상품정보를 찾을 수 없습니다", "1001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("가입상품정보 조회 중 오류 발생 - CustomerId: {}, LineNumber: {}",
|
||||||
|
customerId, lineNumber, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("가입상품정보 조회 중 오류가 발생했습니다", "5000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요금정보 조회 API
|
||||||
|
*/
|
||||||
|
@GetMapping("/customer/bill")
|
||||||
|
@Operation(summary = "요금정보 조회", description = "고객 ID와 회선번호로 요금정보를 조회합니다.")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
|
||||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "고객 정보를 찾을 수 없음"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류")
|
||||||
|
})
|
||||||
|
public ResponseEntity<ApiResponse<Object>> getCustomerBill(
|
||||||
|
@Parameter(description = "고객 ID", example = "CUST_001", required = true)
|
||||||
|
@RequestParam String customerId,
|
||||||
|
@Parameter(description = "회선번호", example = "01012345679", required = true)
|
||||||
|
@RequestParam String lineNumber) {
|
||||||
|
|
||||||
|
log.info("요금정보 조회 요청 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object billInfo = mockDataCreateService.getCustomerBill(customerId, lineNumber);
|
||||||
|
|
||||||
|
if (billInfo != null) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("요금정보 조회가 완료되었습니다", billInfo));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("해당 고객의 요금정보를 찾을 수 없습니다", "1002"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("요금정보 조회 중 오류 발생 - CustomerId: {}, LineNumber: {}",
|
||||||
|
customerId, lineNumber, e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("요금정보 조회 중 오류가 발생했습니다", "5000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,24 @@
|
|||||||
package com.phonebill.kosmock.data;
|
package com.phonebill.kosmock.data;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.entity.BillEntity;
|
||||||
|
import com.phonebill.kosmock.entity.CustomerEntity;
|
||||||
|
import com.phonebill.kosmock.entity.ProductEntity;
|
||||||
|
import com.phonebill.kosmock.repository.BillRepository;
|
||||||
|
import com.phonebill.kosmock.repository.CustomerRepository;
|
||||||
|
import com.phonebill.kosmock.repository.ProductRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KOS Mock 데이터 서비스
|
* KOS Mock 데이터 서비스 (H2 데이터베이스 기반)
|
||||||
* 통신요금 조회 및 상품변경에 필요한 Mock 데이터를 제공합니다.
|
* 통신요금 조회 및 상품변경에 필요한 Mock 데이터를 제공합니다.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@ -19,232 +26,67 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class MockDataService {
|
public class MockDataService {
|
||||||
|
|
||||||
// Mock 사용자 데이터 (회선번호 기반)
|
private final CustomerRepository customerRepository;
|
||||||
private final Map<String, MockCustomerData> mockCustomers = new ConcurrentHashMap<>();
|
private final ProductRepository productRepository;
|
||||||
|
private final BillRepository billRepository;
|
||||||
// Mock 상품 데이터
|
|
||||||
private final Map<String, MockProductData> mockProducts = new ConcurrentHashMap<>();
|
// 요청 처리 이력 (메모리 기반 유지)
|
||||||
|
private final Map<String, MockProcessingResult> processingResults = new HashMap<>();
|
||||||
// Mock 요금 데이터
|
|
||||||
private final Map<String, MockBillData> mockBills = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
// 요청 처리 이력
|
|
||||||
private final Map<String, MockProcessingResult> processingResults = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 초기 Mock 데이터 생성
|
* 초기 Mock 데이터 생성 (user-service 기반)
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public void initializeMockData() {
|
public void initializeMockData() {
|
||||||
log.info("KOS Mock 데이터 초기화 시작");
|
log.info("KOS Mock 데이터 초기화 시작");
|
||||||
|
|
||||||
|
// 상품 데이터만 초기화 (고객 데이터는 API 요청 시 동적 생성)
|
||||||
initializeMockProducts();
|
initializeMockProducts();
|
||||||
initializeMockCustomers();
|
|
||||||
initializeMockBills();
|
|
||||||
|
|
||||||
log.info("KOS Mock 데이터 초기화 완료 - 고객: {}, 상품: {}, 요금: {}",
|
long productCount = productRepository.count();
|
||||||
mockCustomers.size(), mockProducts.size(), mockBills.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock 상품 데이터 초기화
|
|
||||||
*/
|
|
||||||
private void initializeMockProducts() {
|
|
||||||
// 5G 상품
|
|
||||||
mockProducts.put("5G-PREMIUM-001", MockProductData.builder()
|
|
||||||
.productCode("5G-PREMIUM-001")
|
|
||||||
.productName("5G 프리미엄 플랜")
|
|
||||||
.monthlyFee(new BigDecimal("89000"))
|
|
||||||
.dataAllowance("무제한")
|
|
||||||
.voiceAllowance("무제한")
|
|
||||||
.smsAllowance("무제한")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.networkType("5G")
|
|
||||||
.status("ACTIVE")
|
|
||||||
.description("5G 네트워크 무제한 프리미엄 요금제")
|
|
||||||
.build());
|
|
||||||
|
|
||||||
mockProducts.put("5G-STANDARD-001", MockProductData.builder()
|
|
||||||
.productCode("5G-STANDARD-001")
|
|
||||||
.productName("5G 스탠다드 플랜")
|
|
||||||
.monthlyFee(new BigDecimal("69000"))
|
|
||||||
.dataAllowance("100GB")
|
|
||||||
.voiceAllowance("무제한")
|
|
||||||
.smsAllowance("무제한")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.networkType("5G")
|
|
||||||
.status("ACTIVE")
|
|
||||||
.description("5G 네트워크 스탠다드 요금제")
|
|
||||||
.build());
|
|
||||||
|
|
||||||
// LTE 상품
|
|
||||||
mockProducts.put("LTE-PREMIUM-001", MockProductData.builder()
|
|
||||||
.productCode("LTE-PREMIUM-001")
|
|
||||||
.productName("LTE 프리미엄 플랜")
|
|
||||||
.monthlyFee(new BigDecimal("59000"))
|
|
||||||
.dataAllowance("50GB")
|
|
||||||
.voiceAllowance("무제한")
|
|
||||||
.smsAllowance("무제한")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.networkType("LTE")
|
|
||||||
.status("ACTIVE")
|
|
||||||
.description("LTE 네트워크 프리미엄 요금제")
|
|
||||||
.build());
|
|
||||||
|
|
||||||
mockProducts.put("LTE-BASIC-001", MockProductData.builder()
|
|
||||||
.productCode("LTE-BASIC-001")
|
|
||||||
.productName("LTE 베이직 플랜")
|
|
||||||
.monthlyFee(new BigDecimal("39000"))
|
|
||||||
.dataAllowance("20GB")
|
|
||||||
.voiceAllowance("무제한")
|
|
||||||
.smsAllowance("기본 제공")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.networkType("LTE")
|
|
||||||
.status("ACTIVE")
|
|
||||||
.description("LTE 네트워크 베이직 요금제")
|
|
||||||
.build());
|
|
||||||
|
|
||||||
// 종료된 상품 (변경 불가)
|
|
||||||
mockProducts.put("3G-OLD-001", MockProductData.builder()
|
|
||||||
.productCode("3G-OLD-001")
|
|
||||||
.productName("3G 레거시 플랜")
|
|
||||||
.monthlyFee(new BigDecimal("29000"))
|
|
||||||
.dataAllowance("5GB")
|
|
||||||
.voiceAllowance("500분")
|
|
||||||
.smsAllowance("100건")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.networkType("3G")
|
|
||||||
.status("DISCONTINUED")
|
|
||||||
.description("3G 네트워크 레거시 요금제 (신규 가입 불가)")
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock 고객 데이터 초기화
|
|
||||||
*/
|
|
||||||
private void initializeMockCustomers() {
|
|
||||||
// 테스트용 고객 데이터
|
|
||||||
String[] testNumbers = {
|
|
||||||
"01012345678", "01087654321", "01055554444",
|
|
||||||
"01099998888", "01077776666", "01033332222"
|
|
||||||
};
|
|
||||||
|
|
||||||
String[] testNames = {
|
log.info("KOS Mock 데이터 초기화 완료 - 상품: {}", productCount);
|
||||||
"김테스트", "이샘플", "박데모", "최모의", "정시험", "한실험"
|
|
||||||
};
|
|
||||||
|
|
||||||
String[] currentProducts = {
|
|
||||||
"5G-PREMIUM-001", "5G-STANDARD-001", "LTE-PREMIUM-001",
|
|
||||||
"LTE-BASIC-001", "3G-OLD-001", "5G-PREMIUM-001"
|
|
||||||
};
|
|
||||||
|
|
||||||
for (int i = 0; i < testNumbers.length; i++) {
|
|
||||||
mockCustomers.put(testNumbers[i], MockCustomerData.builder()
|
|
||||||
.lineNumber(testNumbers[i])
|
|
||||||
.customerName(testNames[i])
|
|
||||||
.customerId("CUST" + String.format("%06d", i + 1))
|
|
||||||
.operatorCode("KT")
|
|
||||||
.currentProductCode(currentProducts[i])
|
|
||||||
.lineStatus("ACTIVE")
|
|
||||||
.contractDate(LocalDateTime.now().minusMonths(12 + i))
|
|
||||||
.lastModified(LocalDateTime.now().minusDays(i))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비활성 회선 테스트용
|
|
||||||
mockCustomers.put("01000000000", MockCustomerData.builder()
|
|
||||||
.lineNumber("01000000000")
|
|
||||||
.customerName("비활성사용자")
|
|
||||||
.customerId("CUST999999")
|
|
||||||
.operatorCode("KT")
|
|
||||||
.currentProductCode("LTE-BASIC-001")
|
|
||||||
.lineStatus("SUSPENDED")
|
|
||||||
.contractDate(LocalDateTime.now().minusMonths(6))
|
|
||||||
.lastModified(LocalDateTime.now().minusDays(30))
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock 요금 데이터 초기화
|
|
||||||
*/
|
|
||||||
private void initializeMockBills() {
|
|
||||||
for (MockCustomerData customer : mockCustomers.values()) {
|
|
||||||
MockProductData product = mockProducts.get(customer.getCurrentProductCode());
|
|
||||||
if (product != null) {
|
|
||||||
// 최근 3개월 요금 데이터 생성
|
|
||||||
for (int month = 0; month < 3; month++) {
|
|
||||||
LocalDateTime billDate = LocalDateTime.now().minusMonths(month);
|
|
||||||
String billKey = customer.getLineNumber() + "_" + billDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
|
||||||
|
|
||||||
BigDecimal usageFee = calculateUsageFee(product, month);
|
|
||||||
BigDecimal totalFee = product.getMonthlyFee().add(usageFee);
|
|
||||||
|
|
||||||
mockBills.put(billKey, MockBillData.builder()
|
|
||||||
.lineNumber(customer.getLineNumber())
|
|
||||||
.billingMonth(billDate.format(DateTimeFormatter.ofPattern("yyyyMM")))
|
|
||||||
.productCode(product.getProductCode())
|
|
||||||
.productName(product.getProductName())
|
|
||||||
.monthlyFee(product.getMonthlyFee())
|
|
||||||
.usageFee(usageFee)
|
|
||||||
.totalFee(totalFee)
|
|
||||||
.dataUsage(generateRandomDataUsage(product))
|
|
||||||
.voiceUsage(generateRandomVoiceUsage(product))
|
|
||||||
.smsUsage(generateRandomSmsUsage())
|
|
||||||
.billStatus("CONFIRMED")
|
|
||||||
.dueDate(billDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal calculateUsageFee(MockProductData product, int month) {
|
// 기존 메소드들 - H2 데이터베이스 기반으로 재구현
|
||||||
// 간단한 사용료 계산 로직 (랜덤하게 0~30000원)
|
|
||||||
Random random = new Random();
|
|
||||||
return new BigDecimal(random.nextInt(30000));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateRandomDataUsage(MockProductData product) {
|
|
||||||
Random random = new Random();
|
|
||||||
if ("무제한".equals(product.getDataAllowance())) {
|
|
||||||
return random.nextInt(200) + "GB";
|
|
||||||
} else {
|
|
||||||
int allowance = Integer.parseInt(product.getDataAllowance().replace("GB", ""));
|
|
||||||
return random.nextInt(allowance) + "GB";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateRandomVoiceUsage(MockProductData product) {
|
|
||||||
Random random = new Random();
|
|
||||||
if ("무제한".equals(product.getVoiceAllowance())) {
|
|
||||||
return random.nextInt(500) + "분";
|
|
||||||
} else {
|
|
||||||
int allowance = Integer.parseInt(product.getVoiceAllowance().replace("분", ""));
|
|
||||||
return random.nextInt(allowance) + "분";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateRandomSmsUsage() {
|
|
||||||
Random random = new Random();
|
|
||||||
return random.nextInt(100) + "건";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getter methods
|
|
||||||
public MockCustomerData getCustomerData(String lineNumber) {
|
public MockCustomerData getCustomerData(String lineNumber) {
|
||||||
return mockCustomers.get(lineNumber);
|
log.info("MockDataService: 고객 데이터 조회 - LineNumber: {}", lineNumber);
|
||||||
|
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumber(lineNumber);
|
||||||
|
|
||||||
|
if (customerOpt.isEmpty()) {
|
||||||
|
log.warn("MockDataService: 고객 정보를 찾을 수 없음 - LineNumber: {}", lineNumber);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerEntity entity = customerOpt.get();
|
||||||
|
log.info("MockDataService: 고객 정보 발견 - CustomerId: {}, LineNumber: {}",
|
||||||
|
entity.getCustomerId(), entity.getLineNumber());
|
||||||
|
|
||||||
|
return convertToMockCustomerData(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MockProductData getProductData(String productCode) {
|
public MockProductData getProductData(String productCode) {
|
||||||
return mockProducts.get(productCode);
|
Optional<ProductEntity> productOpt = productRepository.findById(productCode);
|
||||||
|
return productOpt.map(this::convertToMockProductData).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MockBillData getBillData(String lineNumber, String billingMonth) {
|
public MockBillData getBillData(String lineNumber, String billingMonth) {
|
||||||
return mockBills.get(lineNumber + "_" + billingMonth);
|
Optional<BillEntity> billOpt = billRepository.findByLineNumberAndBillingMonth(lineNumber, billingMonth);
|
||||||
|
return billOpt.map(this::convertToMockBillData).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MockProductData> getAllAvailableProducts() {
|
public List<MockProductData> getAllAvailableProducts() {
|
||||||
return mockProducts.values().stream()
|
List<ProductEntity> products = productRepository.findByStatusOrderByMonthlyFeeDesc("ACTIVE");
|
||||||
.filter(product -> "ACTIVE".equals(product.getStatus()))
|
return products.stream()
|
||||||
.sorted(Comparator.comparing(MockProductData::getMonthlyFee).reversed())
|
.map(this::convertToMockProductData)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MockProductData> getAllProducts() {
|
||||||
|
List<ProductEntity> products = productRepository.findAll();
|
||||||
|
return products.stream()
|
||||||
|
.map(this::convertToMockProductData)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,9 +99,294 @@ public class MockDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<MockBillData> getBillHistory(String lineNumber) {
|
public List<MockBillData> getBillHistory(String lineNumber) {
|
||||||
return mockBills.values().stream()
|
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
|
||||||
.filter(bill -> lineNumber.equals(bill.getLineNumber()))
|
return bills.stream()
|
||||||
.sorted(Comparator.comparing(MockBillData::getBillingMonth).reversed())
|
.map(this::convertToMockBillData)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호의 실제 요금 데이터가 있는 월 목록 조회
|
||||||
|
*/
|
||||||
|
public List<String> getAvailableMonths(String lineNumber) {
|
||||||
|
log.info("회선 {}의 실제 데이터 보유 월 조회", lineNumber);
|
||||||
|
|
||||||
|
// 데이터베이스에서 실제 청구 데이터가 있는 월 목록 조회
|
||||||
|
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
|
||||||
|
|
||||||
|
if (bills.isEmpty()) {
|
||||||
|
// 실제 데이터가 없으면 Mock 데이터를 생성하여 최근 3개월 반환
|
||||||
|
log.info("실제 청구 데이터가 없어 Mock 데이터 생성: {}", lineNumber);
|
||||||
|
createMockBillDataForRecentMonths(lineNumber);
|
||||||
|
// 다시 조회
|
||||||
|
bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청구월을 yyyy-MM 형식으로 변환하여 반환
|
||||||
|
List<String> availableMonths = bills.stream()
|
||||||
|
.map(bill -> {
|
||||||
|
String billingMonth = bill.getBillingMonth();
|
||||||
|
// yyyyMM 형식을 yyyy-MM 형식으로 변환
|
||||||
|
if (billingMonth.length() == 6) {
|
||||||
|
return billingMonth.substring(0, 4) + "-" + billingMonth.substring(4, 6);
|
||||||
|
}
|
||||||
|
return billingMonth;
|
||||||
|
})
|
||||||
|
.distinct()
|
||||||
|
.sorted(java.util.Collections.reverseOrder()) // 최신 월부터
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
log.info("회선 {}의 데이터 보유 월: {} (총 {}개월)", lineNumber, availableMonths, availableMonths.size());
|
||||||
|
return availableMonths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 3개월 Mock 청구 데이터 생성
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
private void createMockBillDataForRecentMonths(String lineNumber) {
|
||||||
|
log.info("회선 {}의 Mock 청구 데이터 생성", lineNumber);
|
||||||
|
|
||||||
|
// 고객 정보 조회
|
||||||
|
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumberWithProduct(lineNumber);
|
||||||
|
if (customerOpt.isEmpty()) {
|
||||||
|
log.warn("회선번호 {}에 대한 고객 정보가 없어 청구 데이터를 생성할 수 없습니다", lineNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerEntity customer = customerOpt.get();
|
||||||
|
|
||||||
|
// 현재 상품 정보 조회
|
||||||
|
Optional<ProductEntity> productOpt = productRepository.findById(customer.getCurrentProductCode());
|
||||||
|
if (productOpt.isEmpty()) {
|
||||||
|
log.warn("상품 코드 {}를 찾을 수 없어 청구 데이터를 생성할 수 없습니다", customer.getCurrentProductCode());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductEntity product = productOpt.get();
|
||||||
|
|
||||||
|
// 최근 3개월 청구 데이터 생성
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<BillEntity> mockBills = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
LocalDateTime monthDate = now.minusMonths(i);
|
||||||
|
String billingMonth = monthDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||||
|
|
||||||
|
// 이미 해당 월 데이터가 있는지 확인
|
||||||
|
if (billRepository.findByLineNumberAndBillingMonth(lineNumber, billingMonth).isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 청구 데이터 생성
|
||||||
|
BigDecimal usageFee = generateRandomUsageFee();
|
||||||
|
BillEntity billEntity = BillEntity.builder()
|
||||||
|
.lineNumber(lineNumber)
|
||||||
|
.billingMonth(billingMonth)
|
||||||
|
.productCode(product.getProductCode())
|
||||||
|
.productName(product.getProductName())
|
||||||
|
.monthlyFee(product.getMonthlyFee())
|
||||||
|
.usageFee(usageFee)
|
||||||
|
.totalFee(product.getMonthlyFee().add(usageFee))
|
||||||
|
.dataUsage(generateRandomDataUsage())
|
||||||
|
.voiceUsage(generateRandomVoiceUsage())
|
||||||
|
.smsUsage(generateRandomSmsUsage())
|
||||||
|
.billStatus(i == 0 ? "UNPAID" : "PAID") // 당월만 미납
|
||||||
|
.dueDate(monthDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mockBills.add(billEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mockBills.isEmpty()) {
|
||||||
|
billRepository.saveAll(mockBills);
|
||||||
|
log.info("회선 {}의 Mock 청구 데이터 {}개 생성 완료", lineNumber, mockBills.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 데이터 생성을 위한 헬퍼 메소드들
|
||||||
|
private BigDecimal generateRandomUsageFee() {
|
||||||
|
Random random = new Random();
|
||||||
|
return new BigDecimal(random.nextInt(20000)); // 0~20,000원
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRandomDataUsage() {
|
||||||
|
Random random = new Random();
|
||||||
|
double usage = random.nextDouble() * 100; // 0~100GB
|
||||||
|
return String.format("%.1fGB", usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRandomVoiceUsage() {
|
||||||
|
Random random = new Random();
|
||||||
|
int minutes = random.nextInt(500); // 0~500분
|
||||||
|
return String.format("%d분", minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRandomSmsUsage() {
|
||||||
|
Random random = new Random();
|
||||||
|
int count = random.nextInt(100); // 0~100건
|
||||||
|
return String.format("%d건", count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티를 Mock 데이터로 변환하는 메소드들
|
||||||
|
private MockCustomerData convertToMockCustomerData(CustomerEntity entity) {
|
||||||
|
return MockCustomerData.builder()
|
||||||
|
.lineNumber(entity.getLineNumber())
|
||||||
|
.customerName("Mock_Customer") // 고객명은 저장하지 않음
|
||||||
|
.customerId(entity.getCustomerId())
|
||||||
|
.operatorCode(entity.getOperatorCode())
|
||||||
|
.currentProductCode(entity.getCurrentProductCode())
|
||||||
|
.lineStatus(entity.getLineStatus())
|
||||||
|
.contractDate(entity.getContractDate())
|
||||||
|
.lastModified(entity.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockProductData convertToMockProductData(ProductEntity entity) {
|
||||||
|
return MockProductData.builder()
|
||||||
|
.productCode(entity.getProductCode())
|
||||||
|
.productName(entity.getProductName())
|
||||||
|
.monthlyFee(entity.getMonthlyFee())
|
||||||
|
.dataAllowance(entity.getDataAllowance())
|
||||||
|
.voiceAllowance(entity.getVoiceAllowance())
|
||||||
|
.smsAllowance(entity.getSmsAllowance())
|
||||||
|
.operatorCode(entity.getOperatorCode())
|
||||||
|
.networkType(entity.getNetworkType())
|
||||||
|
.status(entity.getStatus())
|
||||||
|
.description(entity.getDescription())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockBillData convertToMockBillData(BillEntity entity) {
|
||||||
|
return MockBillData.builder()
|
||||||
|
.lineNumber(entity.getLineNumber())
|
||||||
|
.billingMonth(entity.getBillingMonth())
|
||||||
|
.productCode(entity.getProductCode())
|
||||||
|
.productName(entity.getProductName())
|
||||||
|
.monthlyFee(entity.getMonthlyFee())
|
||||||
|
.usageFee(entity.getUsageFee())
|
||||||
|
.totalFee(entity.getTotalFee())
|
||||||
|
.dataUsage(entity.getDataUsage())
|
||||||
|
.voiceUsage(entity.getVoiceUsage())
|
||||||
|
.smsUsage(entity.getSmsUsage())
|
||||||
|
.billStatus(entity.getBillStatus())
|
||||||
|
.dueDate(entity.getDueDate())
|
||||||
|
.discountAmount(BigDecimal.ZERO) // BillEntity에 없으므로 기본값
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 상품 데이터 초기화
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
private void initializeMockProducts() {
|
||||||
|
log.info("Mock 상품 데이터 초기화 시작");
|
||||||
|
|
||||||
|
// 기존 상품이 있으면 초기화하지 않음
|
||||||
|
if (productRepository.count() > 0) {
|
||||||
|
log.info("이미 상품 데이터가 존재합니다. 초기화를 건너뜁니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 상품 데이터 생성
|
||||||
|
List<ProductEntity> products = Arrays.asList(
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("5G-PREMIUM-001")
|
||||||
|
.productName("5G 프리미엄")
|
||||||
|
.monthlyFee(new BigDecimal("89000"))
|
||||||
|
.dataAllowance("무제한")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("5G")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("5G 프리미엄 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("5G-STANDARD-001")
|
||||||
|
.productName("5G 스탠다드")
|
||||||
|
.monthlyFee(new BigDecimal("65000"))
|
||||||
|
.dataAllowance("100GB")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("5G")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("5G 스탠다드 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("LTE-PREMIUM-001")
|
||||||
|
.productName("LTE 프리미엄")
|
||||||
|
.monthlyFee(new BigDecimal("55000"))
|
||||||
|
.dataAllowance("무제한")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("LTE")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("LTE 프리미엄 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("LTE-BASIC-001")
|
||||||
|
.productName("LTE 베이직")
|
||||||
|
.monthlyFee(new BigDecimal("35000"))
|
||||||
|
.dataAllowance("50GB")
|
||||||
|
.voiceAllowance("300분")
|
||||||
|
.smsAllowance("100건")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("LTE")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("LTE 베이직 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("3G-OLD-001")
|
||||||
|
.productName("3G 기본")
|
||||||
|
.monthlyFee(new BigDecimal("25000"))
|
||||||
|
.dataAllowance("10GB")
|
||||||
|
.voiceAllowance("200분")
|
||||||
|
.smsAllowance("50건")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("3G")
|
||||||
|
.status("INACTIVE")
|
||||||
|
.description("3G 기본 요금제 (단종)")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
productRepository.saveAll(products);
|
||||||
|
log.info("Mock 상품 데이터 {}개 생성 완료", products.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객의 상품 코드 업데이트
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean updateCustomerProduct(String lineNumber, String newProductCode) {
|
||||||
|
log.info("고객 상품 코드 업데이트 - LineNumber: {}, NewProductCode: {}", lineNumber, newProductCode);
|
||||||
|
|
||||||
|
// 고객 정보 조회
|
||||||
|
Optional<CustomerEntity> customerOpt = customerRepository.findByLineNumber(lineNumber);
|
||||||
|
if (customerOpt.isEmpty()) {
|
||||||
|
log.warn("존재하지 않는 회선번호 - LineNumber: {}", lineNumber);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerEntity customer = customerOpt.get();
|
||||||
|
String oldProductCode = customer.getCurrentProductCode();
|
||||||
|
|
||||||
|
// 상품 코드 업데이트
|
||||||
|
customer.setCurrentProductCode(newProductCode);
|
||||||
|
customer.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
customerRepository.save(customer);
|
||||||
|
|
||||||
|
log.info("고객 상품 코드 업데이트 완료 - LineNumber: {}, {} -> {}",
|
||||||
|
lineNumber, oldProductCode, newProductCode);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 데이터 보유 월 목록 응답 DTO
|
||||||
|
*
|
||||||
|
* 특정 회선번호의 실제 요금 데이터가 있는 월 목록을 반환
|
||||||
|
*
|
||||||
|
* @author 이개발(백엔더)
|
||||||
|
* @version 1.0.1
|
||||||
|
* @since 2025-09-09
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class KosAvailableMonthsResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 결과 코드
|
||||||
|
* - 0000: 성공
|
||||||
|
* - 1001: 존재하지 않는 회선번호
|
||||||
|
* - 1002: 비활성 상태의 회선
|
||||||
|
*/
|
||||||
|
@JsonProperty("resultCode")
|
||||||
|
private String resultCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 결과 메시지
|
||||||
|
*/
|
||||||
|
@JsonProperty("resultMessage")
|
||||||
|
private String resultMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호
|
||||||
|
*/
|
||||||
|
@JsonProperty("lineNumber")
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터가 있는 월 목록 (yyyy-MM 형식)
|
||||||
|
* 예: ["2025-09", "2025-08", "2025-07"]
|
||||||
|
*/
|
||||||
|
@JsonProperty("availableMonths")
|
||||||
|
private List<String> availableMonths;
|
||||||
|
}
|
||||||
@ -12,19 +12,16 @@ import lombok.Data;
|
|||||||
@Schema(description = "KOS 요금 조회 요청")
|
@Schema(description = "KOS 요금 조회 요청")
|
||||||
public class KosBillInquiryRequest {
|
public class KosBillInquiryRequest {
|
||||||
|
|
||||||
@Schema(description = "회선번호", example = "01012345678", required = true)
|
@Schema(description = "회선번호", example = "01012345679", required = true)
|
||||||
@NotBlank(message = "회선번호는 필수입니다")
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
||||||
private String lineNumber;
|
private String lineNumber;
|
||||||
|
|
||||||
@Schema(description = "청구월 (YYYYMM)", example = "202501")
|
@Schema(description = "청구월 (YYYYMM)", example = "202508")
|
||||||
@Pattern(regexp = "^\\d{6}$", message = "청구월은 YYYYMM 형식이어야 합니다")
|
@Pattern(regexp = "^\\d{6}$", message = "청구월은 YYYYMM 형식이어야 합니다")
|
||||||
private String billingMonth;
|
private String billingMonth;
|
||||||
|
|
||||||
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
|
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
|
||||||
@NotBlank(message = "요청 ID는 필수입니다")
|
@NotBlank(message = "요청 ID는 필수입니다")
|
||||||
private String requestId;
|
private String requestId;
|
||||||
|
|
||||||
@Schema(description = "요청자 ID", example = "BILL_SERVICE")
|
|
||||||
private String requestorId;
|
|
||||||
}
|
}
|
||||||
@ -17,6 +17,9 @@ public class KosBillInquiryResponse {
|
|||||||
@Schema(description = "요청 ID", example = "REQ_20250108_001")
|
@Schema(description = "요청 ID", example = "REQ_20250108_001")
|
||||||
private String requestId;
|
private String requestId;
|
||||||
|
|
||||||
|
@Schema(description = "처리 상태", example = "SUCCESS")
|
||||||
|
private String procStatus;
|
||||||
|
|
||||||
@Schema(description = "처리 결과 코드", example = "0000")
|
@Schema(description = "처리 결과 코드", example = "0000")
|
||||||
private String resultCode;
|
private String resultCode;
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import lombok.Data;
|
|||||||
@Schema(description = "KOS 상품 변경 요청")
|
@Schema(description = "KOS 상품 변경 요청")
|
||||||
public class KosProductChangeRequest {
|
public class KosProductChangeRequest {
|
||||||
|
|
||||||
@Schema(description = "회선번호", example = "01012345678", required = true)
|
@Schema(description = "회선번호", example = "01012345679", required = true)
|
||||||
@NotBlank(message = "회선번호는 필수입니다")
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
||||||
private String lineNumber;
|
private String lineNumber;
|
||||||
@ -28,14 +28,4 @@ public class KosProductChangeRequest {
|
|||||||
@Schema(description = "요청 ID", example = "REQ_20250108_002", required = true)
|
@Schema(description = "요청 ID", example = "REQ_20250108_002", required = true)
|
||||||
@NotBlank(message = "요청 ID는 필수입니다")
|
@NotBlank(message = "요청 ID는 필수입니다")
|
||||||
private String requestId;
|
private String requestId;
|
||||||
|
|
||||||
@Schema(description = "요청자 ID", example = "PRODUCT_SERVICE")
|
|
||||||
private String requestorId;
|
|
||||||
|
|
||||||
@Schema(description = "변경 사유", example = "고객 요청에 의한 상품 변경")
|
|
||||||
private String changeReason;
|
|
||||||
|
|
||||||
@Schema(description = "적용 일자 (YYYYMMDD)", example = "20250115")
|
|
||||||
@Pattern(regexp = "^\\d{8}$", message = "적용 일자는 YYYYMMDD 형식이어야 합니다")
|
|
||||||
private String effectiveDate;
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 상품 정보
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "KOS 상품 정보")
|
||||||
|
public class KosProductInfo {
|
||||||
|
|
||||||
|
@JsonProperty("product_code")
|
||||||
|
@Schema(description = "상품 코드", example = "5G_BASIC_001")
|
||||||
|
private String productCode;
|
||||||
|
|
||||||
|
@JsonProperty("product_name")
|
||||||
|
@Schema(description = "상품명", example = "5G 베이직 요금제")
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
@JsonProperty("product_type")
|
||||||
|
@Schema(description = "상품 유형", example = "DATA")
|
||||||
|
private String productType;
|
||||||
|
|
||||||
|
@JsonProperty("monthly_fee")
|
||||||
|
@Schema(description = "월정액", example = "55000")
|
||||||
|
private Integer monthlyFee;
|
||||||
|
|
||||||
|
@JsonProperty("data_allowance")
|
||||||
|
@Schema(description = "데이터 제공량(GB)", example = "100")
|
||||||
|
private Integer dataAllowance;
|
||||||
|
|
||||||
|
@JsonProperty("voice_allowance")
|
||||||
|
@Schema(description = "음성통화 제공량(분)", example = "300")
|
||||||
|
private Integer voiceAllowance;
|
||||||
|
|
||||||
|
@JsonProperty("sms_allowance")
|
||||||
|
@Schema(description = "SMS 제공량(건)", example = "200")
|
||||||
|
private Integer smsAllowance;
|
||||||
|
|
||||||
|
@JsonProperty("network_type")
|
||||||
|
@Schema(description = "네트워크 유형", example = "5G")
|
||||||
|
private String networkType;
|
||||||
|
|
||||||
|
@JsonProperty("status")
|
||||||
|
@Schema(description = "상품 상태", example = "ACTIVE")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@JsonProperty("description")
|
||||||
|
@Schema(description = "상품 설명", example = "5G 네트워크를 이용한 대용량 데이터 요금제")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
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", required = true)
|
||||||
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
|
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
|
||||||
|
@JsonProperty("lineNumber")
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
|
||||||
|
@NotBlank(message = "요청 ID는 필수입니다")
|
||||||
|
@JsonProperty("requestId")
|
||||||
|
private String requestId;
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
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.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOS 상품 목록 조회 응답
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "KOS 상품 목록 조회 응답")
|
||||||
|
public class KosProductListResponse {
|
||||||
|
|
||||||
|
@JsonProperty("result_code")
|
||||||
|
@Schema(description = "결과 코드", example = "0000")
|
||||||
|
private String resultCode;
|
||||||
|
|
||||||
|
@JsonProperty("result_message")
|
||||||
|
@Schema(description = "결과 메시지", example = "상품 목록 조회 성공")
|
||||||
|
private String resultMessage;
|
||||||
|
|
||||||
|
@JsonProperty("product_count")
|
||||||
|
@Schema(description = "조회된 상품 개수", example = "5")
|
||||||
|
private Integer productCount;
|
||||||
|
|
||||||
|
@JsonProperty("products")
|
||||||
|
@Schema(description = "상품 목록")
|
||||||
|
private List<KosProductInfo> products;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 데이터 생성 요청 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MockDataCreateRequest {
|
||||||
|
|
||||||
|
@JsonProperty("customerId")
|
||||||
|
@NotBlank(message = "고객 ID는 필수입니다")
|
||||||
|
private String customerId;
|
||||||
|
|
||||||
|
@JsonProperty("lineNumber")
|
||||||
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
|
@Pattern(regexp = "^010\\d{8}$", message = "회선번호 형식이 올바르지 않습니다 (예: 01012345678)")
|
||||||
|
private String lineNumber;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 데이터 생성 응답 DTO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class MockDataCreateResponse {
|
||||||
|
|
||||||
|
@JsonProperty("customer_id")
|
||||||
|
private String customerId;
|
||||||
|
|
||||||
|
@JsonProperty("line_number")
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
@JsonProperty("current_product_code")
|
||||||
|
private String currentProductCode;
|
||||||
|
|
||||||
|
@JsonProperty("current_product_name")
|
||||||
|
private String currentProductName;
|
||||||
|
|
||||||
|
@JsonProperty("bill_count_created")
|
||||||
|
private int billCountCreated;
|
||||||
|
|
||||||
|
@JsonProperty("message")
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.phonebill.kosmock.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service API 응답 DTO
|
||||||
|
* /api/v1/users API에서 반환하는 사용자 정보
|
||||||
|
*/
|
||||||
|
public class UserResponseDto {
|
||||||
|
|
||||||
|
@JsonProperty("user_id")
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@JsonProperty("customer_id")
|
||||||
|
private String customerId;
|
||||||
|
|
||||||
|
@JsonProperty("line_number")
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
@JsonProperty("user_name")
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
@JsonProperty("account_status")
|
||||||
|
private String accountStatus;
|
||||||
|
|
||||||
|
@JsonProperty("last_login_at")
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
|
@JsonProperty("permissions")
|
||||||
|
private List<String> permissions;
|
||||||
|
|
||||||
|
// 기본 생성자
|
||||||
|
public UserResponseDto() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(String userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCustomerId() {
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerId(String customerId) {
|
||||||
|
this.customerId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLineNumber() {
|
||||||
|
return lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLineNumber(String lineNumber) {
|
||||||
|
this.lineNumber = lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserName() {
|
||||||
|
return userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserName(String userName) {
|
||||||
|
this.userName = userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccountStatus() {
|
||||||
|
return accountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountStatus(String accountStatus) {
|
||||||
|
this.accountStatus = accountStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastLoginAt() {
|
||||||
|
return lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLoginAt(LocalDateTime lastLoginAt) {
|
||||||
|
this.lastLoginAt = lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getPermissions() {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissions(List<String> permissions) {
|
||||||
|
this.permissions = permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "UserResponseDto{" +
|
||||||
|
"userId='" + userId + '\'' +
|
||||||
|
", customerId='" + customerId + '\'' +
|
||||||
|
", lineNumber='" + lineNumber + '\'' +
|
||||||
|
", userName='" + userName + '\'' +
|
||||||
|
", accountStatus='" + accountStatus + '\'' +
|
||||||
|
", lastLoginAt=" + lastLoginAt +
|
||||||
|
", permissions=" + permissions +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.phonebill.kosmock.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요금 정보 엔티티
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bills")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class BillEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "line_number", nullable = false, length = 20)
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
@Column(name = "billing_month", nullable = false, length = 6)
|
||||||
|
private String billingMonth;
|
||||||
|
|
||||||
|
@Column(name = "product_code", nullable = false, length = 50)
|
||||||
|
private String productCode;
|
||||||
|
|
||||||
|
@Column(name = "product_name", nullable = false, length = 100)
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
@Column(name = "monthly_fee", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal monthlyFee;
|
||||||
|
|
||||||
|
@Column(name = "usage_fee", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal usageFee;
|
||||||
|
|
||||||
|
@Column(name = "total_fee", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal totalFee;
|
||||||
|
|
||||||
|
@Column(name = "data_usage", length = 20)
|
||||||
|
private String dataUsage;
|
||||||
|
|
||||||
|
@Column(name = "voice_usage", length = 20)
|
||||||
|
private String voiceUsage;
|
||||||
|
|
||||||
|
@Column(name = "sms_usage", length = 20)
|
||||||
|
private String smsUsage;
|
||||||
|
|
||||||
|
@Column(name = "bill_status", nullable = false, length = 20)
|
||||||
|
private String billStatus;
|
||||||
|
|
||||||
|
@Column(name = "due_date", length = 8)
|
||||||
|
private String dueDate;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// 고객 정보와의 관계
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "line_number", insertable = false, updatable = false)
|
||||||
|
private CustomerEntity customer;
|
||||||
|
|
||||||
|
// 상품 정보와의 관계
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "product_code", insertable = false, updatable = false)
|
||||||
|
private ProductEntity product;
|
||||||
|
|
||||||
|
// 복합 인덱스 설정
|
||||||
|
@Table(indexes = {
|
||||||
|
@Index(name = "idx_line_billing_month", columnList = "line_number, billing_month")
|
||||||
|
})
|
||||||
|
public static class BillEntityIndex {}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.phonebill.kosmock.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 정보 엔티티 (상품가입정보 포함)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "customers")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class CustomerEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "line_number", nullable = false, length = 20)
|
||||||
|
private String lineNumber;
|
||||||
|
|
||||||
|
@Column(name = "customer_id", nullable = false, length = 50)
|
||||||
|
private String customerId;
|
||||||
|
|
||||||
|
@Column(name = "operator_code", nullable = false, length = 10)
|
||||||
|
private String operatorCode;
|
||||||
|
|
||||||
|
@Column(name = "current_product_code", nullable = false, length = 50)
|
||||||
|
private String currentProductCode;
|
||||||
|
|
||||||
|
@Column(name = "line_status", nullable = false, length = 20)
|
||||||
|
private String lineStatus;
|
||||||
|
|
||||||
|
@Column(name = "contract_date", nullable = false)
|
||||||
|
private LocalDateTime contractDate;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
// 상품 엔티티와의 관계 설정 (조회 성능을 위해)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "current_product_code", insertable = false, updatable = false)
|
||||||
|
private ProductEntity product;
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.phonebill.kosmock.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 정보 엔티티
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "products")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class ProductEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "product_code", nullable = false, length = 50)
|
||||||
|
private String productCode;
|
||||||
|
|
||||||
|
@Column(name = "product_name", nullable = false, length = 100)
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
@Column(name = "monthly_fee", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal monthlyFee;
|
||||||
|
|
||||||
|
@Column(name = "data_allowance", length = 20)
|
||||||
|
private String dataAllowance;
|
||||||
|
|
||||||
|
@Column(name = "voice_allowance", length = 20)
|
||||||
|
private String voiceAllowance;
|
||||||
|
|
||||||
|
@Column(name = "sms_allowance", length = 20)
|
||||||
|
private String smsAllowance;
|
||||||
|
|
||||||
|
@Column(name = "operator_code", nullable = false, length = 10)
|
||||||
|
private String operatorCode;
|
||||||
|
|
||||||
|
@Column(name = "network_type", nullable = false, length = 10)
|
||||||
|
private String networkType;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 200)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.phonebill.kosmock.repository;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.entity.BillEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요금 정보 Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface BillRepository extends JpaRepository<BillEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호와 청구월로 요금 정보 조회
|
||||||
|
*/
|
||||||
|
Optional<BillEntity> findByLineNumberAndBillingMonth(String lineNumber, String billingMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호별 요금 이력 조회 (최신순)
|
||||||
|
*/
|
||||||
|
List<BillEntity> findByLineNumberOrderByBillingMonthDesc(String lineNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청구월별 요금 정보 조회
|
||||||
|
*/
|
||||||
|
List<BillEntity> findByBillingMonth(String billingMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호별 특정 개수만큼 최근 요금 이력 조회
|
||||||
|
*/
|
||||||
|
@Query("SELECT b FROM BillEntity b WHERE b.lineNumber = :lineNumber ORDER BY b.billingMonth DESC LIMIT :limit")
|
||||||
|
List<BillEntity> findRecentBillsByLineNumber(@Param("lineNumber") String lineNumber, @Param("limit") int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호와 청구월로 요금 정보 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByLineNumberAndBillingMonth(String lineNumber, String billingMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호별 요금 정보 개수
|
||||||
|
*/
|
||||||
|
long countByLineNumber(String lineNumber);
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.phonebill.kosmock.repository;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.entity.CustomerEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 정보 Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface CustomerRepository extends JpaRepository<CustomerEntity, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 ID로 조회
|
||||||
|
*/
|
||||||
|
Optional<CustomerEntity> findByCustomerId(String customerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선 번호로 조회
|
||||||
|
*/
|
||||||
|
Optional<CustomerEntity> findByLineNumber(String lineNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 코드별 고객 목록 조회
|
||||||
|
*/
|
||||||
|
List<CustomerEntity> findByCurrentProductCode(String productCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선 상태별 고객 목록 조회
|
||||||
|
*/
|
||||||
|
List<CustomerEntity> findByLineStatus(String lineStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 ID와 회선번호로 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByCustomerIdAndLineNumber(String customerId, String lineNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 ID와 회선번호로 조회
|
||||||
|
*/
|
||||||
|
Optional<CustomerEntity> findByCustomerIdAndLineNumber(String customerId, String lineNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 정보와 함께 고객 조회
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM CustomerEntity c LEFT JOIN FETCH c.product WHERE c.lineNumber = :lineNumber")
|
||||||
|
Optional<CustomerEntity> findByLineNumberWithProduct(@Param("lineNumber") String lineNumber);
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.phonebill.kosmock.repository;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.entity.ProductEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 정보 Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface ProductRepository extends JpaRepository<ProductEntity, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 상품 목록 조회
|
||||||
|
*/
|
||||||
|
List<ProductEntity> findByStatusOrderByMonthlyFeeDesc(String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네트워크 타입별 상품 조회
|
||||||
|
*/
|
||||||
|
List<ProductEntity> findByNetworkTypeAndStatusOrderByMonthlyFeeDesc(String networkType, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 코드로 조회
|
||||||
|
*/
|
||||||
|
Optional<ProductEntity> findByProductCode(String productCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByProductCode(String productCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 상품 수 조회
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(p) FROM ProductEntity p")
|
||||||
|
long countAllProducts();
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
package com.phonebill.kosmock.service;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.entity.ProductEntity;
|
||||||
|
import com.phonebill.kosmock.repository.ProductRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애플리케이션 시작 시 초기 데이터 생성 서비스
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DataInitializationService implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DataInitializationService.class);
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) throws Exception {
|
||||||
|
initializeProductData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 정보 초기화
|
||||||
|
* 상품이 없을 때만 수행
|
||||||
|
*/
|
||||||
|
private void initializeProductData() {
|
||||||
|
long productCount = productRepository.countAllProducts();
|
||||||
|
|
||||||
|
if (productCount == 0) {
|
||||||
|
log.info("상품 정보가 없습니다. 초기 상품 데이터를 생성합니다...");
|
||||||
|
|
||||||
|
List<ProductEntity> initialProducts = createInitialProducts();
|
||||||
|
productRepository.saveAll(initialProducts);
|
||||||
|
|
||||||
|
log.info("초기 상품 데이터 {}개 생성 완료", initialProducts.size());
|
||||||
|
} else {
|
||||||
|
log.info("기존 상품 정보 {}개가 존재합니다. 초기화를 건너뜁니다.", productCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기 상품 데이터 생성
|
||||||
|
*/
|
||||||
|
private List<ProductEntity> createInitialProducts() {
|
||||||
|
return Arrays.asList(
|
||||||
|
// 5G 상품
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("5G-PREMIUM-001")
|
||||||
|
.productName("5G 프리미엄 플랜")
|
||||||
|
.monthlyFee(new BigDecimal("89000"))
|
||||||
|
.dataAllowance("무제한")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("5G")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("5G 네트워크 무제한 프리미엄 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("5G-STANDARD-001")
|
||||||
|
.productName("5G 스탠다드 플랜")
|
||||||
|
.monthlyFee(new BigDecimal("69000"))
|
||||||
|
.dataAllowance("100GB")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("5G")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("5G 네트워크 스탠다드 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// LTE 상품
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("LTE-PREMIUM-001")
|
||||||
|
.productName("LTE 프리미엄 플랜")
|
||||||
|
.monthlyFee(new BigDecimal("59000"))
|
||||||
|
.dataAllowance("50GB")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("무제한")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("LTE")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("LTE 네트워크 프리미엄 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("LTE-BASIC-001")
|
||||||
|
.productName("LTE 베이직 플랜")
|
||||||
|
.monthlyFee(new BigDecimal("39000"))
|
||||||
|
.dataAllowance("20GB")
|
||||||
|
.voiceAllowance("무제한")
|
||||||
|
.smsAllowance("기본 제공")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("LTE")
|
||||||
|
.status("ACTIVE")
|
||||||
|
.description("LTE 네트워크 베이직 요금제")
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// 종료된 상품 (변경 불가)
|
||||||
|
ProductEntity.builder()
|
||||||
|
.productCode("3G-OLD-001")
|
||||||
|
.productName("3G 레거시 플랜")
|
||||||
|
.monthlyFee(new BigDecimal("29000"))
|
||||||
|
.dataAllowance("5GB")
|
||||||
|
.voiceAllowance("500분")
|
||||||
|
.smsAllowance("100건")
|
||||||
|
.operatorCode("KT")
|
||||||
|
.networkType("3G")
|
||||||
|
.status("DISCONTINUED")
|
||||||
|
.description("3G 네트워크 레거시 요금제 (신규 가입 불가)")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.phonebill.kosmock.service;
|
|||||||
|
|
||||||
import com.phonebill.kosmock.config.MockConfig;
|
import com.phonebill.kosmock.config.MockConfig;
|
||||||
import com.phonebill.kosmock.data.*;
|
import com.phonebill.kosmock.data.*;
|
||||||
|
import com.phonebill.kosmock.data.MockDataService;
|
||||||
import com.phonebill.kosmock.dto.*;
|
import com.phonebill.kosmock.dto.*;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -72,6 +73,7 @@ public class KosMockService {
|
|||||||
// 성공 응답 생성
|
// 성공 응답 생성
|
||||||
KosBillInquiryResponse response = KosBillInquiryResponse.builder()
|
KosBillInquiryResponse response = KosBillInquiryResponse.builder()
|
||||||
.requestId(request.getRequestId())
|
.requestId(request.getRequestId())
|
||||||
|
.procStatus("SUCCESS")
|
||||||
.resultCode("0000")
|
.resultCode("0000")
|
||||||
.resultMessage("정상 처리되었습니다")
|
.resultMessage("정상 처리되었습니다")
|
||||||
.billInfo(KosBillInquiryResponse.BillInfo.builder()
|
.billInfo(KosBillInquiryResponse.BillInfo.builder()
|
||||||
@ -158,10 +160,15 @@ public class KosMockService {
|
|||||||
// KOS 주문 번호 생성
|
// KOS 주문 번호 생성
|
||||||
String kosOrderNumber = generateKosOrderNumber();
|
String kosOrderNumber = generateKosOrderNumber();
|
||||||
|
|
||||||
// 적용 일자 설정 (없으면 내일 사용)
|
// 적용 일자 설정 (현재 날짜로 자동 설정)
|
||||||
String effectiveDate = request.getEffectiveDate();
|
String effectiveDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||||
if (effectiveDate == null || effectiveDate.isEmpty()) {
|
|
||||||
effectiveDate = LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
// 실제 고객 데이터 업데이트
|
||||||
|
boolean updateSuccess = mockDataService.updateCustomerProduct(request.getLineNumber(), request.getTargetProductCode());
|
||||||
|
if (!updateSuccess) {
|
||||||
|
log.error("고객 상품 코드 업데이트 실패 - LineNumber: {}, TargetProduct: {}",
|
||||||
|
request.getLineNumber(), request.getTargetProductCode());
|
||||||
|
return createProductChangeErrorResponse(request.getRequestId(), "2099", "상품 변경 처리 중 오류가 발생했습니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공 응답 생성
|
// 성공 응답 생성
|
||||||
@ -202,6 +209,161 @@ public class KosMockService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 목록 조회 (Mock)
|
||||||
|
*/
|
||||||
|
public KosProductListResponse getProductList() {
|
||||||
|
log.info("KOS Mock 상품 목록 조회 요청 처리 시작");
|
||||||
|
|
||||||
|
// Mock 응답 지연 시뮬레이션
|
||||||
|
simulateProcessingDelay();
|
||||||
|
|
||||||
|
// Mock 실패 시뮬레이션
|
||||||
|
if (shouldSimulateFailure()) {
|
||||||
|
log.warn("KOS Mock 상품 목록 조회 실패 시뮬레이션");
|
||||||
|
throw new RuntimeException("KOS 시스템 일시적 오류");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock 데이터에서 상품 목록 조회
|
||||||
|
java.util.List<MockProductData> productDataList = mockDataService.getAllProducts();
|
||||||
|
|
||||||
|
// KosProductInfo 리스트로 변환
|
||||||
|
java.util.List<KosProductInfo> productInfoList = productDataList.stream()
|
||||||
|
.map(this::convertToProductInfo)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
|
log.info("KOS Mock 상품 목록 조회 완료 - 상품 수: {}", productInfoList.size());
|
||||||
|
|
||||||
|
return KosProductListResponse.builder()
|
||||||
|
.resultCode("0000")
|
||||||
|
.resultMessage("상품 목록 조회가 완료되었습니다")
|
||||||
|
.productCount(productInfoList.size())
|
||||||
|
.products(productInfoList)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("KOS Mock 상품 목록 조회 처리 중 오류 발생", e);
|
||||||
|
return KosProductListResponse.builder()
|
||||||
|
.resultCode("9999")
|
||||||
|
.resultMessage("시스템 오류가 발생했습니다")
|
||||||
|
.productCount(0)
|
||||||
|
.products(java.util.Collections.emptyList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입상품 조회 처리 (Mock)
|
||||||
|
*/
|
||||||
|
public KosProductInquiryResponse processProductInquiry(KosProductInquiryRequest request) {
|
||||||
|
log.info("KOS Mock 가입상품 조회 요청 처리 시작 - RequestId: {}, LineNumber: {}",
|
||||||
|
request.getRequestId(), request.getLineNumber());
|
||||||
|
|
||||||
|
// Mock 응답 지연 시뮬레이션
|
||||||
|
simulateProcessingDelay();
|
||||||
|
|
||||||
|
// Mock 실패 시뮬레이션
|
||||||
|
if (shouldSimulateFailure()) {
|
||||||
|
log.warn("KOS Mock 가입상품 조회 실패 시뮬레이션 - RequestId: {}", request.getRequestId());
|
||||||
|
throw new RuntimeException("KOS 시스템 일시적 오류");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고객 데이터 조회
|
||||||
|
MockCustomerData customerData = mockDataService.getCustomerData(request.getLineNumber());
|
||||||
|
if (customerData == null) {
|
||||||
|
log.warn("존재하지 않는 회선번호 - LineNumber: {}", request.getLineNumber());
|
||||||
|
return createProductInquiryErrorResponse(request.getRequestId(), "3001", "존재하지 않는 회선번호입니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회선 상태 확인
|
||||||
|
if (!"ACTIVE".equals(customerData.getLineStatus())) {
|
||||||
|
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
|
||||||
|
request.getLineNumber(), customerData.getLineStatus());
|
||||||
|
return createProductInquiryErrorResponse(request.getRequestId(), "3002", "비활성 상태의 회선입니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 상품 정보 조회
|
||||||
|
MockProductData productData = mockDataService.getProductData(customerData.getCurrentProductCode());
|
||||||
|
if (productData == null) {
|
||||||
|
log.warn("존재하지 않는 상품 코드 - ProductCode: {}", customerData.getCurrentProductCode());
|
||||||
|
return createProductInquiryErrorResponse(request.getRequestId(), "3003", "상품 정보를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 응답 생성
|
||||||
|
KosProductInquiryResponse response = KosProductInquiryResponse.builder()
|
||||||
|
.requestId(request.getRequestId())
|
||||||
|
.procStatus("SUCCESS")
|
||||||
|
.resultCode("0000")
|
||||||
|
.resultMessage("정상 처리되었습니다")
|
||||||
|
.productInfo(KosProductInquiryResponse.ProductInfo.builder()
|
||||||
|
.lineNumber(customerData.getLineNumber())
|
||||||
|
.currentProductCode(productData.getProductCode())
|
||||||
|
.currentProductName(productData.getProductName())
|
||||||
|
.monthlyFee(productData.getMonthlyFee())
|
||||||
|
.dataAllowance(productData.getDataAllowance())
|
||||||
|
.voiceAllowance(productData.getVoiceAllowance())
|
||||||
|
.smsAllowance(productData.getSmsAllowance())
|
||||||
|
.productStatus(productData.getStatus())
|
||||||
|
.contractDate(customerData.getContractDate())
|
||||||
|
.build())
|
||||||
|
.customerInfo(KosProductInquiryResponse.CustomerInfo.builder()
|
||||||
|
.customerName(customerData.getCustomerName())
|
||||||
|
.customerId(customerData.getCustomerId())
|
||||||
|
.operatorCode(customerData.getOperatorCode())
|
||||||
|
.lineStatus(customerData.getLineStatus())
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("KOS Mock 가입상품 조회 처리 완료 - RequestId: {}", request.getRequestId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회선번호의 실제 요금 데이터가 있는 월 목록 조회 (Mock)
|
||||||
|
*/
|
||||||
|
public KosAvailableMonthsResponse getAvailableMonths(String lineNumber) {
|
||||||
|
log.info("KOS Mock 데이터 보유 월 목록 조회 - LineNumber: {}", lineNumber);
|
||||||
|
|
||||||
|
// Mock 응답 지연 시뮬레이션
|
||||||
|
simulateProcessingDelay();
|
||||||
|
|
||||||
|
// 고객 데이터 조회
|
||||||
|
MockCustomerData customerData = mockDataService.getCustomerData(lineNumber);
|
||||||
|
if (customerData == null) {
|
||||||
|
log.warn("존재하지 않는 회선번호 - LineNumber: {}", lineNumber);
|
||||||
|
return KosAvailableMonthsResponse.builder()
|
||||||
|
.resultCode("1001")
|
||||||
|
.resultMessage("존재하지 않는 회선번호입니다")
|
||||||
|
.availableMonths(java.util.Collections.emptyList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회선 상태 확인
|
||||||
|
if (!"ACTIVE".equals(customerData.getLineStatus())) {
|
||||||
|
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
|
||||||
|
lineNumber, customerData.getLineStatus());
|
||||||
|
return KosAvailableMonthsResponse.builder()
|
||||||
|
.resultCode("1002")
|
||||||
|
.resultMessage("비활성 상태의 회선입니다")
|
||||||
|
.availableMonths(java.util.Collections.emptyList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 데이터에서 실제 데이터가 있는 월 목록 조회
|
||||||
|
java.util.List<String> availableMonths = mockDataService.getAvailableMonths(lineNumber);
|
||||||
|
|
||||||
|
log.info("KOS Mock 데이터 보유 월 목록 조회 완료 - LineNumber: {}, 월 수: {}",
|
||||||
|
lineNumber, availableMonths.size());
|
||||||
|
|
||||||
|
return KosAvailableMonthsResponse.builder()
|
||||||
|
.resultCode("0000")
|
||||||
|
.resultMessage("정상 처리되었습니다")
|
||||||
|
.lineNumber(lineNumber)
|
||||||
|
.availableMonths(availableMonths)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 처리 지연 시뮬레이션
|
* 처리 지연 시뮬레이션
|
||||||
*/
|
*/
|
||||||
@ -235,6 +397,7 @@ public class KosMockService {
|
|||||||
private KosBillInquiryResponse createBillInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
|
private KosBillInquiryResponse createBillInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
|
||||||
return KosBillInquiryResponse.builder()
|
return KosBillInquiryResponse.builder()
|
||||||
.requestId(requestId)
|
.requestId(requestId)
|
||||||
|
.procStatus("FAILED")
|
||||||
.resultCode(errorCode)
|
.resultCode(errorCode)
|
||||||
.resultMessage(errorMessage)
|
.resultMessage(errorMessage)
|
||||||
.build();
|
.build();
|
||||||
@ -250,4 +413,76 @@ public class KosMockService {
|
|||||||
.resultMessage(errorMessage)
|
.resultMessage(errorMessage)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입상품 조회 오류 응답 생성
|
||||||
|
*/
|
||||||
|
private KosProductInquiryResponse createProductInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
|
||||||
|
return KosProductInquiryResponse.builder()
|
||||||
|
.requestId(requestId)
|
||||||
|
.procStatus("FAILED")
|
||||||
|
.resultCode(errorCode)
|
||||||
|
.resultMessage(errorMessage)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockProductData를 KosProductInfo로 변환
|
||||||
|
*/
|
||||||
|
private KosProductInfo convertToProductInfo(MockProductData productData) {
|
||||||
|
return KosProductInfo.builder()
|
||||||
|
.productCode(productData.getProductCode())
|
||||||
|
.productName(productData.getProductName())
|
||||||
|
.productType(productData.getPlanType())
|
||||||
|
.monthlyFee(productData.getMonthlyFee().intValue())
|
||||||
|
.dataAllowance(parseDataAllowance(productData.getDataAllowance()))
|
||||||
|
.voiceAllowance(parseVoiceAllowance(productData.getVoiceAllowance()))
|
||||||
|
.smsAllowance(parseSmsAllowance(productData.getSmsAllowance()))
|
||||||
|
.networkType(productData.getNetworkType())
|
||||||
|
.status(productData.getStatus())
|
||||||
|
.description(productData.getDescription())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 허용량을 정수로 변환 (GB 단위)
|
||||||
|
*/
|
||||||
|
private Integer parseDataAllowance(String dataAllowance) {
|
||||||
|
if (dataAllowance == null || "무제한".equals(dataAllowance)) {
|
||||||
|
return -1; // 무제한을 -1로 표현
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(dataAllowance.replaceAll("[^0-9]", ""));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 음성 허용량을 정수로 변환 (분 단위)
|
||||||
|
*/
|
||||||
|
private Integer parseVoiceAllowance(String voiceAllowance) {
|
||||||
|
if (voiceAllowance == null || "무제한".equals(voiceAllowance)) {
|
||||||
|
return -1; // 무제한을 -1로 표현
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(voiceAllowance.replaceAll("[^0-9]", ""));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS 허용량을 정수로 변환 (건 단위)
|
||||||
|
*/
|
||||||
|
private Integer parseSmsAllowance(String smsAllowance) {
|
||||||
|
if (smsAllowance == null || "무제한".equals(smsAllowance)) {
|
||||||
|
return -1; // 무제한을 -1로 표현
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(smsAllowance.replaceAll("[^0-9]", ""));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,324 @@
|
|||||||
|
package com.phonebill.kosmock.service;
|
||||||
|
|
||||||
|
import com.phonebill.kosmock.dto.MockDataCreateRequest;
|
||||||
|
import com.phonebill.kosmock.dto.MockDataCreateResponse;
|
||||||
|
import com.phonebill.kosmock.entity.BillEntity;
|
||||||
|
import com.phonebill.kosmock.entity.CustomerEntity;
|
||||||
|
import com.phonebill.kosmock.entity.ProductEntity;
|
||||||
|
import com.phonebill.kosmock.repository.BillRepository;
|
||||||
|
import com.phonebill.kosmock.repository.CustomerRepository;
|
||||||
|
import com.phonebill.kosmock.repository.ProductRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 데이터 생성 서비스
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MockDataCreateService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MockDataCreateService.class);
|
||||||
|
private final CustomerRepository customerRepository;
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final BillRepository billRepository;
|
||||||
|
private final com.phonebill.kosmock.data.MockDataService mockDataService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 데이터 생성 (고객 정보 + 요금 정보)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MockDataCreateResponse createMockData(MockDataCreateRequest request) {
|
||||||
|
log.info("Mock 데이터 생성 시작 - CustomerId: {}, LineNumber: {}",
|
||||||
|
request.getCustomerId(), request.getLineNumber());
|
||||||
|
|
||||||
|
// 1. 기존 데이터 존재 여부 확인
|
||||||
|
if (customerRepository.existsByCustomerIdAndLineNumber(request.getCustomerId(), request.getLineNumber())) {
|
||||||
|
throw new IllegalArgumentException("이미 존재하는 고객 정보입니다: " + request.getCustomerId() + ", " + request.getLineNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 랜덤 상품 선택
|
||||||
|
List<ProductEntity> activeProducts = productRepository.findByStatusOrderByMonthlyFeeDesc("ACTIVE");
|
||||||
|
if (activeProducts.isEmpty()) {
|
||||||
|
throw new IllegalStateException("활성화된 상품이 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductEntity selectedProduct = selectRandomProduct(activeProducts);
|
||||||
|
|
||||||
|
// 3. 고객 정보 생성
|
||||||
|
CustomerEntity customer = createCustomer(request, selectedProduct);
|
||||||
|
customerRepository.save(customer);
|
||||||
|
|
||||||
|
// 4. 요금 정보 생성 (최근 3개월)
|
||||||
|
List<BillEntity> bills = createBills(customer, selectedProduct);
|
||||||
|
billRepository.saveAll(bills);
|
||||||
|
|
||||||
|
log.info("Mock 데이터 생성 완료 - CustomerId: {}, LineNumber: {}, Product: {}, Bills: {}",
|
||||||
|
request.getCustomerId(), request.getLineNumber(), selectedProduct.getProductCode(), bills.size());
|
||||||
|
|
||||||
|
return MockDataCreateResponse.builder()
|
||||||
|
.customerId(customer.getCustomerId())
|
||||||
|
.lineNumber(customer.getLineNumber())
|
||||||
|
.currentProductCode(selectedProduct.getProductCode())
|
||||||
|
.currentProductName(selectedProduct.getProductName())
|
||||||
|
.billCountCreated(bills.size())
|
||||||
|
.message("Mock 데이터가 성공적으로 생성되었습니다")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랜덤 상품 선택 (가중치 적용)
|
||||||
|
*/
|
||||||
|
private ProductEntity selectRandomProduct(List<ProductEntity> products) {
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
// 가중치 적용: 프리미엄 상품(30%), 스탠다드 상품(70%)
|
||||||
|
if (random.nextDouble() < 0.7) {
|
||||||
|
// 스탠다드/베이직 상품 우선
|
||||||
|
Optional<ProductEntity> basicProduct = products.stream()
|
||||||
|
.filter(p -> p.getProductName().contains("베이직") || p.getProductName().contains("스탠다드"))
|
||||||
|
.findFirst();
|
||||||
|
if (basicProduct.isPresent()) {
|
||||||
|
return basicProduct.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤 선택
|
||||||
|
return products.get(random.nextInt(products.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 정보 생성
|
||||||
|
*/
|
||||||
|
private CustomerEntity createCustomer(MockDataCreateRequest request, ProductEntity product) {
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
return CustomerEntity.builder()
|
||||||
|
.lineNumber(request.getLineNumber())
|
||||||
|
.customerId(request.getCustomerId())
|
||||||
|
.operatorCode("KT")
|
||||||
|
.currentProductCode(product.getProductCode())
|
||||||
|
.lineStatus("ACTIVE")
|
||||||
|
.contractDate(LocalDateTime.now().minusMonths(random.nextInt(24) + 1)) // 1~24개월 전 가입
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요금 정보 생성 (최근 3개월)
|
||||||
|
*/
|
||||||
|
private List<BillEntity> createBills(CustomerEntity customer, ProductEntity product) {
|
||||||
|
List<BillEntity> bills = new ArrayList<>();
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
for (int month = 0; month < 3; month++) {
|
||||||
|
LocalDateTime billDate = LocalDateTime.now().minusMonths(month);
|
||||||
|
String billingMonth = billDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||||
|
|
||||||
|
// 사용료 계산 (랜덤)
|
||||||
|
BigDecimal usageFee = new BigDecimal(random.nextInt(30000));
|
||||||
|
BigDecimal totalFee = product.getMonthlyFee().add(usageFee);
|
||||||
|
|
||||||
|
BillEntity bill = BillEntity.builder()
|
||||||
|
.lineNumber(customer.getLineNumber())
|
||||||
|
.billingMonth(billingMonth)
|
||||||
|
.productCode(product.getProductCode())
|
||||||
|
.productName(product.getProductName())
|
||||||
|
.monthlyFee(product.getMonthlyFee())
|
||||||
|
.usageFee(usageFee)
|
||||||
|
.totalFee(totalFee)
|
||||||
|
.dataUsage(generateDataUsage(product))
|
||||||
|
.voiceUsage(generateVoiceUsage(product))
|
||||||
|
.smsUsage(generateSmsUsage())
|
||||||
|
.billStatus("CONFIRMED")
|
||||||
|
.dueDate(billDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
bills.add(bill);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bills;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateDataUsage(ProductEntity product) {
|
||||||
|
Random random = new Random();
|
||||||
|
if ("무제한".equals(product.getDataAllowance())) {
|
||||||
|
return random.nextInt(200) + "GB";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
int allowance = Integer.parseInt(product.getDataAllowance().replace("GB", ""));
|
||||||
|
return random.nextInt(allowance) + "GB";
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return "10GB"; // 기본값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateVoiceUsage(ProductEntity product) {
|
||||||
|
Random random = new Random();
|
||||||
|
if ("무제한".equals(product.getVoiceAllowance())) {
|
||||||
|
return random.nextInt(500) + "분";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
int allowance = Integer.parseInt(product.getVoiceAllowance().replace("분", ""));
|
||||||
|
return random.nextInt(allowance) + "분";
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return "100분"; // 기본값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateSmsUsage() {
|
||||||
|
Random random = new Random();
|
||||||
|
return random.nextInt(100) + "건";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 가입상품정보 조회
|
||||||
|
*/
|
||||||
|
public Object getCustomerProduct(String customerId, String lineNumber) {
|
||||||
|
log.info("고객 상품정보 조회 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
|
||||||
|
Optional<CustomerEntity> customer = customerRepository
|
||||||
|
.findByCustomerIdAndLineNumber(customerId, lineNumber);
|
||||||
|
|
||||||
|
if (customer.isEmpty()) {
|
||||||
|
log.warn("고객 정보를 찾을 수 없음 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerEntity customerEntity = customer.get();
|
||||||
|
|
||||||
|
// 현재 상품 정보 조회
|
||||||
|
Optional<ProductEntity> product = productRepository
|
||||||
|
.findByProductCode(customerEntity.getCurrentProductCode());
|
||||||
|
|
||||||
|
if (product.isEmpty()) {
|
||||||
|
log.warn("상품 정보를 찾을 수 없음 - ProductCode: {}", customerEntity.getCurrentProductCode());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductEntity productEntity = product.get();
|
||||||
|
|
||||||
|
// 응답 데이터 구성
|
||||||
|
return CustomerProductInfo.builder()
|
||||||
|
.customerId(customerEntity.getCustomerId())
|
||||||
|
.lineNumber(customerEntity.getLineNumber())
|
||||||
|
.operatorCode(customerEntity.getOperatorCode())
|
||||||
|
.lineStatus(customerEntity.getLineStatus())
|
||||||
|
.contractDate(customerEntity.getContractDate())
|
||||||
|
.currentProductCode(productEntity.getProductCode())
|
||||||
|
.currentProductName(productEntity.getProductName())
|
||||||
|
.monthlyFee(productEntity.getMonthlyFee())
|
||||||
|
.dataAllowance(productEntity.getDataAllowance())
|
||||||
|
.voiceAllowance(productEntity.getVoiceAllowance())
|
||||||
|
.smsAllowance(productEntity.getSmsAllowance())
|
||||||
|
.productStatus(productEntity.getStatus())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 요금정보 조회
|
||||||
|
*/
|
||||||
|
public Object getCustomerBill(String customerId, String lineNumber) {
|
||||||
|
log.info("고객 요금정보 조회 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
|
||||||
|
Optional<CustomerEntity> customer = customerRepository
|
||||||
|
.findByCustomerIdAndLineNumber(customerId, lineNumber);
|
||||||
|
|
||||||
|
if (customer.isEmpty()) {
|
||||||
|
log.warn("고객 정보를 찾을 수 없음 - CustomerId: {}, LineNumber: {}", customerId, lineNumber);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 3개월 요금 정보 조회
|
||||||
|
List<BillEntity> bills = billRepository.findByLineNumberOrderByBillingMonthDesc(lineNumber);
|
||||||
|
|
||||||
|
if (bills.isEmpty()) {
|
||||||
|
log.warn("요금 정보를 찾을 수 없음 - LineNumber: {}", lineNumber);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터 구성
|
||||||
|
List<CustomerBillInfo> billInfos = bills.stream()
|
||||||
|
.map(bill -> CustomerBillInfo.builder()
|
||||||
|
.billingMonth(bill.getBillingMonth())
|
||||||
|
.productCode(bill.getProductCode())
|
||||||
|
.productName(bill.getProductName())
|
||||||
|
.monthlyFee(bill.getMonthlyFee())
|
||||||
|
.usageFee(bill.getUsageFee())
|
||||||
|
.totalFee(bill.getTotalFee())
|
||||||
|
.dataUsage(bill.getDataUsage())
|
||||||
|
.voiceUsage(bill.getVoiceUsage())
|
||||||
|
.smsUsage(bill.getSmsUsage())
|
||||||
|
.billStatus(bill.getBillStatus())
|
||||||
|
.dueDate(bill.getDueDate())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return CustomerBillsInfo.builder()
|
||||||
|
.customerId(customer.get().getCustomerId())
|
||||||
|
.lineNumber(lineNumber)
|
||||||
|
.billCount(billInfos.size())
|
||||||
|
.bills(billInfos)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 상품정보 응답 DTO
|
||||||
|
*/
|
||||||
|
@lombok.Builder
|
||||||
|
@lombok.Data
|
||||||
|
public static class CustomerProductInfo {
|
||||||
|
private String customerId;
|
||||||
|
private String lineNumber;
|
||||||
|
private String operatorCode;
|
||||||
|
private String lineStatus;
|
||||||
|
private LocalDateTime contractDate;
|
||||||
|
private String currentProductCode;
|
||||||
|
private String currentProductName;
|
||||||
|
private BigDecimal monthlyFee;
|
||||||
|
private String dataAllowance;
|
||||||
|
private String voiceAllowance;
|
||||||
|
private String smsAllowance;
|
||||||
|
private String productStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고객 요금정보 응답 DTO
|
||||||
|
*/
|
||||||
|
@lombok.Builder
|
||||||
|
@lombok.Data
|
||||||
|
public static class CustomerBillsInfo {
|
||||||
|
private String customerId;
|
||||||
|
private String lineNumber;
|
||||||
|
private int billCount;
|
||||||
|
private List<CustomerBillInfo> bills;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Builder
|
||||||
|
@lombok.Data
|
||||||
|
public static class CustomerBillInfo {
|
||||||
|
private String billingMonth;
|
||||||
|
private String productCode;
|
||||||
|
private String productName;
|
||||||
|
private BigDecimal monthlyFee;
|
||||||
|
private BigDecimal usageFee;
|
||||||
|
private BigDecimal totalFee;
|
||||||
|
private String dataUsage;
|
||||||
|
private String voiceUsage;
|
||||||
|
private String smsUsage;
|
||||||
|
private String billStatus;
|
||||||
|
private String dueDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,48 +1,3 @@
|
|||||||
spring:
|
|
||||||
# H2 데이터베이스 설정 (Mock 서비스용)
|
|
||||||
datasource:
|
|
||||||
url: jdbc:h2:mem:kosmock;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
|
||||||
username: sa
|
|
||||||
password:
|
|
||||||
driver-class-name: org.h2.Driver
|
|
||||||
|
|
||||||
# JPA 설정
|
|
||||||
jpa:
|
|
||||||
database-platform: org.hibernate.dialect.H2Dialect
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: create-drop
|
|
||||||
show-sql: true
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
format_sql: true
|
|
||||||
|
|
||||||
# H2 Console (개발환경에서만)
|
|
||||||
h2:
|
|
||||||
console:
|
|
||||||
enabled: true
|
|
||||||
path: /h2-console
|
|
||||||
|
|
||||||
# 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:4}
|
|
||||||
|
|
||||||
# Mock 응답 시간 (개발 환경에서는 빠른 응답)
|
|
||||||
kos:
|
|
||||||
mock:
|
|
||||||
response-delay: 100 # milliseconds
|
|
||||||
failure-rate: 0.01 # 1% 실패율
|
|
||||||
|
|
||||||
# 로깅 레벨 (개발환경)
|
# 로깅 레벨 (개발환경)
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -1,27 +1,6 @@
|
|||||||
spring:
|
# 로깅 레벨
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: ${REDIS_HOST:localhost}
|
|
||||||
port: ${REDIS_PORT:6379}
|
|
||||||
password: ${REDIS_PASSWORD:}
|
|
||||||
timeout: 2000ms
|
|
||||||
lettuce:
|
|
||||||
pool:
|
|
||||||
max-active: 20
|
|
||||||
max-idle: 10
|
|
||||||
min-idle: 5
|
|
||||||
|
|
||||||
# Mock 응답 시간 (실제 KOS 시스템을 모방)
|
|
||||||
kos:
|
|
||||||
mock:
|
|
||||||
response-delay: 1000 # milliseconds (1초)
|
|
||||||
failure-rate: 0.05 # 5% 실패율
|
|
||||||
|
|
||||||
# 로깅 레벨 (운영환경)
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.phonebill.kosmock: INFO
|
com.phonebill.kosmock: INFO
|
||||||
org.springframework.web: WARN
|
org.springframework.web: WARN
|
||||||
org.springframework.data.redis: WARN
|
org.springframework.data.redis: WARN
|
||||||
file:
|
|
||||||
name: /var/log/kos-mock-service.log
|
|
||||||
@ -2,7 +2,28 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: kos-mock-service
|
name: kos-mock-service
|
||||||
profiles:
|
profiles:
|
||||||
active: dev
|
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:file:./data/kos_mock
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password: password
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
platform: h2
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
show-sql: true
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
|
path: /h2-console
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8084}
|
port: ${SERVER_PORT:8084}
|
||||||
@ -22,8 +43,12 @@ management:
|
|||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.phonebill.kosmock: INFO
|
com.phonebill.kosmock: DEBUG
|
||||||
org.springframework.web: INFO
|
org.springframework.web: INFO
|
||||||
|
org.springframework.jdbc.core: DEBUG
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.type.descriptor.sql: TRACE
|
||||||
|
com.zaxxer.hikari: DEBUG
|
||||||
pattern:
|
pattern:
|
||||||
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
|
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 %logger{36} - %msg%n'
|
file: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
|
||||||
@ -38,4 +63,5 @@ springdoc:
|
|||||||
path: /swagger-ui.html
|
path: /swagger-ui.html
|
||||||
tags-sorter: alpha
|
tags-sorter: alpha
|
||||||
operations-sorter: alpha
|
operations-sorter: alpha
|
||||||
show-actuator: true
|
show-actuator: true
|
||||||
|
paths-to-exclude: /actuator/**
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
package com.phonebill.kosmock;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KOS Mock Application 통합 테스트
|
|
||||||
*/
|
|
||||||
@SpringBootTest
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
class KosMockApplicationTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void contextLoads() {
|
|
||||||
// Spring Context가 정상적으로 로드되는지 확인
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package com.phonebill.kosmock.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.phonebill.kosmock.dto.KosBillInquiryRequest;
|
|
||||||
import com.phonebill.kosmock.dto.KosProductChangeRequest;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
|
||||||
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KOS Mock Controller 테스트
|
|
||||||
*/
|
|
||||||
@SpringBootTest
|
|
||||||
@AutoConfigureMockMvc
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
class KosMockControllerTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private MockMvc mockMvc;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("서비스 상태 체크 API 테스트")
|
|
||||||
void healthCheck() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/v1/kos/health"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.resultCode").value("0000"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("요금 조회 API 성공 테스트")
|
|
||||||
void inquireBill_Success() throws Exception {
|
|
||||||
KosBillInquiryRequest request = new KosBillInquiryRequest();
|
|
||||||
request.setLineNumber("01012345678");
|
|
||||||
request.setBillingMonth("202501");
|
|
||||||
request.setRequestId("TEST_REQ_001");
|
|
||||||
request.setRequestorId("TEST_SERVICE");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("요금 조회 API 입력값 검증 실패 테스트")
|
|
||||||
void inquireBill_ValidationFailure() throws Exception {
|
|
||||||
KosBillInquiryRequest request = new KosBillInquiryRequest();
|
|
||||||
// 필수값 누락
|
|
||||||
request.setBillingMonth("202501");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.success").value(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("상품 변경 API 성공 테스트")
|
|
||||||
void changeProduct_Success() throws Exception {
|
|
||||||
KosProductChangeRequest request = new KosProductChangeRequest();
|
|
||||||
request.setLineNumber("01012345678");
|
|
||||||
request.setCurrentProductCode("LTE-BASIC-001");
|
|
||||||
request.setTargetProductCode("5G-PREMIUM-001");
|
|
||||||
request.setRequestId("TEST_REQ_002");
|
|
||||||
request.setRequestorId("TEST_SERVICE");
|
|
||||||
request.setChangeReason("테스트 상품 변경");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/kos/product/change")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("Mock 설정 조회 API 테스트")
|
|
||||||
void getMockConfig() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/v1/kos/mock/config"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.resultCode").value("0000"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
spring:
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
host: localhost
|
|
||||||
port: 6379
|
|
||||||
timeout: 1000ms
|
|
||||||
|
|
||||||
# 테스트용 Mock 설정
|
|
||||||
kos:
|
|
||||||
mock:
|
|
||||||
response-delay: 0 # 테스트에서는 지연 없음
|
|
||||||
failure-rate: 0.0 # 테스트에서는 실패 시뮬레이션 없음
|
|
||||||
debug-mode: true
|
|
||||||
|
|
||||||
# 로깅 레벨 (테스트환경)
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
com.phonebill.kosmock: DEBUG
|
|
||||||
org.springframework.web: INFO
|
|
||||||
org.springframework.test: INFO
|
|
||||||
@ -3,56 +3,27 @@
|
|||||||
<ExternalSystemSettings>
|
<ExternalSystemSettings>
|
||||||
<option name="env">
|
<option name="env">
|
||||||
<map>
|
<map>
|
||||||
<!-- Database Connection -->
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
|
||||||
<entry key="DB_HOST" value="20.249.107.185" />
|
<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" />
|
<entry key="DB_KIND" value="postgresql" />
|
||||||
|
<entry key="DB_NAME" value="product_change_db" />
|
||||||
<!-- Redis Connection -->
|
<entry key="DB_PASSWORD" value="ProductUser2025!" />
|
||||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
<entry key="DB_PORT" value="5432" />
|
||||||
<entry key="REDIS_PORT" value="6379" />
|
<entry key="DB_USERNAME" value="product_change_user" />
|
||||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
<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" />
|
<entry key="REDIS_DATABASE" value="2" />
|
||||||
|
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||||
<!-- Server Configuration -->
|
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
<entry key="SERVER_PORT" value="8083" />
|
<entry key="SERVER_PORT" value="8083" />
|
||||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
<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>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,14 @@
|
|||||||
package com.unicorn.phonebill.product.config;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
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.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@ -16,133 +19,106 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
|||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security 설정 클래스
|
* Spring Security 설정
|
||||||
*
|
|
||||||
* 주요 기능:
|
|
||||||
* - JWT 인증 필터 설정
|
|
||||||
* - CORS 설정
|
|
||||||
* - API 엔드포인트 보안 설정
|
|
||||||
* - 세션 비활성화 (Stateless)
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
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
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
// CSRF 비활성화 (JWT 사용으로 불필요)
|
// CSRF 비활성화 (JWT 사용으로 불필요)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.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 설정
|
// OPTIONS 요청은 모두 허용 (CORS Preflight)
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
|
||||||
// 세션 비활성화 (Stateless)
|
// Protected endpoints (인증 필요)
|
||||||
.sessionManagement(session ->
|
.requestMatchers("/products/**").authenticated()
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
||||||
|
|
||||||
// 예외 처리 설정
|
// Actuator endpoints (관리용)
|
||||||
.exceptionHandling(exceptions -> exceptions
|
.requestMatchers("/actuator/**").hasRole("ADMIN")
|
||||||
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
|
|
||||||
.accessDeniedHandler(jwtAccessDeniedHandler))
|
|
||||||
|
|
||||||
// 권한 설정
|
// 나머지 모든 요청 인증 필요
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.anyRequest().authenticated()
|
||||||
// Health Check 및 문서화 API는 인증 불필요
|
)
|
||||||
.requestMatchers("/actuator/**").permitAll()
|
|
||||||
.requestMatchers("/v3/api-docs/**", "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
// JWT 필터 추가
|
||||||
.requestMatchers("/swagger-resources/**", "/webjars/**").permitAll()
|
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||||
|
|
||||||
// OPTIONS 요청은 인증 불필요 (CORS Preflight)
|
// Exception 처리
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint((request, response, authException) -> {
|
||||||
// 모든 API는 인증 필요
|
response.setStatus(401);
|
||||||
.requestMatchers("/products/**").authenticated()
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
|
||||||
// 나머지 요청은 모두 인증 필요
|
})
|
||||||
.anyRequest().authenticated())
|
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||||
|
response.setStatus(403);
|
||||||
// JWT 인증 필터 추가
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Bean
|
||||||
* CORS 설정
|
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||||
*/
|
return new JwtAuthenticationFilter(jwtTokenProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
// 허용할 Origin 설정
|
// 환경변수에서 허용할 Origin 패턴 설정
|
||||||
configuration.setAllowedOriginPatterns(Arrays.asList(
|
String[] origins = allowedOrigins.split(",");
|
||||||
"http://localhost:3000", // 개발환경 프론트엔드
|
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||||
"http://localhost:8080", // API Gateway
|
|
||||||
"https://*.mvno.com", // 운영환경
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
"https://*.mvno-dev.com" // 개발환경
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
));
|
|
||||||
|
|
||||||
// 허용할 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"
|
|
||||||
));
|
|
||||||
|
|
||||||
// 자격 증명 허용
|
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
// Preflight 요청 캐시 시간 설정 (1시간)
|
|
||||||
configuration.setMaxAge(3600L);
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", configuration);
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 암호화기
|
|
||||||
*/
|
|
||||||
@Bean
|
|
||||||
public PasswordEncoder passwordEncoder() {
|
|
||||||
return new BCryptPasswordEncoder();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -39,9 +39,9 @@ public class SwaggerConfig {
|
|||||||
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||||
._default("8083")
|
._default("8083")
|
||||||
.description("Server port"))))
|
.description("Server port"))))
|
||||||
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
|
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
|
||||||
.components(new Components()
|
.components(new Components()
|
||||||
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
|
.addSecuritySchemes("bearerAuth", createAPIKeyScheme()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Info apiInfo() {
|
private Info apiInfo() {
|
||||||
|
|||||||
@ -29,7 +29,6 @@ import java.time.LocalDate;
|
|||||||
* 상품변경 서비스 REST API 컨트롤러
|
* 상품변경 서비스 REST API 컨트롤러
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* - 상품변경 메뉴 조회 (UFR-PROD-010)
|
|
||||||
* - 고객 및 상품 정보 조회 (UFR-PROD-020)
|
* - 고객 및 상품 정보 조회 (UFR-PROD-020)
|
||||||
* - 상품변경 요청 및 사전체크 (UFR-PROD-030)
|
* - 상품변경 요청 및 사전체크 (UFR-PROD-030)
|
||||||
* - KOS 연동 상품변경 처리 (UFR-PROD-040)
|
* - KOS 연동 상품변경 처리 (UFR-PROD-040)
|
||||||
@ -50,41 +49,12 @@ public class ProductController {
|
|||||||
this.productService = productService;
|
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 구현
|
* UFR-PROD-020 구현
|
||||||
*/
|
*/
|
||||||
@GetMapping("/customer/{lineNumber}")
|
@GetMapping("/customer")
|
||||||
@Operation(summary = "고객 정보 조회",
|
@Operation(summary = "고객 정보 조회",
|
||||||
description = "특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다")
|
description = "특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@ -99,7 +69,7 @@ public class ProductController {
|
|||||||
})
|
})
|
||||||
public ResponseEntity<CustomerInfoResponse> getCustomerInfo(
|
public ResponseEntity<CustomerInfoResponse> getCustomerInfo(
|
||||||
@Parameter(description = "고객 회선번호", example = "01012345678")
|
@Parameter(description = "고객 회선번호", example = "01012345678")
|
||||||
@PathVariable
|
@RequestParam("lineNumber")
|
||||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||||
String lineNumber) {
|
String lineNumber) {
|
||||||
|
|
||||||
@ -130,20 +100,18 @@ public class ProductController {
|
|||||||
})
|
})
|
||||||
public ResponseEntity<AvailableProductsResponse> getAvailableProducts(
|
public ResponseEntity<AvailableProductsResponse> getAvailableProducts(
|
||||||
@Parameter(description = "현재 상품코드 (필터링용)")
|
@Parameter(description = "현재 상품코드 (필터링용)")
|
||||||
@RequestParam(required = false) String currentProductCode,
|
@RequestParam(required = false) String currentProductCode) {
|
||||||
@Parameter(description = "사업자 코드")
|
|
||||||
@RequestParam(required = false) String operatorCode) {
|
|
||||||
|
|
||||||
String userId = getCurrentUserId();
|
String userId = getCurrentUserId();
|
||||||
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, operatorCode={}, userId={}",
|
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, userId={}",
|
||||||
currentProductCode, operatorCode, userId);
|
currentProductCode, userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode, operatorCode);
|
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode);
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, operatorCode={}, userId={}",
|
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, userId={}",
|
||||||
currentProductCode, operatorCode, userId, e);
|
currentProductCode, userId, e);
|
||||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다");
|
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -186,12 +154,10 @@ public class ProductController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/change")
|
@PostMapping("/change")
|
||||||
@Operation(summary = "상품변경 요청",
|
@Operation(summary = "상품변경 요청",
|
||||||
description = "실제 상품변경 처리를 요청합니다")
|
description = "실제 상품변경 처리를 요청합니다 (동기 처리)")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@ApiResponse(responseCode = "200", description = "상품변경 처리 완료",
|
@ApiResponse(responseCode = "200", description = "상품변경 처리 완료",
|
||||||
content = @Content(schema = @Schema(implementation = ProductChangeResponse.class))),
|
content = @Content(schema = @Schema(implementation = ProductChangeResponse.class))),
|
||||||
@ApiResponse(responseCode = "202", description = "상품변경 요청 접수 (비동기 처리)",
|
|
||||||
content = @Content(schema = @Schema(implementation = ProductChangeAsyncResponse.class))),
|
|
||||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||||
@ApiResponse(responseCode = "409", description = "사전체크 실패 또는 처리 불가 상태",
|
@ApiResponse(responseCode = "409", description = "사전체크 실패 또는 처리 불가 상태",
|
||||||
@ -201,63 +167,24 @@ public class ProductController {
|
|||||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
})
|
})
|
||||||
public ResponseEntity<?> requestProductChange(
|
public ResponseEntity<ProductChangeResponse> requestProductChange(
|
||||||
@Valid @RequestBody ProductChangeRequest request,
|
@Valid @RequestBody ProductChangeRequest request) {
|
||||||
@Parameter(description = "처리 모드 (sync: 동기, async: 비동기)")
|
|
||||||
@RequestParam(defaultValue = "sync") String mode) {
|
|
||||||
|
|
||||||
String userId = getCurrentUserId();
|
String userId = getCurrentUserId();
|
||||||
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, mode={}, userId={}",
|
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, userId={}",
|
||||||
request.getLineNumber(), request.getCurrentProductCode(),
|
request.getLineNumber(), request.getCurrentProductCode(),
|
||||||
request.getTargetProductCode(), mode, userId);
|
request.getTargetProductCode(), userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ("async".equalsIgnoreCase(mode)) {
|
// 동기 처리
|
||||||
// 비동기 처리
|
ProductChangeResponse response = productService.requestProductChange(request, userId);
|
||||||
ProductChangeAsyncResponse response = productService.requestProductChangeAsync(request, userId);
|
return ResponseEntity.ok(response);
|
||||||
return ResponseEntity.accepted().body(response);
|
|
||||||
} else {
|
|
||||||
// 동기 처리 (기본값)
|
|
||||||
ProductChangeResponse response = productService.requestProductChange(request, userId);
|
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("상품변경 요청 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
|
logger.error("상품변경 요청 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
|
||||||
throw new RuntimeException("상품변경 처리 중 오류가 발생했습니다");
|
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;
|
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.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@ -10,6 +13,7 @@ import java.math.BigDecimal;
|
|||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@Builder
|
@Builder
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class Product {
|
public class Product {
|
||||||
|
|
||||||
private final String productCode;
|
private final String productCode;
|
||||||
@ -22,6 +26,30 @@ public class Product {
|
|||||||
private final String operatorCode;
|
private final String operatorCode;
|
||||||
private final String description;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다른 상품으로 변경 가능한지 확인
|
* 다른 상품으로 변경 가능한지 확인
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,25 +1,167 @@
|
|||||||
package com.unicorn.phonebill.product.domain;
|
package com.unicorn.phonebill.product.domain;
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상품변경 처리 결과 도메인 모델
|
* 상품변경 처리 결과 도메인 모델
|
||||||
|
* Updated for compilation fix
|
||||||
*/
|
*/
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
public class ProductChangeResult {
|
public class ProductChangeResult {
|
||||||
|
|
||||||
private final String requestId;
|
private final String requestId;
|
||||||
private final boolean success;
|
private final boolean success;
|
||||||
private final String resultCode;
|
private final String resultCode;
|
||||||
private final String resultMessage;
|
private final String resultMessage;
|
||||||
|
private final String failureReason;
|
||||||
|
private final String kosOrderNumber;
|
||||||
|
private final String effectiveDate;
|
||||||
private final Product changedProduct;
|
private final Product changedProduct;
|
||||||
private final LocalDateTime processedAt;
|
private final LocalDateTime processedAt;
|
||||||
private final Map<String, Object> additionalData;
|
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)
|
.success(false)
|
||||||
.resultCode(resultCode)
|
.resultCode(resultCode)
|
||||||
.resultMessage(resultMessage)
|
.resultMessage(resultMessage)
|
||||||
|
.failureReason(resultMessage)
|
||||||
.processedAt(LocalDateTime.now())
|
.processedAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -70,22 +213,9 @@ public class ProductChangeResult {
|
|||||||
.success(false)
|
.success(false)
|
||||||
.resultCode(resultCode)
|
.resultCode(resultCode)
|
||||||
.resultMessage(resultMessage)
|
.resultMessage(resultMessage)
|
||||||
|
.failureReason(resultMessage)
|
||||||
.additionalData(additionalData)
|
.additionalData(additionalData)
|
||||||
.processedAt(LocalDateTime.now())
|
.processedAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과가 성공인지 확인
|
|
||||||
*/
|
|
||||||
public boolean isSuccess() {
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과가 실패인지 확인
|
|
||||||
*/
|
|
||||||
public boolean isFailure() {
|
|
||||||
return !success;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.unicorn.phonebill.product.dto;
|
package com.unicorn.phonebill.product.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@ -8,8 +8,6 @@ import lombok.NoArgsConstructor;
|
|||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상품변경 요청 DTO
|
* 상품변경 요청 DTO
|
||||||
@ -21,19 +19,16 @@ import java.time.LocalDateTime;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ProductChangeRequest {
|
public class ProductChangeRequest {
|
||||||
|
|
||||||
|
@JsonProperty("lineNumber")
|
||||||
@NotBlank(message = "회선번호는 필수입니다")
|
@NotBlank(message = "회선번호는 필수입니다")
|
||||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||||
private String lineNumber;
|
private String lineNumber;
|
||||||
|
|
||||||
|
@JsonProperty("currentProductCode")
|
||||||
@NotBlank(message = "현재 상품 코드는 필수입니다")
|
@NotBlank(message = "현재 상품 코드는 필수입니다")
|
||||||
private String currentProductCode;
|
private String currentProductCode;
|
||||||
|
|
||||||
|
@JsonProperty("targetProductCode")
|
||||||
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
|
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
|
||||||
private String targetProductCode;
|
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;
|
|
||||||
}
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,45 +1,35 @@
|
|||||||
package com.unicorn.phonebill.product.exception;
|
package com.unicorn.phonebill.product.exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Circuit Breaker Open 상태 예외
|
* Circuit Breaker 관련 예외
|
||||||
*/
|
*/
|
||||||
public class CircuitBreakerException extends BusinessException {
|
public class CircuitBreakerException extends BusinessException {
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private final String serviceName;
|
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);
|
super(errorCode, message);
|
||||||
this.serviceName = serviceName;
|
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() {
|
public String getServiceName() {
|
||||||
return serviceName;
|
return serviceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCircuitBreakerState() {
|
public static CircuitBreakerException circuitBreakerOpen(String serviceName) {
|
||||||
return circuitBreakerState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자주 사용되는 Circuit Breaker 예외 팩토리 메소드들
|
|
||||||
public static CircuitBreakerException circuitOpen(String serviceName) {
|
|
||||||
return new CircuitBreakerException("CIRCUIT_BREAKER_OPEN",
|
return new CircuitBreakerException("CIRCUIT_BREAKER_OPEN",
|
||||||
"서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
|
"Circuit Breaker가 OPEN 상태입니다. 잠시 후 다시 시도해주세요", serviceName);
|
||||||
serviceName, "OPEN");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static CircuitBreakerException halfOpenFailed(String serviceName) {
|
public static CircuitBreakerException circuitBreakerTimeout(String serviceName) {
|
||||||
return new CircuitBreakerException("CIRCUIT_BREAKER_HALF_OPEN_FAILED",
|
return new CircuitBreakerException("CIRCUIT_BREAKER_TIMEOUT",
|
||||||
"서비스 복구 시도 중 실패했습니다",
|
"Circuit Breaker 타임아웃이 발생했습니다", serviceName);
|
||||||
serviceName, "HALF_OPEN");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static CircuitBreakerException callNotPermitted(String serviceName) {
|
|
||||||
return new CircuitBreakerException("CIRCUIT_BREAKER_CALL_NOT_PERMITTED",
|
|
||||||
"서비스 호출이 차단되었습니다",
|
|
||||||
serviceName, "OPEN");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -43,4 +43,19 @@ public class KosConnectionException extends BusinessException {
|
|||||||
return new KosConnectionException("KOS_AUTH_FAILED",
|
return new KosConnectionException("KOS_AUTH_FAILED",
|
||||||
"KOS 시스템 인증에 실패했습니다", serviceName);
|
"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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -33,8 +33,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
|||||||
ProductChangeHistoryEntity entity = ProductChangeHistoryEntity.fromDomain(history);
|
ProductChangeHistoryEntity entity = ProductChangeHistoryEntity.fromDomain(history);
|
||||||
ProductChangeHistoryEntity savedEntity = jpaRepository.save(entity);
|
ProductChangeHistoryEntity savedEntity = jpaRepository.save(entity);
|
||||||
|
|
||||||
log.info("상품변경 이력 저장 완료: id={}, requestId={}",
|
log.info("상품변경 이력 저장 완료: id={}", savedEntity.getId());
|
||||||
savedEntity.getId(), savedEntity.getRequestId());
|
|
||||||
|
|
||||||
return savedEntity.toDomain();
|
return savedEntity.toDomain();
|
||||||
}
|
}
|
||||||
@ -43,7 +42,7 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
|||||||
public Optional<ProductChangeHistory> findByRequestId(String requestId) {
|
public Optional<ProductChangeHistory> findByRequestId(String requestId) {
|
||||||
log.debug("요청 ID로 이력 조회: requestId={}", requestId);
|
log.debug("요청 ID로 이력 조회: requestId={}", requestId);
|
||||||
|
|
||||||
return jpaRepository.findByRequestId(requestId)
|
return jpaRepository.findById(requestId)
|
||||||
.map(ProductChangeHistoryEntity::toDomain);
|
.map(ProductChangeHistoryEntity::toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,14 +159,14 @@ public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryR
|
|||||||
public boolean existsByRequestId(String requestId) {
|
public boolean existsByRequestId(String requestId) {
|
||||||
log.debug("요청 ID 존재 여부 확인: requestId={}", requestId);
|
log.debug("요청 ID 존재 여부 확인: requestId={}", requestId);
|
||||||
|
|
||||||
return jpaRepository.existsByRequestId(requestId);
|
return jpaRepository.existsById(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteById(Long id) {
|
public void deleteById(Long id) {
|
||||||
log.info("상품변경 이력 삭제: id={}", id);
|
log.info("상품변경 이력 삭제: id={}", id);
|
||||||
|
|
||||||
jpaRepository.deleteById(id);
|
jpaRepository.deleteById(id.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -2,14 +2,18 @@ package com.unicorn.phonebill.product.repository;
|
|||||||
|
|
||||||
import com.unicorn.phonebill.product.domain.Product;
|
import com.unicorn.phonebill.product.domain.Product;
|
||||||
import com.unicorn.phonebill.product.domain.ProductStatus;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 캐시를 활용한 상품 Repository 구현체
|
* Redis 캐시를 활용한 상품 Repository 구현체
|
||||||
@ -21,6 +25,7 @@ public class ProductRepositoryImpl implements ProductRepository {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(ProductRepositoryImpl.class);
|
private static final Logger logger = LoggerFactory.getLogger(ProductRepositoryImpl.class);
|
||||||
|
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
private final KosClientService kosClientService;
|
||||||
|
|
||||||
// 캐시 키 접두사
|
// 캐시 키 접두사
|
||||||
private static final String PRODUCT_CACHE_PREFIX = "product:";
|
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 PRODUCT_CACHE_TTL = 3600; // 1시간
|
||||||
private static final long PRODUCTS_CACHE_TTL = 1800; // 30분
|
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.redisTemplate = redisTemplate;
|
||||||
|
this.kosClientService = kosClientService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -76,14 +83,17 @@ public class ProductRepositoryImpl implements ProductRepository {
|
|||||||
logger.debug("Cache miss for available products");
|
logger.debug("Cache miss for available products");
|
||||||
incrementCacheMisses();
|
incrementCacheMisses();
|
||||||
|
|
||||||
// TODO: KOS API 호출로 실제 데이터 조회
|
// KOS API 호출로 실제 데이터 조회
|
||||||
// 현재는 테스트 데이터 반환
|
List<KosProductInfo> kosProducts = kosClientService.getProductListFromKos();
|
||||||
List<Product> products = createTestAvailableProducts();
|
List<Product> products = convertKosProductsToProducts(kosProducts);
|
||||||
|
|
||||||
cacheProducts(products, AVAILABLE_PRODUCTS_KEY);
|
cacheProducts(products, AVAILABLE_PRODUCTS_KEY);
|
||||||
|
logger.info("KOS에서 조회한 상품 개수: {}", products.size());
|
||||||
return products;
|
return products;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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();
|
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) {
|
private Optional<Product> createTestProduct(String productCode) {
|
||||||
Product product = Product.builder()
|
Product product = Product.builder()
|
||||||
.productCode(productCode)
|
.productCode(productCode)
|
||||||
|
|||||||
@ -7,15 +7,12 @@ import lombok.AccessLevel;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상품변경 이력 엔티티
|
* 상품변경 이력 엔티티 (실제 DB 스키마에 맞춘 버전)
|
||||||
* 모든 상품변경 요청 및 처리 이력을 관리
|
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "pc_product_change_history")
|
@Table(name = "pc_product_change_history")
|
||||||
@ -24,105 +21,94 @@ import java.util.Map;
|
|||||||
public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@Column(name = "id", nullable = false, unique = true, length = 100)
|
||||||
private Long id;
|
private String id;
|
||||||
|
|
||||||
@Column(name = "request_id", nullable = false, unique = true, length = 50)
|
|
||||||
private String requestId;
|
|
||||||
|
|
||||||
@Column(name = "line_number", nullable = false, length = 20)
|
@Column(name = "line_number", nullable = false, length = 20)
|
||||||
private String lineNumber;
|
private String lineNumber;
|
||||||
|
|
||||||
@Column(name = "customer_id", nullable = false, length = 50)
|
@Column(name = "customer_id", nullable = false, length = 100)
|
||||||
private String customerId;
|
private String customerId;
|
||||||
|
|
||||||
@Column(name = "current_product_code", nullable = false, length = 20)
|
@Column(name = "old_product_code", length = 50)
|
||||||
private String currentProductCode;
|
private String currentProductCode;
|
||||||
|
|
||||||
@Column(name = "target_product_code", nullable = false, length = 20)
|
@Column(name = "new_product_code", nullable = false, length = 50)
|
||||||
private String targetProductCode;
|
private String targetProductCode;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "process_status", nullable = false, length = 20)
|
@Column(name = "change_status", length = 20)
|
||||||
private ProcessStatus processStatus;
|
private ProcessStatus processStatus;
|
||||||
|
|
||||||
@Column(name = "validation_result", columnDefinition = "TEXT")
|
@Column(name = "change_reason", length = 255)
|
||||||
private String validationResult;
|
private String changeReason;
|
||||||
|
|
||||||
@Column(name = "process_message", columnDefinition = "TEXT")
|
@Column(name = "change_method", length = 50)
|
||||||
private String processMessage;
|
private String changeMethod;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@Column(name = "request_time", nullable = false)
|
||||||
@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)
|
|
||||||
private LocalDateTime requestedAt;
|
private LocalDateTime requestedAt;
|
||||||
|
|
||||||
@Column(name = "validated_at")
|
@Column(name = "approval_time")
|
||||||
private LocalDateTime validatedAt;
|
private LocalDateTime approvalTime;
|
||||||
|
|
||||||
@Column(name = "processed_at")
|
@Column(name = "completion_time")
|
||||||
private LocalDateTime processedAt;
|
private LocalDateTime completionTime;
|
||||||
|
|
||||||
@Version
|
@Column(name = "approver_id", length = 50)
|
||||||
@Column(name = "version", nullable = false)
|
private String approverId;
|
||||||
private Long version = 0L;
|
|
||||||
|
@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
|
@Builder
|
||||||
public ProductChangeHistoryEntity(
|
public ProductChangeHistoryEntity(
|
||||||
String requestId,
|
String id,
|
||||||
String lineNumber,
|
String lineNumber,
|
||||||
String customerId,
|
String customerId,
|
||||||
String currentProductCode,
|
String currentProductCode,
|
||||||
String targetProductCode,
|
String targetProductCode,
|
||||||
ProcessStatus processStatus,
|
ProcessStatus processStatus,
|
||||||
String validationResult,
|
String changeReason,
|
||||||
String processMessage,
|
String changeMethod,
|
||||||
Map<String, Object> kosRequestData,
|
LocalDateTime requestedAt) {
|
||||||
Map<String, Object> kosResponseData,
|
this.id = id;
|
||||||
LocalDateTime requestedAt,
|
|
||||||
LocalDateTime validatedAt,
|
|
||||||
LocalDateTime processedAt) {
|
|
||||||
this.requestId = requestId;
|
|
||||||
this.lineNumber = lineNumber;
|
this.lineNumber = lineNumber;
|
||||||
this.customerId = customerId;
|
this.customerId = customerId;
|
||||||
this.currentProductCode = currentProductCode;
|
this.currentProductCode = currentProductCode;
|
||||||
this.targetProductCode = targetProductCode;
|
this.targetProductCode = targetProductCode;
|
||||||
this.processStatus = processStatus != null ? processStatus : ProcessStatus.REQUESTED;
|
this.processStatus = processStatus != null ? processStatus : ProcessStatus.REQUESTED;
|
||||||
this.validationResult = validationResult;
|
this.changeReason = changeReason;
|
||||||
this.processMessage = processMessage;
|
this.changeMethod = changeMethod != null ? changeMethod : "API";
|
||||||
this.kosRequestData = kosRequestData;
|
|
||||||
this.kosResponseData = kosResponseData;
|
|
||||||
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
|
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
|
||||||
this.validatedAt = validatedAt;
|
this.retryCount = 0;
|
||||||
this.processedAt = processedAt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 도메인 모델로 변환
|
* 도메인 모델로 변환 (간소화)
|
||||||
*/
|
*/
|
||||||
public ProductChangeHistory toDomain() {
|
public ProductChangeHistory toDomain() {
|
||||||
return ProductChangeHistory.builder()
|
return ProductChangeHistory.builder()
|
||||||
.id(this.id)
|
.id(null) // Long type을 위해 null 처리
|
||||||
.requestId(this.requestId)
|
.requestId(this.id)
|
||||||
.lineNumber(this.lineNumber)
|
.lineNumber(this.lineNumber)
|
||||||
.customerId(this.customerId)
|
.customerId(this.customerId)
|
||||||
.currentProductCode(this.currentProductCode)
|
.currentProductCode(this.currentProductCode)
|
||||||
.targetProductCode(this.targetProductCode)
|
.targetProductCode(this.targetProductCode)
|
||||||
.processStatus(this.processStatus)
|
.processStatus(this.processStatus)
|
||||||
.validationResult(this.validationResult)
|
|
||||||
.processMessage(this.processMessage)
|
|
||||||
.kosRequestData(this.kosRequestData)
|
|
||||||
.kosResponseData(this.kosResponseData)
|
|
||||||
.requestedAt(this.requestedAt)
|
.requestedAt(this.requestedAt)
|
||||||
.validatedAt(this.validatedAt)
|
|
||||||
.processedAt(this.processedAt)
|
|
||||||
.version(this.version)
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,30 +117,22 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
|||||||
*/
|
*/
|
||||||
public static ProductChangeHistoryEntity fromDomain(ProductChangeHistory domain) {
|
public static ProductChangeHistoryEntity fromDomain(ProductChangeHistory domain) {
|
||||||
return ProductChangeHistoryEntity.builder()
|
return ProductChangeHistoryEntity.builder()
|
||||||
.requestId(domain.getRequestId())
|
.id(UUID.randomUUID().toString()) // 새로운 UUID 생성
|
||||||
.lineNumber(domain.getLineNumber())
|
.lineNumber(domain.getLineNumber())
|
||||||
.customerId(domain.getCustomerId())
|
.customerId(domain.getCustomerId())
|
||||||
.currentProductCode(domain.getCurrentProductCode())
|
.currentProductCode(domain.getCurrentProductCode())
|
||||||
.targetProductCode(domain.getTargetProductCode())
|
.targetProductCode(domain.getTargetProductCode())
|
||||||
.processStatus(domain.getProcessStatus())
|
.processStatus(domain.getProcessStatus())
|
||||||
.validationResult(domain.getValidationResult())
|
|
||||||
.processMessage(domain.getProcessMessage())
|
|
||||||
.kosRequestData(domain.getKosRequestData())
|
|
||||||
.kosResponseData(domain.getKosResponseData())
|
|
||||||
.requestedAt(domain.getRequestedAt())
|
.requestedAt(domain.getRequestedAt())
|
||||||
.validatedAt(domain.getValidatedAt())
|
|
||||||
.processedAt(domain.getProcessedAt())
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상태를 완료로 변경
|
* 상태를 완료로 변경
|
||||||
*/
|
*/
|
||||||
public void markAsCompleted(String message, Map<String, Object> kosResponseData) {
|
public void markAsCompleted(String message) {
|
||||||
this.processStatus = ProcessStatus.COMPLETED;
|
this.processStatus = ProcessStatus.COMPLETED;
|
||||||
this.processMessage = message;
|
this.completionTime = LocalDateTime.now();
|
||||||
this.kosResponseData = kosResponseData;
|
|
||||||
this.processedAt = LocalDateTime.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -162,37 +140,14 @@ public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
|||||||
*/
|
*/
|
||||||
public void markAsFailed(String message) {
|
public void markAsFailed(String message) {
|
||||||
this.processStatus = ProcessStatus.FAILED;
|
this.processStatus = ProcessStatus.FAILED;
|
||||||
this.processMessage = message;
|
this.errorMessage = message;
|
||||||
this.processedAt = LocalDateTime.now();
|
this.completionTime = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검증 완료로 상태 변경
|
* 상태를 처리중으로 변경
|
||||||
*/
|
|
||||||
public void markAsValidated(String validationResult) {
|
|
||||||
this.processStatus = ProcessStatus.VALIDATED;
|
|
||||||
this.validationResult = validationResult;
|
|
||||||
this.validatedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 처리 중으로 상태 변경
|
|
||||||
*/
|
*/
|
||||||
public void markAsProcessing() {
|
public void markAsProcessing() {
|
||||||
this.processStatus = ProcessStatus.PROCESSING;
|
this.processStatus = ProcessStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* KOS 요청 데이터 설정
|
|
||||||
*/
|
|
||||||
public void setKosRequestData(Map<String, Object> kosRequestData) {
|
|
||||||
this.kosRequestData = kosRequestData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 처리 메시지 업데이트
|
|
||||||
*/
|
|
||||||
public void updateProcessMessage(String message) {
|
|
||||||
this.processMessage = message;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -17,12 +17,12 @@ import java.util.Optional;
|
|||||||
* 상품변경 이력 JPA Repository
|
* 상품변경 이력 JPA Repository
|
||||||
*/
|
*/
|
||||||
@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 " +
|
@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 " +
|
"AND h.requestedAt < :timeoutThreshold " +
|
||||||
"ORDER BY h.requestedAt ASC")
|
"ORDER BY h.requestedAt ASC")
|
||||||
List<ProductChangeHistoryEntity> findProcessingRequestsOlderThan(
|
List<ProductChangeHistoryEntity> findProcessingRequestsOlderThan(
|
||||||
@ -93,8 +93,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||||
"WHERE h.lineNumber = :lineNumber " +
|
"WHERE h.lineNumber = :lineNumber " +
|
||||||
"AND h.processStatus = 'COMPLETED' " +
|
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
|
||||||
"ORDER BY h.processedAt DESC")
|
"ORDER BY h.completionTime DESC")
|
||||||
Page<ProductChangeHistoryEntity> findLatestSuccessfulChangeByLineNumber(
|
Page<ProductChangeHistoryEntity> findLatestSuccessfulChangeByLineNumber(
|
||||||
@Param("lineNumber") String lineNumber,
|
@Param("lineNumber") String lineNumber,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
@ -115,8 +115,8 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
|||||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||||
"WHERE h.currentProductCode = :currentProductCode " +
|
"WHERE h.currentProductCode = :currentProductCode " +
|
||||||
"AND h.targetProductCode = :targetProductCode " +
|
"AND h.targetProductCode = :targetProductCode " +
|
||||||
"AND h.processStatus = 'COMPLETED' " +
|
"AND h.processStatus = com.unicorn.phonebill.product.domain.ProcessStatus.COMPLETED " +
|
||||||
"AND h.processedAt >= :fromDate")
|
"AND h.completionTime >= :fromDate")
|
||||||
long countSuccessfulChangesByProductCodesSince(
|
long countSuccessfulChangesByProductCodesSince(
|
||||||
@Param("currentProductCode") String currentProductCode,
|
@Param("currentProductCode") String currentProductCode,
|
||||||
@Param("targetProductCode") String targetProductCode,
|
@Param("targetProductCode") String targetProductCode,
|
||||||
@ -127,11 +127,11 @@ public interface ProductChangeHistoryJpaRepository extends JpaRepository<Product
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||||
"WHERE h.lineNumber = :lineNumber " +
|
"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);
|
long countInProgressRequestsByLineNumber(@Param("lineNumber") String lineNumber);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요청 ID 존재 여부 확인
|
* 요청 ID 존재 여부 확인 (id 필드 사용)
|
||||||
*/
|
*/
|
||||||
boolean existsByRequestId(String requestId);
|
boolean existsById(String id);
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,6 @@ public class ProductCacheService {
|
|||||||
private static final String AVAILABLE_PRODUCTS_PREFIX = "availableProducts:";
|
private static final String AVAILABLE_PRODUCTS_PREFIX = "availableProducts:";
|
||||||
private static final String PRODUCT_STATUS_PREFIX = "productStatus:";
|
private static final String PRODUCT_STATUS_PREFIX = "productStatus:";
|
||||||
private static final String LINE_STATUS_PREFIX = "lineStatus:";
|
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:";
|
private static final String PRODUCT_CHANGE_RESULT_PREFIX = "productChangeResult:";
|
||||||
|
|
||||||
public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
|
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시간) ==========
|
// ========== 상품변경결과 캐시 (TTL: 1시간) ==========
|
||||||
|
|
||||||
@ -207,9 +185,6 @@ public class ProductCacheService {
|
|||||||
public void evictCustomerCaches(String lineNumber, String customerId) {
|
public void evictCustomerCaches(String lineNumber, String customerId) {
|
||||||
evictCustomerProductInfo(lineNumber);
|
evictCustomerProductInfo(lineNumber);
|
||||||
evictLineStatus(lineNumber);
|
evictLineStatus(lineNumber);
|
||||||
if (StringUtils.hasText(customerId)) {
|
|
||||||
evictMenuInfo(customerId);
|
|
||||||
}
|
|
||||||
logger.info("고객 관련 캐시 무효화 완료: lineNumber={}, customerId={}", lineNumber, customerId);
|
logger.info("고객 관련 캐시 무효화 완료: lineNumber={}, customerId={}", lineNumber, customerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,10 +246,6 @@ public class ProductCacheService {
|
|||||||
logger.debug("회선상태 캐시 무효화: {}", lineNumber);
|
logger.debug("회선상태 캐시 무효화: {}", lineNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(value = "menuInfo", key = "#userId")
|
|
||||||
public void evictMenuInfo(String userId) {
|
|
||||||
logger.debug("메뉴정보 캐시 무효화: {}", userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@CacheEvict(value = "productChangeResult", key = "#requestId")
|
@CacheEvict(value = "productChangeResult", key = "#requestId")
|
||||||
public void evictProductChangeResult(String requestId) {
|
public void evictProductChangeResult(String requestId) {
|
||||||
|
|||||||
@ -9,21 +9,12 @@ import java.util.List;
|
|||||||
* 상품 관리 서비스 인터페이스
|
* 상품 관리 서비스 인터페이스
|
||||||
*
|
*
|
||||||
* 주요 기능:
|
* 주요 기능:
|
||||||
* - 상품변경 메뉴 조회
|
|
||||||
* - 고객 및 상품 정보 조회
|
* - 고객 및 상품 정보 조회
|
||||||
* - 상품변경 처리
|
* - 상품변경 처리
|
||||||
* - 상품변경 이력 관리
|
* - 상품변경 이력 관리
|
||||||
*/
|
*/
|
||||||
public interface ProductService {
|
public interface ProductService {
|
||||||
|
|
||||||
/**
|
|
||||||
* 상품변경 메뉴 조회
|
|
||||||
* UFR-PROD-010 구현
|
|
||||||
*
|
|
||||||
* @param userId 사용자 ID
|
|
||||||
* @return 메뉴 응답
|
|
||||||
*/
|
|
||||||
ProductMenuResponse getProductMenu(String userId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 고객 정보 조회
|
* 고객 정보 조회
|
||||||
@ -39,10 +30,9 @@ public interface ProductService {
|
|||||||
* UFR-PROD-020 구현
|
* UFR-PROD-020 구현
|
||||||
*
|
*
|
||||||
* @param currentProductCode 현재 상품코드 (필터링용)
|
* @param currentProductCode 현재 상품코드 (필터링용)
|
||||||
* @param operatorCode 사업자 코드 (필터링용)
|
|
||||||
* @return 가용 상품 목록 응답
|
* @return 가용 상품 목록 응답
|
||||||
*/
|
*/
|
||||||
AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode);
|
AvailableProductsResponse getAvailableProducts(String currentProductCode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상품변경 사전체크
|
* 상품변경 사전체크
|
||||||
@ -73,13 +63,6 @@ public interface ProductService {
|
|||||||
*/
|
*/
|
||||||
ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId);
|
ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId);
|
||||||
|
|
||||||
/**
|
|
||||||
* 상품변경 결과 조회
|
|
||||||
*
|
|
||||||
* @param requestId 상품변경 요청 ID
|
|
||||||
* @return 상품변경 결과 응답
|
|
||||||
*/
|
|
||||||
ProductChangeResultResponse getProductChangeResult(String requestId);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상품변경 이력 조회
|
* 상품변경 이력 조회
|
||||||
|
|||||||
@ -3,8 +3,11 @@ package com.unicorn.phonebill.product.service;
|
|||||||
import com.unicorn.phonebill.product.dto.*;
|
import com.unicorn.phonebill.product.dto.*;
|
||||||
import com.unicorn.phonebill.product.domain.Product;
|
import com.unicorn.phonebill.product.domain.Product;
|
||||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
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.ProductRepository;
|
||||||
import com.unicorn.phonebill.product.repository.ProductChangeHistoryRepository;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@ -41,49 +44,20 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
private final ProductChangeHistoryRepository historyRepository;
|
private final ProductChangeHistoryRepository historyRepository;
|
||||||
private final ProductValidationService validationService;
|
private final ProductValidationService validationService;
|
||||||
private final ProductCacheService cacheService;
|
private final ProductCacheService cacheService;
|
||||||
// TODO: KOS 연동 서비스 추가 예정
|
private final KosClientService kosClientService;
|
||||||
// private final KosClientService kosClientService;
|
|
||||||
|
|
||||||
public ProductServiceImpl(ProductRepository productRepository,
|
public ProductServiceImpl(ProductRepository productRepository,
|
||||||
ProductChangeHistoryRepository historyRepository,
|
ProductChangeHistoryRepository historyRepository,
|
||||||
ProductValidationService validationService,
|
ProductValidationService validationService,
|
||||||
ProductCacheService cacheService) {
|
ProductCacheService cacheService,
|
||||||
|
KosClientService kosClientService) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.historyRepository = historyRepository;
|
this.historyRepository = historyRepository;
|
||||||
this.validationService = validationService;
|
this.validationService = validationService;
|
||||||
this.cacheService = cacheService;
|
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
|
@Override
|
||||||
public CustomerInfoResponse getCustomerInfo(String lineNumber) {
|
public CustomerInfoResponse getCustomerInfo(String lineNumber) {
|
||||||
@ -117,36 +91,36 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode) {
|
public AvailableProductsResponse getAvailableProducts(String currentProductCode) {
|
||||||
logger.info("가용 상품 목록 조회: currentProductCode={}, operatorCode={}", currentProductCode, operatorCode);
|
logger.info("가용 상품 목록 조회: currentProductCode={}", currentProductCode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 캐시에서 상품 목록 조회
|
// 캐시에서 상품 목록 조회
|
||||||
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts(operatorCode);
|
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts("all");
|
||||||
if (cachedProducts != null && !cachedProducts.isEmpty()) {
|
if (cachedProducts != null && !cachedProducts.isEmpty()) {
|
||||||
logger.debug("상품 목록 캐시 히트: operatorCode={}, count={}", operatorCode, cachedProducts.size());
|
logger.debug("상품 목록 캐시 히트: count={}", cachedProducts.size());
|
||||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(cachedProducts, currentProductCode);
|
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(cachedProducts, currentProductCode);
|
||||||
return AvailableProductsResponse.success(filteredProducts);
|
return AvailableProductsResponse.success(filteredProducts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캐시 미스 시 실제 조회
|
// 캐시 미스 시 실제 조회
|
||||||
List<Product> products = productRepository.findAvailableProductsByOperator(operatorCode);
|
List<Product> products = productRepository.findAvailableProducts();
|
||||||
List<ProductInfoDto> productDtos = products.stream()
|
List<ProductInfoDto> productDtos = products.stream()
|
||||||
.map(this::convertToDto)
|
.map(this::convertToDto)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
// 캐시에 저장
|
// 캐시에 저장
|
||||||
cacheService.cacheAvailableProducts(operatorCode, productDtos);
|
cacheService.cacheAvailableProducts("all", productDtos);
|
||||||
|
|
||||||
// 현재 상품 기준 필터링
|
// 현재 상품 기준 필터링
|
||||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(productDtos, currentProductCode);
|
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(productDtos, currentProductCode);
|
||||||
|
|
||||||
logger.info("가용 상품 목록 조회 완료: operatorCode={}, totalCount={}, filteredCount={}",
|
logger.info("가용 상품 목록 조회 완료: totalCount={}, filteredCount={}",
|
||||||
operatorCode, productDtos.size(), filteredProducts.size());
|
productDtos.size(), filteredProducts.size());
|
||||||
return AvailableProductsResponse.success(filteredProducts);
|
return AvailableProductsResponse.success(filteredProducts);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("가용 상품 목록 조회 중 오류: operatorCode={}", operatorCode, e);
|
logger.error("가용 상품 목록 조회 중 오류", e);
|
||||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다", e);
|
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,12 +165,18 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
|
|
||||||
// 4. 처리 결과에 따른 이력 업데이트
|
// 4. 처리 결과에 따른 이력 업데이트
|
||||||
if (changeResult.isSuccess()) {
|
if (changeResult.isSuccess()) {
|
||||||
// KOS 응답 데이터를 Map으로 변환
|
// KOS 응답 데이터 사용 (실제 응답 데이터 또는 기본 데이터)
|
||||||
Map<String, Object> kosResponseData = Map.of(
|
Map<String, Object> kosResponseData = changeResult.getKosResponseData();
|
||||||
"resultCode", changeResult.getResultCode(),
|
if (kosResponseData == null) {
|
||||||
"resultMessage", changeResult.getResultMessage(),
|
kosResponseData = Map.of(
|
||||||
"processedAt", LocalDateTime.now().toString()
|
"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);
|
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
|
@Override
|
||||||
public ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable) {
|
public ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable) {
|
||||||
@ -365,49 +310,49 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
|
|
||||||
// ========== Private Helper Methods ==========
|
// ========== 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) {
|
private CustomerInfoResponse.CustomerInfo getCustomerInfoFromDataSource(String lineNumber) {
|
||||||
// TODO: 실제 KOS 연동 또는 DB 조회 구현
|
try {
|
||||||
// 현재는 임시 데이터 반환
|
logger.debug("KOS 시스템에서 고객 정보 조회: lineNumber={}", lineNumber);
|
||||||
ProductInfoDto currentProduct = ProductInfoDto.builder()
|
|
||||||
.productCode("PLAN001")
|
// KOS 시스템 호출
|
||||||
.productName("5G 베이직 플랜")
|
KosCommonResponse<KosProductInquiryResponse> kosResponse = kosClientService.getProductInquiry(lineNumber);
|
||||||
.monthlyFee(new java.math.BigDecimal("45000"))
|
|
||||||
.dataAllowance("50GB")
|
if (kosResponse.getSuccess() && kosResponse.getData() != null) {
|
||||||
.voiceAllowance("무제한")
|
KosProductInquiryResponse kosData = kosResponse.getData();
|
||||||
.smsAllowance("기본 무료")
|
|
||||||
.isAvailable(true)
|
// KOS 응답을 내부 DTO로 변환
|
||||||
.operatorCode("MVNO001")
|
ProductInfoDto currentProduct = ProductInfoDto.builder()
|
||||||
.build();
|
.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()
|
return CustomerInfoResponse.CustomerInfo.builder()
|
||||||
.customerId("CUST001")
|
.customerId(kosData.getCustomerInfo().getCustomerId())
|
||||||
.lineNumber(lineNumber)
|
.lineNumber(lineNumber)
|
||||||
.customerName("홍길동")
|
.customerName(kosData.getCustomerInfo().getCustomerName())
|
||||||
.currentProduct(currentProduct)
|
.currentProduct(currentProduct)
|
||||||
.lineStatus("ACTIVE")
|
.lineStatus(kosData.getCustomerInfo().getLineStatus())
|
||||||
.build();
|
.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) {
|
private ProductChangeHistory createProductChangeHistory(String requestId, ProductChangeRequest request, String userId) {
|
||||||
return ProductChangeHistory.createNew(
|
return ProductChangeHistory.createNew(
|
||||||
requestId,
|
requestId, // UUID는 엔티티에서 자동 생성됨
|
||||||
request.getLineNumber(),
|
request.getLineNumber(),
|
||||||
userId, // customerId로 사용
|
userId, // customerId로 사용
|
||||||
request.getCurrentProductCode(),
|
request.getCurrentProductCode(),
|
||||||
@ -456,21 +401,71 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
* KOS 연동 상품변경 처리 (임시 구현)
|
* KOS 연동 상품변경 처리 (임시 구현)
|
||||||
*/
|
*/
|
||||||
private ProductChangeResult processProductChangeWithKos(ProductChangeRequest request, String requestId) {
|
private ProductChangeResult processProductChangeWithKos(ProductChangeRequest request, String requestId) {
|
||||||
// TODO: 실제 KOS 연동 구현
|
logger.info("KOS 상품 변경 처리 시작: requestId={}, lineNumber={}", requestId, request.getLineNumber());
|
||||||
// 현재는 임시 성공 결과 반환
|
|
||||||
try {
|
try {
|
||||||
Thread.sleep(100); // 처리 시간 시뮬레이션
|
// KOS 상품변경 API 호출
|
||||||
return ProductChangeResult.builder()
|
Map<String, Object> kosResponse = kosClientService.changeProductInKos(
|
||||||
.success(true)
|
request.getLineNumber(),
|
||||||
.resultCode("SUCCESS")
|
request.getCurrentProductCode(),
|
||||||
.resultMessage("상품 변경이 완료되었습니다")
|
request.getTargetProductCode()
|
||||||
.build();
|
);
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
// 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()
|
return ProductChangeResult.builder()
|
||||||
.success(false)
|
.success(false)
|
||||||
.resultCode("SYSTEM_ERROR")
|
.resultCode("SYSTEM_ERROR")
|
||||||
.failureReason("처리 중 시스템 오류 발생")
|
.failureReason("KOS 시스템 연동 중 오류가 발생했습니다: " + e.getMessage())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -531,45 +526,4 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
.build();
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -59,13 +59,13 @@ public class ProductValidationService {
|
|||||||
failureReasonBuilder.append("변경 대상 상품이 판매중이 아닙니다. ");
|
failureReasonBuilder.append("변경 대상 상품이 판매중이 아닙니다. ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 사업자 일치 확인
|
// 2. 사업자 일치 확인 (제외됨)
|
||||||
boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
|
// boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
|
||||||
request.getTargetProductCode(), validationDetails);
|
// request.getTargetProductCode(), validationDetails);
|
||||||
if (!isOperatorMatch) {
|
// if (!isOperatorMatch) {
|
||||||
overallSuccess = false;
|
// overallSuccess = false;
|
||||||
failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
|
// failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 3. 회선 상태 확인
|
// 3. 회선 상태 확인
|
||||||
boolean isLineStatusValid = validateLineStatus(request.getLineNumber(), validationDetails);
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.unicorn.phonebill: ${LOG_LEVEL_APP:DEBUG}
|
root: INFO
|
||||||
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
|
com.unicorn.phonebill: DEBUG
|
||||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
|
org.springframework.security: DEBUG
|
||||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
org.hibernate: DEBUG
|
||||||
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}
|
|
||||||
@ -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:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: WARN
|
root: WARN
|
||||||
com.unicorn.phonebill: INFO
|
com.unicorn.phonebill: INFO
|
||||||
org.springframework.security: WARN
|
org.springframework.security: WARN
|
||||||
org.hibernate: 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
|
|
||||||
@ -5,69 +5,60 @@ spring:
|
|||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
|
|
||||||
# Database 기본 설정
|
|
||||||
datasource:
|
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:
|
hikari:
|
||||||
maximum-pool-size: 20
|
maximum-pool-size: 20
|
||||||
minimum-idle: 5
|
minimum-idle: 5
|
||||||
idle-timeout: 300000
|
connection-timeout: 30000
|
||||||
|
idle-timeout: 600000
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
connection-timeout: 20000
|
|
||||||
validation-timeout: 5000
|
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000
|
||||||
|
# JPA 설정
|
||||||
# JPA 기본 설정
|
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: false
|
show-sql: ${SHOW_SQL:true}
|
||||||
hibernate:
|
|
||||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
|
||||||
show-sql: ${JPA_SHOW_SQL:false}
|
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: false
|
format_sql: true
|
||||||
use_sql_comments: false
|
use_sql_comments: true
|
||||||
jdbc:
|
|
||||||
batch_size: 25
|
|
||||||
order_inserts: true
|
|
||||||
order_updates: true
|
|
||||||
connection:
|
connection:
|
||||||
provider_disables_autocommit: true
|
provider_disables_autocommit: false
|
||||||
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
# Redis 기본 설정
|
hibernate:
|
||||||
|
ddl-auto: ${DDL_AUTO:update}
|
||||||
|
|
||||||
|
# Redis 설정
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
|
host: ${REDIS_HOST:localhost}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:}
|
||||||
timeout: 2000ms
|
timeout: 2000ms
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
max-active: 20
|
max-active: 8
|
||||||
max-idle: 8
|
max-idle: 8
|
||||||
min-idle: 2
|
min-idle: 0
|
||||||
max-wait: -1ms
|
max-wait: -1ms
|
||||||
time-between-eviction-runs: 30s
|
database: ${REDIS_DATABASE:2}
|
||||||
|
|
||||||
# Cache 설정
|
# Cache 개발 설정 (TTL 단축)
|
||||||
cache:
|
cache:
|
||||||
type: redis
|
|
||||||
cache-names:
|
|
||||||
- customerInfo
|
|
||||||
- productInfo
|
|
||||||
- availableProducts
|
|
||||||
- productStatus
|
|
||||||
- lineStatus
|
|
||||||
redis:
|
redis:
|
||||||
time-to-live: 14400000 # 4시간 (ms)
|
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
|
||||||
cache-null-values: false
|
|
||||||
use-key-prefix: true
|
# Server 개발 설정
|
||||||
key-prefix: "product-service:"
|
server:
|
||||||
|
port: ${SERVER_PORT:8083}
|
||||||
# Security 기본 설정
|
error:
|
||||||
security:
|
include-stacktrace: always
|
||||||
oauth2:
|
include-message: always
|
||||||
resourceserver:
|
include-binding-errors: always
|
||||||
jwt:
|
|
||||||
issuer-uri: ${JWT_ISSUER_URI:http://localhost:8080/auth}
|
|
||||||
|
|
||||||
# Jackson 설정
|
# Jackson 설정
|
||||||
jackson:
|
jackson:
|
||||||
serialization:
|
serialization:
|
||||||
@ -78,22 +69,16 @@ spring:
|
|||||||
adjust-dates-to-context-time-zone: false
|
adjust-dates-to-context-time-zone: false
|
||||||
time-zone: Asia/Seoul
|
time-zone: Asia/Seoul
|
||||||
date-format: yyyy-MM-dd'T'HH:mm:ss
|
date-format: yyyy-MM-dd'T'HH:mm:ss
|
||||||
|
|
||||||
# HTTP 설정
|
|
||||||
webflux: {}
|
|
||||||
|
|
||||||
# Server 설정
|
# CORS
|
||||||
server:
|
cors:
|
||||||
port: ${SERVER_PORT:8083}
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
|
||||||
compression:
|
|
||||||
enabled: true
|
# JWT 토큰 설정
|
||||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
jwt:
|
||||||
http2:
|
secret: ${JWT_SECRET:}
|
||||||
enabled: true
|
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800000}
|
||||||
error:
|
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
|
||||||
include-stacktrace: never
|
|
||||||
include-message: always
|
|
||||||
include-binding-errors: always
|
|
||||||
|
|
||||||
# Management & Actuator
|
# Management & Actuator
|
||||||
management:
|
management:
|
||||||
@ -128,25 +113,6 @@ management:
|
|||||||
build:
|
build:
|
||||||
enabled: true
|
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 설정
|
# OpenAPI/Swagger 설정
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
@ -214,42 +180,44 @@ resilience4j:
|
|||||||
base-config: default
|
base-config: default
|
||||||
timeout-duration: 10s
|
timeout-duration: 10s
|
||||||
|
|
||||||
# 비즈니스 설정
|
# KOS Mock 서버 설정
|
||||||
app:
|
kos:
|
||||||
product:
|
base-url: ${KOS_BASE_URL:http://localhost:9090}
|
||||||
cache:
|
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
|
||||||
customer-info-ttl: 14400 # 4시간 (초)
|
read-timeout: ${KOS_READ_TIMEOUT:10000}
|
||||||
product-info-ttl: 7200 # 2시간 (초)
|
max-retries: ${KOS_MAX_RETRIES:3}
|
||||||
available-products-ttl: 86400 # 24시간 (초)
|
retry-delay: ${KOS_RETRY_DELAY:1000}
|
||||||
product-status-ttl: 3600 # 1시간 (초)
|
|
||||||
line-status-ttl: 1800 # 30분 (초)
|
# Circuit Breaker 설정
|
||||||
validation:
|
circuit-breaker:
|
||||||
max-retry-attempts: 3
|
failure-rate-threshold: ${KOS_CB_FAILURE_RATE:0.5}
|
||||||
validation-timeout: 5s
|
slow-call-duration-threshold: ${KOS_CB_SLOW_CALL_THRESHOLD:10000}
|
||||||
processing:
|
slow-call-rate-threshold: ${KOS_CB_SLOW_CALL_RATE:0.5}
|
||||||
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:true}
|
sliding-window-size: ${KOS_CB_SLIDING_WINDOW_SIZE:10}
|
||||||
max-concurrent-requests: ${PRODUCT_PROCESSING_MAX_CONCURRENT_REQUESTS:100}
|
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
|
||||||
request-timeout: ${PRODUCT_PROCESSING_REQUEST_TIMEOUT:30s}
|
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
|
||||||
|
wait-duration-in-open-state: ${KOS_CB_WAIT_DURATION:60000}
|
||||||
security:
|
|
||||||
jwt:
|
|
||||||
secret: ${JWT_SECRET:product-service-secret-key-change-in-production}
|
# Logging 운영 설정
|
||||||
expiration: ${JWT_EXPIRATION:86400} # 24시간
|
logging:
|
||||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7일
|
level:
|
||||||
cors:
|
root: WARN
|
||||||
allowed-origins:
|
com.unicorn.phonebill: INFO
|
||||||
- http://localhost:3000
|
com.phonebill.common.security: DEBUG
|
||||||
- https://mvno.com
|
org.springframework.security: DEBUG
|
||||||
allowed-methods:
|
org.hibernate: WARN
|
||||||
- GET
|
org.hibernate.resource.transaction: ERROR
|
||||||
- POST
|
org.hibernate.internal: ERROR
|
||||||
- PUT
|
pattern:
|
||||||
- DELETE
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
- OPTIONS
|
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||||
allowed-headers:
|
file:
|
||||||
- Authorization
|
name: logs/product-service.log
|
||||||
- Content-Type
|
max-size: 500MB
|
||||||
- Accept
|
max-history: 30
|
||||||
- X-Requested-With
|
total-size-cap: 10GB
|
||||||
allow-credentials: true
|
logback:
|
||||||
max-age: 3600
|
rollingpolicy:
|
||||||
|
clean-history-on-start: true
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
<option name="env">
|
<option name="env">
|
||||||
<map>
|
<map>
|
||||||
<entry key="BILL_INQUIRY_URL" value="http://localhost:8082" />
|
<entry key="BILL_INQUIRY_URL" value="http://localhost:8082" />
|
||||||
|
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
|
||||||
<entry key="DB_HOST" value="20.249.70.6" />
|
<entry key="DB_HOST" value="20.249.70.6" />
|
||||||
<entry key="DB_KIND" value="postgresql" />
|
<entry key="DB_KIND" value="postgresql" />
|
||||||
<entry key="DB_NAME" value="phonebill_auth" />
|
<entry key="DB_NAME" value="phonebill_auth" />
|
||||||
@ -11,11 +12,9 @@
|
|||||||
<entry key="DB_PORT" value="5432" />
|
<entry key="DB_PORT" value="5432" />
|
||||||
<entry key="DB_USERNAME" value="auth_user" />
|
<entry key="DB_USERNAME" value="auth_user" />
|
||||||
<entry key="DDL_AUTO" value="update" />
|
<entry key="DDL_AUTO" value="update" />
|
||||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
|
||||||
<entry key="LOG_FILE" value="logs/user-service.log" />
|
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
|
||||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
|
||||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
|
||||||
<entry key="PRODUCT_CHANGE_URL" value="http://localhost:8083" />
|
|
||||||
<entry key="REDIS_DATABASE" value="0" />
|
<entry key="REDIS_DATABASE" value="0" />
|
||||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.phonebill.user.config;
|
||||||
|
|
||||||
|
import com.phonebill.user.entity.AuthPermissionEntity;
|
||||||
|
import com.phonebill.user.enums.PermissionCode;
|
||||||
|
import com.phonebill.user.repository.AuthPermissionRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애플리케이션 시작 시 기본 데이터 초기화
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
|
private final AuthPermissionRepository authPermissionRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(String... args) throws Exception {
|
||||||
|
initializePermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 권한 데이터 초기화
|
||||||
|
*/
|
||||||
|
private void initializePermissions() {
|
||||||
|
log.info("권한 데이터 초기화 시작");
|
||||||
|
|
||||||
|
for (PermissionCode permissionCode : PermissionCode.values()) {
|
||||||
|
// 이미 존재하는 권한인지 확인
|
||||||
|
if (!authPermissionRepository.existsByPermissionCodeAndIsActiveTrue(permissionCode.getCode())) {
|
||||||
|
AuthPermissionEntity permission = AuthPermissionEntity.builder()
|
||||||
|
.serviceCode("USER_SERVICE")
|
||||||
|
.permissionCode(permissionCode.getCode())
|
||||||
|
.permissionName(permissionCode.getCode())
|
||||||
|
.permissionDescription(permissionCode.getDescription())
|
||||||
|
.isActive(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
authPermissionRepository.save(permission);
|
||||||
|
log.info("권한 생성: {}", permissionCode.getCode());
|
||||||
|
} else {
|
||||||
|
log.debug("권한 이미 존재: {}", permissionCode.getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("권한 데이터 초기화 완료");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,8 +42,9 @@ public class JwtConfig {
|
|||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public JwtTokenProvider jwtTokenProvider(
|
public JwtTokenProvider jwtTokenProvider(
|
||||||
@Value("${security.jwt.secret:phonebill-jwt-secret-key-2025-dev}") String secret,
|
@Value("${jwt.secret}") String secret,
|
||||||
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
|
@Value("${jwt.access-token-validity}") long tokenValidityInMilliseconds) {
|
||||||
|
long tokenValidityInSeconds = tokenValidityInMilliseconds / 1000;
|
||||||
return new JwtTokenProvider(secret, tokenValidityInSeconds);
|
return new JwtTokenProvider(secret, tokenValidityInSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,7 @@ package com.phonebill.user.config;
|
|||||||
import com.phonebill.common.security.JwtAuthenticationFilter;
|
import com.phonebill.common.security.JwtAuthenticationFilter;
|
||||||
import com.phonebill.common.security.JwtTokenProvider;
|
import com.phonebill.common.security.JwtTokenProvider;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@ -28,7 +29,9 @@ import java.util.List;
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
@Value("${cors.allowed-origins")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
@ -46,8 +49,10 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(authz -> authz
|
.authorizeHttpRequests(authz -> authz
|
||||||
// Public endpoints (인증 불필요)
|
// Public endpoints (인증 불필요)
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/auth/login",
|
"/api/v1/auth/login",
|
||||||
"/auth/refresh",
|
"/api/v1/auth/register",
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
"/api/v1/users",
|
||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/actuator/info",
|
"/actuator/info",
|
||||||
"/actuator/prometheus",
|
"/actuator/prometheus",
|
||||||
@ -102,10 +107,11 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
// 개발환경에서는 모든 Origin 허용, 운영환경에서는 특정 도메인만 허용
|
// 환경변수에서 허용할 Origin 패턴 설정
|
||||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
String[] origins = allowedOrigins.split(",");
|
||||||
|
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||||
|
|
||||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user