mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
release
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="product-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Connection -->
|
||||
<entry key="DB_HOST" value="20.249.107.185" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
<entry key="DB_NAME" value="product_change_db" />
|
||||
<entry key="DB_USERNAME" value="product_change_user" />
|
||||
<entry key="DB_PASSWORD" value="ProductUser2025!" />
|
||||
<entry key="DB_KIND" value="postgresql" />
|
||||
|
||||
<!-- Redis Connection -->
|
||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||
<entry key="REDIS_DATABASE" value="2" />
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<entry key="SERVER_PORT" value="8083" />
|
||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||
|
||||
<!-- JWT Configuration -->
|
||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
||||
<entry key="JWT_EXPIRATION" value="3600" />
|
||||
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="JPA_DDL_AUTO" value="update" />
|
||||
<entry key="DDL_AUTO" value="update" />
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<entry key="LOG_FILE" value="logs/product-service.log" />
|
||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||
|
||||
<!-- KOS Mock Configuration -->
|
||||
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
|
||||
<entry key="KOS_MOCK_ENABLED" value="true" />
|
||||
<entry key="KOS_API_KEY" value="dev-api-key" />
|
||||
<entry key="KOS_CLIENT_ID" value="product-service-dev" />
|
||||
|
||||
<!-- Product Service Specific Settings -->
|
||||
<entry key="PRODUCT_PROCESSING_ASYNC_ENABLED" value="false" />
|
||||
<entry key="PRODUCT_CACHE_CUSTOMER_INFO_TTL" value="600" />
|
||||
<entry key="PRODUCT_CACHE_PRODUCT_INFO_TTL" value="300" />
|
||||
<entry key="PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL" value="1800" />
|
||||
<entry key="PRODUCT_CACHE_PRODUCT_STATUS_TTL" value="300" />
|
||||
<entry key="PRODUCT_CACHE_LINE_STATUS_TTL" value="180" />
|
||||
<entry key="PRODUCT_VALIDATION_ENABLED" value="true" />
|
||||
<entry key="PRODUCT_VALIDATION_STRICT_MODE" value="false" />
|
||||
<entry key="TEST_DATA_ENABLED" value="true" />
|
||||
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="product-service:bootRun" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" value="-Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,189 @@
|
||||
// product-service 모듈
|
||||
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
|
||||
|
||||
dependencies {
|
||||
// Common module dependency
|
||||
implementation project(':common')
|
||||
|
||||
// Database (product service specific)
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
runtimeOnly 'com.h2database:h2' // for testing
|
||||
|
||||
// Circuit Breaker & Resilience
|
||||
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.1.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.1.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-retry:2.1.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-timelimiter:2.1.0'
|
||||
|
||||
// HTTP Client
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux' // for WebClient
|
||||
|
||||
// Logging (product service specific)
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
|
||||
|
||||
// Utilities (product service specific)
|
||||
implementation 'org.modelmapper:modelmapper:3.2.0'
|
||||
|
||||
// Test Dependencies (product service specific)
|
||||
testImplementation 'org.testcontainers:postgresql'
|
||||
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.0'
|
||||
testImplementation 'io.github.resilience4j:resilience4j-test:2.1.0'
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom 'org.testcontainers:testcontainers-bom:1.19.1'
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
// Test 환경 설정
|
||||
systemProperty 'spring.profiles.active', 'test'
|
||||
|
||||
// 병렬 실행 설정
|
||||
maxParallelForks = Runtime.runtime.availableProcessors()
|
||||
|
||||
// 메모리 설정
|
||||
minHeapSize = "512m"
|
||||
maxHeapSize = "2048m"
|
||||
|
||||
// Test 결과 리포트
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed"
|
||||
exceptionFormat = 'full'
|
||||
}
|
||||
|
||||
// Coverage 설정
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
// Jacoco Test Coverage
|
||||
apply plugin: 'jacoco'
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.10"
|
||||
}
|
||||
|
||||
jacocoTestReport {
|
||||
dependsOn test
|
||||
|
||||
reports {
|
||||
xml.required = true
|
||||
csv.required = false
|
||||
html.required = true
|
||||
html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
classDirectories.setFrom(files(classDirectories.files.collect {
|
||||
fileTree(dir: it, exclude: [
|
||||
'**/dto/**',
|
||||
'**/config/**',
|
||||
'**/exception/**',
|
||||
'**/*Application.*'
|
||||
])
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
jacocoTestCoverageVerification {
|
||||
dependsOn jacocoTestReport
|
||||
|
||||
violationRules {
|
||||
rule {
|
||||
limit {
|
||||
minimum = 0.80 // 80% 커버리지 목표
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spring Boot Plugin 설정
|
||||
springBoot {
|
||||
buildInfo()
|
||||
}
|
||||
|
||||
// JAR 설정
|
||||
jar {
|
||||
enabled = false
|
||||
archiveClassifier = ''
|
||||
}
|
||||
|
||||
bootJar {
|
||||
enabled = true
|
||||
archiveClassifier = ''
|
||||
archiveFileName = "${project.name}.jar"
|
||||
|
||||
// Build 정보 포함
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': project.name,
|
||||
'Implementation-Version': project.version,
|
||||
'Implementation-Vendor': 'MVNO Corp',
|
||||
'Built-By': System.getProperty('user.name'),
|
||||
'Build-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ"),
|
||||
'Created-By': "${System.getProperty('java.version')} (${System.getProperty('java.vendor')})",
|
||||
'Build-Jdk': "${System.getProperty('java.version')}",
|
||||
'Build-OS': "${System.getProperty('os.name')} ${System.getProperty('os.arch')} ${System.getProperty('os.version')}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경 설정
|
||||
if (project.hasProperty('dev')) {
|
||||
bootRun {
|
||||
args = ['--spring.profiles.active=dev']
|
||||
systemProperty 'spring.devtools.restart.enabled', 'true'
|
||||
systemProperty 'spring.devtools.livereload.enabled', 'true'
|
||||
}
|
||||
}
|
||||
|
||||
// Production 빌드 설정
|
||||
if (project.hasProperty('prod')) {
|
||||
bootJar {
|
||||
archiveFileName = "${project.name}-${project.version}-prod.jar"
|
||||
}
|
||||
}
|
||||
|
||||
// Docker 빌드를 위한 태스크
|
||||
task copyJar(type: Copy, dependsOn: bootJar) {
|
||||
from layout.buildDirectory.file("libs/${bootJar.archiveFileName.get()}")
|
||||
into layout.buildDirectory.dir("docker")
|
||||
rename { String fileName ->
|
||||
fileName.replace(bootJar.archiveFileName.get(), "app.jar")
|
||||
}
|
||||
}
|
||||
|
||||
// 정적 분석 도구 설정 (추후 확장 가능)
|
||||
task checkstyle(type: Checkstyle) {
|
||||
configFile = file("${rootDir}/config/checkstyle/checkstyle.xml")
|
||||
source 'src/main/java'
|
||||
include '**/*.java'
|
||||
exclude '**/generated/**'
|
||||
classpath = files()
|
||||
ignoreFailures = true
|
||||
}
|
||||
|
||||
// Clean 확장
|
||||
clean {
|
||||
delete 'logs'
|
||||
delete 'build/docker'
|
||||
}
|
||||
|
||||
// 컴파일 옵션
|
||||
compileJava {
|
||||
options.encoding = 'UTF-8'
|
||||
options.compilerArgs += [
|
||||
'-Xlint:all',
|
||||
'-Xlint:-processing',
|
||||
'-Werror'
|
||||
]
|
||||
}
|
||||
|
||||
compileTestJava {
|
||||
options.encoding = 'UTF-8'
|
||||
options.compilerArgs += [
|
||||
'-Xlint:all',
|
||||
'-Xlint:-processing'
|
||||
]
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.unicorn.phonebill.product;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Product Service 메인 애플리케이션
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 요청 처리
|
||||
* - KOS 시스템 연동
|
||||
* - Redis 캐싱
|
||||
* - JWT 인증/인가
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableJpaAuditing
|
||||
@EnableCaching
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class ProductServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ProductServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.phonebill.product.dto.ErrorResponse;
|
||||
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.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JWT 권한 부족 시 처리하는 Handler
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 권한이 부족한 요청에 대한 응답 처리
|
||||
* - 403 Forbidden 응답 생성
|
||||
* - 표준화된 에러 응답 포맷 적용
|
||||
*/
|
||||
@Component
|
||||
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAccessDeniedHandler.class);
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException, ServletException {
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String userId = authentication != null ? authentication.getName() : "anonymous";
|
||||
|
||||
logger.error("권한이 부족한 요청입니다. User: {}, URI: {}, Error: {}",
|
||||
userId, request.getRequestURI(), accessDeniedException.getMessage());
|
||||
|
||||
// 에러 응답 생성
|
||||
ErrorResponse errorResponse = createErrorResponse(request, accessDeniedException, userId);
|
||||
|
||||
// HTTP 응답 설정
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
|
||||
// 응답 본문 작성
|
||||
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
|
||||
response.getWriter().write(jsonResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 오류 응답 생성
|
||||
*/
|
||||
private ErrorResponse createErrorResponse(HttpServletRequest request,
|
||||
AccessDeniedException accessDeniedException,
|
||||
String userId) {
|
||||
String path = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
|
||||
String message = "요청한 리소스에 접근할 권한이 없습니다";
|
||||
String details = String.format("사용자 '%s'는 '%s %s' 리소스에 접근할 권한이 없습니다",
|
||||
userId, method, path);
|
||||
|
||||
return ErrorResponse.of("FORBIDDEN", message, details, path);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.phonebill.product.dto.ErrorResponse;
|
||||
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.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JWT 인증 실패 시 처리하는 EntryPoint
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 인증되지 않은 요청에 대한 응답 처리
|
||||
* - 401 Unauthorized 응답 생성
|
||||
* - 표준화된 에러 응답 포맷 적용
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException, ServletException {
|
||||
|
||||
logger.error("인증되지 않은 요청입니다. URI: {}, Error: {}",
|
||||
request.getRequestURI(), authException.getMessage());
|
||||
|
||||
// 에러 응답 생성
|
||||
ErrorResponse errorResponse = createErrorResponse(request, authException);
|
||||
|
||||
// HTTP 응답 설정
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
|
||||
// 응답 본문 작성
|
||||
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
|
||||
response.getWriter().write(jsonResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 오류 응답 생성
|
||||
*/
|
||||
private ErrorResponse createErrorResponse(HttpServletRequest request, AuthenticationException authException) {
|
||||
String path = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
|
||||
// 요청 컨텍스트에 따른 오류 메시지 생성
|
||||
String message = determineErrorMessage(request, authException);
|
||||
String details = String.format("요청한 리소스에 접근하기 위해서는 인증이 필요합니다. [%s %s]", method, path);
|
||||
|
||||
return ErrorResponse.of("UNAUTHORIZED", message, details, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 오류 메시지 결정
|
||||
*/
|
||||
private String determineErrorMessage(HttpServletRequest request, AuthenticationException authException) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
// Authorization 헤더가 없는 경우
|
||||
if (authHeader == null) {
|
||||
return "인증 토큰이 제공되지 않았습니다";
|
||||
}
|
||||
|
||||
// Bearer 토큰 형식이 아닌 경우
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
return "올바르지 않은 인증 토큰 형식입니다. Bearer 토큰이 필요합니다";
|
||||
}
|
||||
|
||||
// 토큰은 있지만 유효하지 않은 경우
|
||||
return "제공된 인증 토큰이 유효하지 않습니다";
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
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,202 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Redis 설정 클래스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - Redis 연결 설정
|
||||
* - 캐시 매니저 설정
|
||||
* - 직렬화/역직렬화 설정
|
||||
* - 캐시별 TTL 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리 (기본값 사용)
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
return new LettuceConnectionFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* String-Object 형태의 데이터 처리
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// ObjectMapper 설정
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.WRAPPER_ARRAY);
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// JSON 직렬화 설정
|
||||
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer =
|
||||
new GenericJackson2JsonRedisSerializer(objectMapper);
|
||||
|
||||
// String 직렬화 설정
|
||||
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
|
||||
|
||||
// Key 직렬화: String
|
||||
template.setKeySerializer(stringRedisSerializer);
|
||||
template.setHashKeySerializer(stringRedisSerializer);
|
||||
|
||||
// Value 직렬화: JSON
|
||||
template.setValueSerializer(jackson2JsonRedisSerializer);
|
||||
template.setHashValueSerializer(jackson2JsonRedisSerializer);
|
||||
|
||||
// 기본 직렬화 설정
|
||||
template.setDefaultSerializer(jackson2JsonRedisSerializer);
|
||||
template.afterPropertiesSet();
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Cache Manager 설정
|
||||
* @Cacheable 어노테이션 사용을 위한 설정
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
|
||||
// 기본 캐시 설정
|
||||
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ofHours(1)) // 기본 TTL: 1시간
|
||||
.disableCachingNullValues()
|
||||
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(createJsonRedisSerializer()));
|
||||
|
||||
// 캐시별 개별 TTL 설정
|
||||
Map<String, RedisCacheConfiguration> cacheConfigurations = createCacheConfigurations();
|
||||
|
||||
return RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(defaultCacheConfig)
|
||||
.withInitialCacheConfigurations(cacheConfigurations)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시별 개별 설정
|
||||
* 데이터 특성에 맞는 TTL 적용
|
||||
*/
|
||||
private Map<String, RedisCacheConfiguration> createCacheConfigurations() {
|
||||
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
|
||||
|
||||
// 고객상품정보: 4시간 (자주 변경되지 않음)
|
||||
configMap.put("customerProductInfo", createCacheConfig(Duration.ofHours(4)));
|
||||
|
||||
// 현재상품정보: 2시간 (변경 가능성 있음)
|
||||
configMap.put("currentProductInfo", createCacheConfig(Duration.ofHours(2)));
|
||||
|
||||
// 가용상품목록: 24시간 (상품 정보는 하루 단위로 변경)
|
||||
configMap.put("availableProducts", createCacheConfig(Duration.ofHours(24)));
|
||||
|
||||
// 상품상태: 1시간 (자주 확인 필요)
|
||||
configMap.put("productStatus", createCacheConfig(Duration.ofHours(1)));
|
||||
|
||||
// 회선상태: 30분 (실시간 확인 필요)
|
||||
configMap.put("lineStatus", createCacheConfig(Duration.ofMinutes(30)));
|
||||
|
||||
// 메뉴정보: 6시간 (메뉴는 자주 변경되지 않음)
|
||||
configMap.put("menuInfo", createCacheConfig(Duration.ofHours(6)));
|
||||
|
||||
// 상품변경결과: 1시간 (결과 조회용)
|
||||
configMap.put("productChangeResult", createCacheConfig(Duration.ofHours(1)));
|
||||
|
||||
return configMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 TTL을 가진 캐시 설정 생성
|
||||
*/
|
||||
private RedisCacheConfiguration createCacheConfig(Duration ttl) {
|
||||
return RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(ttl)
|
||||
.disableCachingNullValues()
|
||||
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(createJsonRedisSerializer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 직렬화기 생성
|
||||
*/
|
||||
private Jackson2JsonRedisSerializer<Object> createJsonRedisSerializer() {
|
||||
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer =
|
||||
new Jackson2JsonRedisSerializer<>(Object.class);
|
||||
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.WRAPPER_ARRAY);
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// setObjectMapper는 deprecated되었으므로 생성자 사용
|
||||
return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 프로퍼티 설정 클래스
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "spring.data.redis")
|
||||
public static class RedisProperties {
|
||||
private String host = "localhost";
|
||||
private int port = 6379;
|
||||
private String password;
|
||||
private int database = 0;
|
||||
private Duration timeout = Duration.ofSeconds(2);
|
||||
|
||||
// Getters and Setters
|
||||
public String getHost() { return host; }
|
||||
public void setHost(String host) { this.host = host; }
|
||||
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int port) { this.port = port; }
|
||||
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
|
||||
public int getDatabase() { return database; }
|
||||
public void setDatabase(int database) { this.database = database; }
|
||||
|
||||
public Duration getTimeout() { return timeout; }
|
||||
public void setTimeout(Duration timeout) { this.timeout = timeout; }
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.unicorn.phonebill.product.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - JWT 인증 필터 설정
|
||||
* - CORS 설정
|
||||
* - API 엔드포인트 보안 설정
|
||||
* - 세션 비활성화 (Stateless)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
JwtAccessDeniedHandler jwtAccessDeniedHandler,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security Filter Chain 설정
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF 비활성화 (JWT 사용으로 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// CORS 설정
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
||||
// 세션 비활성화 (Stateless)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 예외 처리 설정
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
|
||||
.accessDeniedHandler(jwtAccessDeniedHandler))
|
||||
|
||||
// 권한 설정
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
// Health Check 및 문서화 API는 인증 불필요
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
|
||||
// OPTIONS 요청은 인증 불필요 (CORS Preflight)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
|
||||
// 모든 API는 인증 필요
|
||||
.requestMatchers("/products/**").authenticated()
|
||||
|
||||
// 나머지 요청은 모두 인증 필요
|
||||
.anyRequest().authenticated())
|
||||
|
||||
// JWT 인증 필터 추가
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// 허용할 Origin 설정
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(
|
||||
"http://localhost:3000", // 개발환경 프론트엔드
|
||||
"http://localhost:8080", // API Gateway
|
||||
"https://*.mvno.com", // 운영환경
|
||||
"https://*.mvno-dev.com" // 개발환경
|
||||
));
|
||||
|
||||
// 허용할 HTTP 메서드
|
||||
configuration.setAllowedMethods(Arrays.asList(
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
|
||||
));
|
||||
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"Access-Control-Request-Method",
|
||||
"Access-Control-Request-Headers",
|
||||
"X-User-ID",
|
||||
"X-Customer-ID",
|
||||
"X-Request-ID"
|
||||
));
|
||||
|
||||
// 노출할 헤더
|
||||
configuration.setExposedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"X-Request-ID",
|
||||
"X-Total-Count"
|
||||
));
|
||||
|
||||
// 자격 증명 허용
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Preflight 요청 캐시 시간 설정 (1시간)
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화기
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
package com.unicorn.phonebill.product.controller;
|
||||
|
||||
import com.unicorn.phonebill.product.dto.*;
|
||||
import com.unicorn.phonebill.product.service.ProductService;
|
||||
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.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 상품변경 서비스 REST API 컨트롤러
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 메뉴 조회 (UFR-PROD-010)
|
||||
* - 고객 및 상품 정보 조회 (UFR-PROD-020)
|
||||
* - 상품변경 요청 및 사전체크 (UFR-PROD-030)
|
||||
* - KOS 연동 상품변경 처리 (UFR-PROD-040)
|
||||
* - 상품변경 이력 조회
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/products")
|
||||
@Validated
|
||||
@Tag(name = "Product Change Service", description = "상품변경 서비스 API")
|
||||
@SecurityRequirement(name = "bearerAuth")
|
||||
public class ProductController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductController.class);
|
||||
|
||||
private final ProductService productService;
|
||||
|
||||
public ProductController(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 구현
|
||||
*/
|
||||
@GetMapping("/customer/{lineNumber}")
|
||||
@Operation(summary = "고객 정보 조회",
|
||||
description = "특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "고객 정보 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = CustomerInfoResponse.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<CustomerInfoResponse> getCustomerInfo(
|
||||
@Parameter(description = "고객 회선번호", example = "01012345678")
|
||||
@PathVariable
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
String lineNumber) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("고객 정보 조회 요청: lineNumber={}, userId={}", lineNumber, userId);
|
||||
|
||||
try {
|
||||
CustomerInfoResponse response = productService.getCustomerInfo(lineNumber);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("고객 정보 조회 실패: lineNumber={}, userId={}", lineNumber, userId, e);
|
||||
throw new RuntimeException("고객 정보 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 가능한 상품 목록 조회
|
||||
* UFR-PROD-020 구현
|
||||
*/
|
||||
@GetMapping("/available")
|
||||
@Operation(summary = "변경 가능한 상품 목록 조회",
|
||||
description = "현재 판매중이고 변경 가능한 상품 목록을 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "상품 목록 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = AvailableProductsResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<AvailableProductsResponse> getAvailableProducts(
|
||||
@Parameter(description = "현재 상품코드 (필터링용)")
|
||||
@RequestParam(required = false) String currentProductCode,
|
||||
@Parameter(description = "사업자 코드")
|
||||
@RequestParam(required = false) String operatorCode) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("가용 상품 목록 조회 요청: currentProductCode={}, operatorCode={}, userId={}",
|
||||
currentProductCode, operatorCode, userId);
|
||||
|
||||
try {
|
||||
AvailableProductsResponse response = productService.getAvailableProducts(currentProductCode, operatorCode);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("가용 상품 목록 조회 실패: currentProductCode={}, operatorCode={}, userId={}",
|
||||
currentProductCode, operatorCode, userId, e);
|
||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크
|
||||
* UFR-PROD-030 구현
|
||||
*/
|
||||
@PostMapping("/change/validation")
|
||||
@Operation(summary = "상품변경 사전체크",
|
||||
description = "상품변경 요청 전 사전체크를 수행합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "사전체크 완료 (성공/실패 포함)",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeValidationResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<ProductChangeValidationResponse> validateProductChange(
|
||||
@Valid @RequestBody ProductChangeValidationRequest request) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 사전체크 요청: lineNumber={}, current={}, target={}, userId={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), userId);
|
||||
|
||||
try {
|
||||
ProductChangeValidationResponse response = productService.validateProductChange(request);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 사전체크 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
|
||||
throw new RuntimeException("상품변경 사전체크 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 요청 (동기 처리)
|
||||
* UFR-PROD-040 구현
|
||||
*/
|
||||
@PostMapping("/change")
|
||||
@Operation(summary = "상품변경 요청",
|
||||
description = "실제 상품변경 처리를 요청합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "상품변경 처리 완료",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeResponse.class))),
|
||||
@ApiResponse(responseCode = "202", description = "상품변경 요청 접수 (비동기 처리)",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeAsyncResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "409", description = "사전체크 실패 또는 처리 불가 상태",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "503", description = "KOS 시스템 장애 (Circuit Breaker Open)",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<?> requestProductChange(
|
||||
@Valid @RequestBody ProductChangeRequest request,
|
||||
@Parameter(description = "처리 모드 (sync: 동기, async: 비동기)")
|
||||
@RequestParam(defaultValue = "sync") String mode) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 요청: lineNumber={}, current={}, target={}, mode={}, userId={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), mode, userId);
|
||||
|
||||
try {
|
||||
if ("async".equalsIgnoreCase(mode)) {
|
||||
// 비동기 처리
|
||||
ProductChangeAsyncResponse response = productService.requestProductChangeAsync(request, userId);
|
||||
return ResponseEntity.accepted().body(response);
|
||||
} else {
|
||||
// 동기 처리 (기본값)
|
||||
ProductChangeResponse response = productService.requestProductChange(request, userId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 요청 실패: lineNumber={}, userId={}", request.getLineNumber(), userId, e);
|
||||
throw new RuntimeException("상품변경 처리 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 결과 조회
|
||||
*/
|
||||
@GetMapping("/change/{requestId}")
|
||||
@Operation(summary = "상품변경 결과 조회",
|
||||
description = "특정 요청ID의 상품변경 처리 결과를 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "처리 결과 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeResultResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "404", description = "요청 정보를 찾을 수 없음",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<ProductChangeResultResponse> getProductChangeResult(
|
||||
@Parameter(description = "상품변경 요청 ID")
|
||||
@PathVariable String requestId) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 결과 조회 요청: requestId={}, userId={}", requestId, userId);
|
||||
|
||||
try {
|
||||
ProductChangeResultResponse response = productService.getProductChangeResult(requestId);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 결과 조회 실패: requestId={}, userId={}", requestId, userId, e);
|
||||
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 이력 조회
|
||||
* UFR-PROD-040 구현 (이력 관리)
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "상품변경 이력 조회",
|
||||
description = "고객의 상품변경 이력을 조회합니다")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "이력 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = ProductChangeHistoryResponse.class))),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||
@ApiResponse(responseCode = "500", description = "서버 오류",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
})
|
||||
public ResponseEntity<ProductChangeHistoryResponse> getProductChangeHistory(
|
||||
@Parameter(description = "회선번호 (미입력시 로그인 고객 기준)")
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
String lineNumber,
|
||||
@Parameter(description = "조회 시작일 (YYYY-MM-DD)")
|
||||
@RequestParam(required = false) String startDate,
|
||||
@Parameter(description = "조회 종료일 (YYYY-MM-DD)")
|
||||
@RequestParam(required = false) String endDate,
|
||||
@Parameter(description = "페이지 번호 (1부터 시작)")
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@Parameter(description = "페이지 크기")
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
|
||||
String userId = getCurrentUserId();
|
||||
logger.info("상품변경 이력 조회 요청: lineNumber={}, startDate={}, endDate={}, page={}, size={}, userId={}",
|
||||
lineNumber, startDate, endDate, page, size, userId);
|
||||
|
||||
try {
|
||||
// 페이지 번호를 0-based로 변환
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), Math.min(100, Math.max(1, size)));
|
||||
|
||||
// 날짜 유효성 검증
|
||||
validateDateRange(startDate, endDate);
|
||||
|
||||
ProductChangeHistoryResponse response = productService.getProductChangeHistory(
|
||||
lineNumber, startDate, endDate, pageable);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 이력 조회 실패: lineNumber={}, userId={}", lineNumber, userId, e);
|
||||
throw new RuntimeException("상품변경 이력 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Private Helper Methods ==========
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자 ID 조회
|
||||
*/
|
||||
private String getCurrentUserId() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
return authentication.getName();
|
||||
}
|
||||
throw new RuntimeException("인증된 사용자 정보를 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 유효성 검증
|
||||
*/
|
||||
private void validateDateRange(String startDate, String endDate) {
|
||||
if (startDate != null && endDate != null) {
|
||||
try {
|
||||
LocalDate start = LocalDate.parse(startDate);
|
||||
LocalDate end = LocalDate.parse(endDate);
|
||||
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("시작일이 종료일보다 늦을 수 없습니다");
|
||||
}
|
||||
|
||||
if (start.isBefore(LocalDate.now().minusYears(2))) {
|
||||
throw new IllegalArgumentException("조회 가능한 기간을 초과했습니다 (최대 2년)");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IllegalArgumentException) {
|
||||
throw e;
|
||||
}
|
||||
throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Exception Handler ==========
|
||||
|
||||
/**
|
||||
* 컨트롤러 레벨 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
|
||||
logger.error("컨트롤러에서 런타임 예외 발생", e);
|
||||
ErrorResponse errorResponse = ErrorResponse.internalServerError(e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) {
|
||||
logger.warn("잘못된 요청 파라미터: {}", e.getMessage());
|
||||
ErrorResponse errorResponse = ErrorResponse.validationError(e.getMessage());
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
/**
|
||||
* 상품변경 처리 상태
|
||||
*/
|
||||
public enum ProcessStatus {
|
||||
/**
|
||||
* 요청 접수
|
||||
*/
|
||||
REQUESTED("요청 접수"),
|
||||
|
||||
/**
|
||||
* 사전체크 완료
|
||||
*/
|
||||
VALIDATED("사전체크 완료"),
|
||||
|
||||
/**
|
||||
* 처리 중
|
||||
*/
|
||||
PROCESSING("처리 중"),
|
||||
|
||||
/**
|
||||
* 완료
|
||||
*/
|
||||
COMPLETED("완료"),
|
||||
|
||||
/**
|
||||
* 실패
|
||||
*/
|
||||
FAILED("실패");
|
||||
|
||||
private final String description;
|
||||
|
||||
ProcessStatus(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리가 완료된 상태인지 확인
|
||||
*/
|
||||
public boolean isFinished() {
|
||||
return this == COMPLETED || this == FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공적으로 완료된 상태인지 확인
|
||||
*/
|
||||
public boolean isSuccessful() {
|
||||
return this == COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중인 상태인지 확인
|
||||
*/
|
||||
public boolean isInProgress() {
|
||||
return this == PROCESSING || this == VALIDATED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 상품 도메인 모델
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class Product {
|
||||
|
||||
private final String productCode;
|
||||
private final String productName;
|
||||
private final BigDecimal monthlyFee;
|
||||
private final String dataAllowance;
|
||||
private final String voiceAllowance;
|
||||
private final String smsAllowance;
|
||||
private final ProductStatus status;
|
||||
private final String operatorCode;
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* 다른 상품으로 변경 가능한지 확인
|
||||
*/
|
||||
public boolean canChangeTo(Product targetProduct) {
|
||||
if (targetProduct == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 상품으로는 변경 불가
|
||||
if (this.productCode.equals(targetProduct.productCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 사업자 상품끼리만 변경 가능
|
||||
if (!isSameOperator(targetProduct)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 대상 상품이 판매 중이어야 함
|
||||
return targetProduct.status == ProductStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일한 사업자 상품인지 확인
|
||||
*/
|
||||
public boolean isSameOperator(Product other) {
|
||||
return other != null &&
|
||||
this.operatorCode != null &&
|
||||
this.operatorCode.equals(other.operatorCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품이 활성 상태인지 확인
|
||||
*/
|
||||
public boolean isActive() {
|
||||
return status == ProductStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품이 판매 중지 상태인지 확인
|
||||
*/
|
||||
public boolean isDiscontinued() {
|
||||
return status == ProductStatus.DISCONTINUED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 요금 차이 계산
|
||||
*/
|
||||
public BigDecimal calculateFeeDifference(Product targetProduct) {
|
||||
if (targetProduct == null || targetProduct.monthlyFee == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
BigDecimal currentFee = this.monthlyFee != null ? this.monthlyFee : BigDecimal.ZERO;
|
||||
return targetProduct.monthlyFee.subtract(currentFee);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금이 더 비싼지 확인
|
||||
*/
|
||||
public boolean isMoreExpensiveThan(Product other) {
|
||||
if (other == null || other.monthlyFee == null || this.monthlyFee == null) {
|
||||
return false;
|
||||
}
|
||||
return this.monthlyFee.compareTo(other.monthlyFee) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리미엄 상품인지 확인 (월 요금 기준)
|
||||
*/
|
||||
public boolean isPremium() {
|
||||
if (monthlyFee == null) {
|
||||
return false;
|
||||
}
|
||||
// 월 요금 60,000원 이상을 프리미엄으로 간주
|
||||
return monthlyFee.compareTo(new BigDecimal("60000")) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 정보 요약 문자열 생성
|
||||
*/
|
||||
public String getSummary() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(productName);
|
||||
if (monthlyFee != null) {
|
||||
sb.append(" (월 ").append(monthlyFee.toPlainString()).append("원)");
|
||||
}
|
||||
if (dataAllowance != null) {
|
||||
sb.append(" - 데이터: ").append(dataAllowance);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 도메인 모델
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class ProductChangeHistory {
|
||||
|
||||
private final Long id;
|
||||
private final String requestId;
|
||||
private final String lineNumber;
|
||||
private final String customerId;
|
||||
private final String currentProductCode;
|
||||
private final String targetProductCode;
|
||||
private final ProcessStatus processStatus;
|
||||
private final String validationResult;
|
||||
private final String processMessage;
|
||||
private final Map<String, Object> kosRequestData;
|
||||
private final Map<String, Object> kosResponseData;
|
||||
private final LocalDateTime requestedAt;
|
||||
private final LocalDateTime validatedAt;
|
||||
private final LocalDateTime processedAt;
|
||||
private final Long version;
|
||||
|
||||
/**
|
||||
* 완료 상태로 변경된 새 인스턴스 생성
|
||||
*/
|
||||
public ProductChangeHistory markAsCompleted(String message, Map<String, Object> kosResponseData) {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(ProcessStatus.COMPLETED)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(message)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 상태로 변경된 새 인스턴스 생성
|
||||
*/
|
||||
public ProductChangeHistory markAsFailed(String message) {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(ProcessStatus.FAILED)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(message)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 상태로 변경된 새 인스턴스 생성 (오버로딩)
|
||||
*/
|
||||
public ProductChangeHistory markAsFailed(String resultCode, String failureReason) {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(ProcessStatus.FAILED)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(resultCode + ": " + failureReason)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 완료 상태로 변경된 새 인스턴스 생성
|
||||
*/
|
||||
public ProductChangeHistory markAsValidated(String validationResult) {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(ProcessStatus.VALIDATED)
|
||||
.validationResult(validationResult)
|
||||
.processMessage(this.processMessage)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(LocalDateTime.now())
|
||||
.processedAt(this.processedAt)
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중 상태로 변경된 새 인스턴스 생성
|
||||
*/
|
||||
public ProductChangeHistory markAsProcessing() {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(ProcessStatus.PROCESSING)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(this.processMessage)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(this.processedAt)
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리가 완료된 상태인지 확인
|
||||
*/
|
||||
public boolean isFinished() {
|
||||
return processStatus != null && processStatus.isFinished();
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공적으로 완료된 상태인지 확인
|
||||
*/
|
||||
public boolean isSuccessful() {
|
||||
return processStatus != null && processStatus.isSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중인 상태인지 확인
|
||||
*/
|
||||
public boolean isInProgress() {
|
||||
return processStatus != null && processStatus.isInProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 상품변경 이력 생성 (팩토리 메소드)
|
||||
*/
|
||||
public static ProductChangeHistory createNew(
|
||||
String requestId,
|
||||
String lineNumber,
|
||||
String customerId,
|
||||
String currentProductCode,
|
||||
String targetProductCode) {
|
||||
|
||||
return ProductChangeHistory.builder()
|
||||
.requestId(requestId)
|
||||
.lineNumber(lineNumber)
|
||||
.customerId(customerId)
|
||||
.currentProductCode(currentProductCode)
|
||||
.targetProductCode(targetProductCode)
|
||||
.processStatus(ProcessStatus.REQUESTED)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 코드 추출 (processMessage에서)
|
||||
*/
|
||||
public String getResultCode() {
|
||||
if (processMessage != null && processMessage.contains(":")) {
|
||||
return processMessage.split(":")[0].trim();
|
||||
}
|
||||
return processStatus != null ? processStatus.name() : "UNKNOWN";
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 메시지 추출 (processMessage에서)
|
||||
*/
|
||||
public String getResultMessage() {
|
||||
if (processMessage != null && processMessage.contains(":")) {
|
||||
String[] parts = processMessage.split(":", 2);
|
||||
if (parts.length > 1) {
|
||||
return parts[1].trim();
|
||||
}
|
||||
}
|
||||
return processMessage != null ? processMessage : "처리 메시지가 없습니다.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 사유 추출 (실패 상태일 때의 processMessage)
|
||||
*/
|
||||
public String getFailureReason() {
|
||||
if (processStatus == ProcessStatus.FAILED) {
|
||||
return getResultMessage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 상품변경 처리 결과 도메인 모델
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class ProductChangeResult {
|
||||
|
||||
private final String requestId;
|
||||
private final boolean success;
|
||||
private final String resultCode;
|
||||
private final String resultMessage;
|
||||
private final Product changedProduct;
|
||||
private final LocalDateTime processedAt;
|
||||
private final Map<String, Object> additionalData;
|
||||
|
||||
/**
|
||||
* 성공 결과 생성 (팩토리 메소드)
|
||||
*/
|
||||
public static ProductChangeResult createSuccessResult(
|
||||
String requestId,
|
||||
String resultMessage,
|
||||
Product changedProduct) {
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.requestId(requestId)
|
||||
.success(true)
|
||||
.resultCode("SUCCESS")
|
||||
.resultMessage(resultMessage)
|
||||
.changedProduct(changedProduct)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 결과 생성 (팩토리 메소드)
|
||||
*/
|
||||
public static ProductChangeResult createFailureResult(
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String resultMessage) {
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.requestId(requestId)
|
||||
.success(false)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 데이터와 함께 실패 결과 생성
|
||||
*/
|
||||
public static ProductChangeResult createFailureResult(
|
||||
String requestId,
|
||||
String resultCode,
|
||||
String resultMessage,
|
||||
Map<String, Object> additionalData) {
|
||||
|
||||
return ProductChangeResult.builder()
|
||||
.requestId(requestId)
|
||||
.success(false)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.additionalData(additionalData)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과가 성공인지 확인
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과가 실패인지 확인
|
||||
*/
|
||||
public boolean isFailure() {
|
||||
return !success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.unicorn.phonebill.product.domain;
|
||||
|
||||
/**
|
||||
* 상품 상태
|
||||
*/
|
||||
public enum ProductStatus {
|
||||
/**
|
||||
* 판매 중
|
||||
*/
|
||||
ACTIVE("판매 중"),
|
||||
|
||||
/**
|
||||
* 판매 중지
|
||||
*/
|
||||
DISCONTINUED("판매 중지"),
|
||||
|
||||
/**
|
||||
* 준비 중
|
||||
*/
|
||||
PREPARING("준비 중");
|
||||
|
||||
private final String description;
|
||||
|
||||
ProductStatus(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 가능한 상품 상태인지 확인
|
||||
*/
|
||||
public boolean isChangeable() {
|
||||
return this == ACTIVE;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 변경 가능한 상품 목록 조회 응답 DTO
|
||||
* API: GET /products/available
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AvailableProductsResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private ProductsData data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ProductsData {
|
||||
|
||||
@NotNull(message = "상품 목록은 필수입니다")
|
||||
private List<ProductInfoDto> products;
|
||||
|
||||
private Integer totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static AvailableProductsResponse success(List<ProductInfoDto> products) {
|
||||
ProductsData data = ProductsData.builder()
|
||||
.products(products)
|
||||
.totalCount(products != null ? products.size() : 0)
|
||||
.build();
|
||||
|
||||
return AvailableProductsResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 변경 결과 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChangeResult {
|
||||
private String requestId;
|
||||
private String status;
|
||||
private String message;
|
||||
private String processedAt;
|
||||
private String completedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 고객 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CustomerInfo {
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String phoneNumber;
|
||||
private String email;
|
||||
private String address;
|
||||
private String customerType;
|
||||
private String status;
|
||||
private String joinDate;
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 고객 정보 조회 응답 DTO
|
||||
* API: GET /products/customer/{lineNumber}
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class CustomerInfoResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private CustomerInfo data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class CustomerInfo {
|
||||
|
||||
@NotBlank(message = "고객 ID는 필수입니다")
|
||||
private String customerId;
|
||||
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
private String lineNumber;
|
||||
|
||||
@NotBlank(message = "고객명은 필수입니다")
|
||||
private String customerName;
|
||||
|
||||
@NotNull(message = "현재 상품 정보는 필수입니다")
|
||||
@Valid
|
||||
private ProductInfoDto currentProduct;
|
||||
|
||||
@NotBlank(message = "회선 상태는 필수입니다")
|
||||
private String lineStatus; // ACTIVE, SUSPENDED, TERMINATED
|
||||
|
||||
@Valid
|
||||
private ContractInfo contractInfo;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ContractInfo {
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate contractDate;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate termEndDate;
|
||||
|
||||
private BigDecimal earlyTerminationFee;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static CustomerInfoResponse success(CustomerInfo data) {
|
||||
return CustomerInfoResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 공통 오류 응답 DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ErrorResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
@Builder.Default
|
||||
private Boolean success = false;
|
||||
|
||||
@Valid
|
||||
private ErrorData error;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ErrorData {
|
||||
|
||||
@NotNull(message = "오류 코드는 필수입니다")
|
||||
private String code;
|
||||
|
||||
@NotNull(message = "오류 메시지는 필수입니다")
|
||||
private String message;
|
||||
|
||||
private String details;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
@Builder.Default
|
||||
private LocalDateTime timestamp = LocalDateTime.now();
|
||||
|
||||
private String path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse of(String code, String message) {
|
||||
return ErrorResponse.of(code, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse of(String code, String message, String details, String path) {
|
||||
ErrorData errorData = ErrorData.builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.details(details)
|
||||
.path(path)
|
||||
.build();
|
||||
|
||||
return ErrorResponse.builder()
|
||||
.error(errorData)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse validationError(String message) {
|
||||
return of("INVALID_REQUEST", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse unauthorized(String message) {
|
||||
return of("UNAUTHORIZED", message != null ? message : "인증이 필요합니다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse forbidden(String message) {
|
||||
return of("FORBIDDEN", message != null ? message : "서비스 이용 권한이 없습니다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 오류 응답 생성
|
||||
*/
|
||||
public static ErrorResponse internalServerError(String message) {
|
||||
return of("INTERNAL_SERVER_ERROR", message != null ? message : "서버 내부 오류가 발생했습니다");
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 상품변경 비동기 처리 응답 DTO (접수 완료 시)
|
||||
* API: POST /products/change (202 응답)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProductChangeAsyncResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private AsyncData data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class AsyncData {
|
||||
|
||||
@NotNull(message = "요청 ID는 필수입니다")
|
||||
private String requestId;
|
||||
|
||||
@NotNull(message = "처리 상태는 필수입니다")
|
||||
private ProcessStatus processStatus; // PENDING, PROCESSING
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime estimatedCompletionTime;
|
||||
|
||||
private String message;
|
||||
}
|
||||
|
||||
public enum ProcessStatus {
|
||||
PENDING, PROCESSING
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 접수 응답 생성
|
||||
*/
|
||||
public static ProductChangeAsyncResponse accepted(String requestId, String message) {
|
||||
AsyncData data = AsyncData.builder()
|
||||
.requestId(requestId)
|
||||
.processStatus(ProcessStatus.PROCESSING)
|
||||
.estimatedCompletionTime(LocalDateTime.now().plusMinutes(5))
|
||||
.message(message != null ? message : "상품 변경이 진행되었습니다")
|
||||
.build();
|
||||
|
||||
return ProductChangeAsyncResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProductChangeHistoryRequest {
|
||||
private String userId;
|
||||
private String startDate;
|
||||
private String endDate;
|
||||
private String status;
|
||||
private int page;
|
||||
private int size;
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 조회 응답 DTO
|
||||
* API: GET /products/history
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProductChangeHistoryResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private HistoryData data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class HistoryData {
|
||||
|
||||
@NotNull(message = "이력 목록은 필수입니다")
|
||||
private List<ProductChangeHistoryItem> history;
|
||||
|
||||
@Valid
|
||||
private PaginationInfo pagination;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ProductChangeHistoryItem {
|
||||
|
||||
@NotNull(message = "요청 ID는 필수입니다")
|
||||
private String requestId;
|
||||
|
||||
@NotNull(message = "회선번호는 필수입니다")
|
||||
private String lineNumber;
|
||||
|
||||
@NotNull(message = "처리 상태는 필수입니다")
|
||||
private String processStatus; // PENDING, PROCESSING, COMPLETED, FAILED
|
||||
|
||||
private String currentProductCode;
|
||||
|
||||
private String currentProductName;
|
||||
|
||||
private String targetProductCode;
|
||||
|
||||
private String targetProductName;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
private String resultMessage;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class PaginationInfo {
|
||||
|
||||
@NotNull(message = "현재 페이지는 필수입니다")
|
||||
private Integer page;
|
||||
|
||||
@NotNull(message = "페이지 크기는 필수입니다")
|
||||
private Integer size;
|
||||
|
||||
@NotNull(message = "전체 요소 수는 필수입니다")
|
||||
private Long totalElements;
|
||||
|
||||
@NotNull(message = "전체 페이지 수는 필수입니다")
|
||||
private Integer totalPages;
|
||||
|
||||
private Boolean hasNext;
|
||||
|
||||
private Boolean hasPrevious;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static ProductChangeHistoryResponse success(List<ProductChangeHistoryItem> history, PaginationInfo pagination) {
|
||||
HistoryData data = HistoryData.builder()
|
||||
.history(history)
|
||||
.pagination(pagination)
|
||||
.build();
|
||||
|
||||
return ProductChangeHistoryResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 상품변경 요청 DTO
|
||||
* API: POST /products/change
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProductChangeRequest {
|
||||
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
private String lineNumber;
|
||||
|
||||
@NotBlank(message = "현재 상품 코드는 필수입니다")
|
||||
private String currentProductCode;
|
||||
|
||||
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
|
||||
private String targetProductCode;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime requestDate;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
|
||||
private LocalDate changeEffectiveDate;
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 상품변경 처리 응답 DTO (동기 처리 완료 시)
|
||||
* API: POST /products/change (200 응답)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProductChangeResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private ProductChangeData data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ProductChangeData {
|
||||
|
||||
@NotNull(message = "요청 ID는 필수입니다")
|
||||
private String requestId;
|
||||
|
||||
@NotNull(message = "처리 상태는 필수입니다")
|
||||
private ProcessStatus processStatus; // COMPLETED, FAILED
|
||||
|
||||
@NotNull(message = "결과 코드는 필수입니다")
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
|
||||
@Valid
|
||||
private ProductInfoDto changedProduct;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime processedAt;
|
||||
}
|
||||
|
||||
public enum ProcessStatus {
|
||||
COMPLETED, FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static ProductChangeResponse success(String requestId, String resultCode,
|
||||
String resultMessage, ProductInfoDto changedProduct) {
|
||||
ProductChangeData data = ProductChangeData.builder()
|
||||
.requestId(requestId)
|
||||
.processStatus(ProcessStatus.COMPLETED)
|
||||
.resultCode(resultCode)
|
||||
.resultMessage(resultMessage)
|
||||
.changedProduct(changedProduct)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return ProductChangeResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 상품변경 결과 조회 응답 DTO
|
||||
* API: GET /products/change/{requestId}
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProductChangeResultResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private ProductChangeResult data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ProductChangeResult {
|
||||
|
||||
@NotNull(message = "요청 ID는 필수입니다")
|
||||
private String requestId;
|
||||
|
||||
private String lineNumber;
|
||||
|
||||
@NotNull(message = "처리 상태는 필수입니다")
|
||||
private ProcessStatus processStatus; // PENDING, PROCESSING, COMPLETED, FAILED
|
||||
|
||||
private String currentProductCode;
|
||||
|
||||
private String targetProductCode;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
|
||||
private String failureReason;
|
||||
}
|
||||
|
||||
public enum ProcessStatus {
|
||||
PENDING("접수 대기"),
|
||||
PROCESSING("처리 중"),
|
||||
COMPLETED("처리 완료"),
|
||||
FAILED("처리 실패");
|
||||
|
||||
private final String description;
|
||||
|
||||
ProcessStatus(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static ProductChangeResultResponse success(ProductChangeResult data) {
|
||||
return ProductChangeResultResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크 요청 DTO
|
||||
* API: POST /products/change/validation
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProductChangeValidationRequest {
|
||||
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(regexp = "^010[0-9]{8}$", message = "회선번호는 010으로 시작하는 11자리 숫자여야 합니다")
|
||||
private String lineNumber;
|
||||
|
||||
@NotBlank(message = "현재 상품 코드는 필수입니다")
|
||||
private String currentProductCode;
|
||||
|
||||
@NotBlank(message = "변경 대상 상품 코드는 필수입니다")
|
||||
private String targetProductCode;
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크 응답 DTO
|
||||
* API: POST /products/change/validation
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProductChangeValidationResponse {
|
||||
|
||||
@NotNull(message = "성공 여부는 필수입니다")
|
||||
private Boolean success;
|
||||
|
||||
@Valid
|
||||
private ValidationData data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ValidationData {
|
||||
|
||||
@NotNull(message = "검증 결과는 필수입니다")
|
||||
private ValidationResult validationResult; // SUCCESS, FAILURE
|
||||
|
||||
private List<ValidationDetail> validationDetails;
|
||||
|
||||
private String failureReason;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ValidationDetail {
|
||||
|
||||
private CheckType checkType; // PRODUCT_AVAILABLE, OPERATOR_MATCH, LINE_STATUS
|
||||
|
||||
private CheckResult result; // PASS, FAIL
|
||||
|
||||
private String message;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ValidationResult {
|
||||
SUCCESS, FAILURE
|
||||
}
|
||||
|
||||
public enum CheckType {
|
||||
PRODUCT_AVAILABLE("상품 판매 여부 확인"),
|
||||
OPERATOR_MATCH("사업자 일치 확인"),
|
||||
LINE_STATUS("회선 상태 확인");
|
||||
|
||||
private final String description;
|
||||
|
||||
CheckType(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
public enum CheckResult {
|
||||
PASS, FAIL
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static ProductChangeValidationResponse success(ValidationData data) {
|
||||
return ProductChangeValidationResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성
|
||||
*/
|
||||
public static ProductChangeValidationResponse failure(String reason, List<ValidationData.ValidationDetail> details) {
|
||||
ValidationData data = ValidationData.builder()
|
||||
.validationResult(ValidationResult.FAILURE)
|
||||
.failureReason(reason)
|
||||
.validationDetails(details)
|
||||
.build();
|
||||
|
||||
return ProductChangeValidationResponse.builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 상품 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProductInfo {
|
||||
private String productId;
|
||||
private String productName;
|
||||
private String productType;
|
||||
private String description;
|
||||
private BigDecimal price;
|
||||
private String status;
|
||||
private String category;
|
||||
private String validFrom;
|
||||
private String validTo;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 상품 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@Schema(description = "상품 정보")
|
||||
public class ProductInfoDto {
|
||||
|
||||
@Schema(description = "상품 코드", example = "PLAN001")
|
||||
private final String productCode;
|
||||
|
||||
@Schema(description = "상품명", example = "5G 프리미엄 플랜")
|
||||
private final String productName;
|
||||
|
||||
@Schema(description = "월 요금", example = "55000")
|
||||
private final BigDecimal monthlyFee;
|
||||
|
||||
@Schema(description = "데이터 제공량", example = "100GB")
|
||||
private final String dataAllowance;
|
||||
|
||||
@Schema(description = "음성 제공량", example = "무제한")
|
||||
private final String voiceAllowance;
|
||||
|
||||
@Schema(description = "SMS 제공량", example = "기본 무료")
|
||||
private final String smsAllowance;
|
||||
|
||||
@Schema(description = "변경 가능 여부", example = "true")
|
||||
private final boolean isAvailable;
|
||||
|
||||
@Schema(description = "사업자 코드", example = "MVNO001")
|
||||
private final String operatorCode;
|
||||
|
||||
@Schema(description = "상품 설명")
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* 도메인 모델에서 DTO로 변환
|
||||
*/
|
||||
public static ProductInfoDto fromDomain(Product product) {
|
||||
if (product == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProductInfoDto.builder()
|
||||
.productCode(product.getProductCode())
|
||||
.productName(product.getProductName())
|
||||
.monthlyFee(product.getMonthlyFee())
|
||||
.dataAllowance(product.getDataAllowance())
|
||||
.voiceAllowance(product.getVoiceAllowance())
|
||||
.smsAllowance(product.getSmsAllowance())
|
||||
.isAvailable(product.getStatus() == ProductStatus.ACTIVE)
|
||||
.operatorCode(product.getOperatorCode())
|
||||
.description(product.getDescription())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO에서 도메인 모델로 변환
|
||||
*/
|
||||
public Product toDomain() {
|
||||
return Product.builder()
|
||||
.productCode(this.productCode)
|
||||
.productName(this.productName)
|
||||
.monthlyFee(this.monthlyFee)
|
||||
.dataAllowance(this.dataAllowance)
|
||||
.voiceAllowance(this.voiceAllowance)
|
||||
.smsAllowance(this.smsAllowance)
|
||||
.status(this.isAvailable ? ProductStatus.ACTIVE : ProductStatus.DISCONTINUED)
|
||||
.operatorCode(this.operatorCode)
|
||||
.description(this.description)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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,22 @@
|
||||
package com.unicorn.phonebill.product.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 검증 결과 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ValidationResult {
|
||||
private boolean isValid;
|
||||
private String message;
|
||||
private List<String> errors;
|
||||
private String validationCode;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 기본 클래스
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String errorCode;
|
||||
|
||||
public BusinessException(String errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public BusinessException(String errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
/**
|
||||
* Circuit Breaker Open 상태 예외
|
||||
*/
|
||||
public class CircuitBreakerException extends BusinessException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String serviceName;
|
||||
private final String circuitBreakerState;
|
||||
|
||||
public CircuitBreakerException(String errorCode, String message, String serviceName, String circuitBreakerState) {
|
||||
super(errorCode, message);
|
||||
this.serviceName = serviceName;
|
||||
this.circuitBreakerState = circuitBreakerState;
|
||||
}
|
||||
|
||||
public String getServiceName() {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
public String getCircuitBreakerState() {
|
||||
return circuitBreakerState;
|
||||
}
|
||||
|
||||
// 자주 사용되는 Circuit Breaker 예외 팩토리 메소드들
|
||||
public static CircuitBreakerException circuitOpen(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_OPEN",
|
||||
"서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.",
|
||||
serviceName, "OPEN");
|
||||
}
|
||||
|
||||
public static CircuitBreakerException halfOpenFailed(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_HALF_OPEN_FAILED",
|
||||
"서비스 복구 시도 중 실패했습니다",
|
||||
serviceName, "HALF_OPEN");
|
||||
}
|
||||
|
||||
public static CircuitBreakerException callNotPermitted(String serviceName) {
|
||||
return new CircuitBreakerException("CIRCUIT_BREAKER_CALL_NOT_PERMITTED",
|
||||
"서비스 호출이 차단되었습니다",
|
||||
serviceName, "OPEN");
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
/**
|
||||
* KOS 연동 관련 예외
|
||||
*/
|
||||
public class KosConnectionException extends BusinessException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String serviceName;
|
||||
|
||||
public KosConnectionException(String errorCode, String message, String serviceName) {
|
||||
super(errorCode, message);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
public KosConnectionException(String errorCode, String message, String serviceName, Throwable cause) {
|
||||
super(errorCode, message, cause);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
public String getServiceName() {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
// 자주 사용되는 KOS 연동 예외 팩토리 메소드들
|
||||
public static KosConnectionException connectionTimeout(String serviceName) {
|
||||
return new KosConnectionException("KOS_CONNECTION_TIMEOUT",
|
||||
"KOS 시스템 연결 시간이 초과되었습니다", serviceName);
|
||||
}
|
||||
|
||||
public static KosConnectionException serviceUnavailable(String serviceName) {
|
||||
return new KosConnectionException("KOS_SERVICE_UNAVAILABLE",
|
||||
"KOS 시스템에 접근할 수 없습니다", serviceName);
|
||||
}
|
||||
|
||||
public static KosConnectionException invalidResponse(String serviceName, String details) {
|
||||
return new KosConnectionException("KOS_INVALID_RESPONSE",
|
||||
"KOS 시스템에서 잘못된 응답을 받았습니다: " + details, serviceName);
|
||||
}
|
||||
|
||||
public static KosConnectionException authenticationFailed(String serviceName) {
|
||||
return new KosConnectionException("KOS_AUTH_FAILED",
|
||||
"KOS 시스템 인증에 실패했습니다", serviceName);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
/**
|
||||
* 상품변경 관련 예외
|
||||
*/
|
||||
public class ProductChangeException extends BusinessException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ProductChangeException(String errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
|
||||
public ProductChangeException(String errorCode, String message, Throwable cause) {
|
||||
super(errorCode, message, cause);
|
||||
}
|
||||
|
||||
// 자주 사용되는 예외 팩토리 메소드들
|
||||
public static ProductChangeException duplicateRequest(String requestId) {
|
||||
return new ProductChangeException("DUPLICATE_REQUEST",
|
||||
"이미 처리 중인 상품변경 요청이 있습니다. RequestId: " + requestId);
|
||||
}
|
||||
|
||||
public static ProductChangeException requestNotFound(String requestId) {
|
||||
return new ProductChangeException("REQUEST_NOT_FOUND",
|
||||
"상품변경 요청을 찾을 수 없습니다. RequestId: " + requestId);
|
||||
}
|
||||
|
||||
public static ProductChangeException invalidStatus(String currentStatus, String expectedStatus) {
|
||||
return new ProductChangeException("INVALID_STATUS",
|
||||
String.format("잘못된 상태입니다. 현재: %s, 예상: %s", currentStatus, expectedStatus));
|
||||
}
|
||||
|
||||
public static ProductChangeException processingTimeout(String requestId) {
|
||||
return new ProductChangeException("PROCESSING_TIMEOUT",
|
||||
"상품변경 처리 시간이 초과되었습니다. RequestId: " + requestId);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.unicorn.phonebill.product.exception;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 상품변경 검증 실패 예외
|
||||
*/
|
||||
public class ProductValidationException extends BusinessException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private final List<String> validationDetails = new ArrayList<>();
|
||||
|
||||
public ProductValidationException(String errorCode, String message, List<String> validationDetails) {
|
||||
super(errorCode, message);
|
||||
if (validationDetails != null) {
|
||||
this.validationDetails.addAll(validationDetails);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getValidationDetails() {
|
||||
return validationDetails;
|
||||
}
|
||||
|
||||
// 자주 사용되는 검증 예외 팩토리 메소드들
|
||||
public static ProductValidationException productNotAvailable(String productCode) {
|
||||
return new ProductValidationException("PRODUCT_NOT_AVAILABLE",
|
||||
"판매 중지된 상품입니다: " + productCode,
|
||||
List.of("상품코드 " + productCode + "는 현재 판매하지 않는 상품입니다"));
|
||||
}
|
||||
|
||||
public static ProductValidationException operatorMismatch(String currentOperator, String targetOperator) {
|
||||
return new ProductValidationException("OPERATOR_MISMATCH",
|
||||
"다른 사업자 상품으로는 변경할 수 없습니다",
|
||||
List.of(String.format("현재 사업자: %s, 대상 사업자: %s", currentOperator, targetOperator)));
|
||||
}
|
||||
|
||||
public static ProductValidationException lineStatusInvalid(String lineStatus) {
|
||||
return new ProductValidationException("LINE_STATUS_INVALID",
|
||||
"회선 상태가 올바르지 않습니다: " + lineStatus,
|
||||
List.of("정상 상태의 회선만 상품 변경이 가능합니다"));
|
||||
}
|
||||
|
||||
public static ProductValidationException sameProductChange(String productCode) {
|
||||
return new ProductValidationException("SAME_PRODUCT_CHANGE",
|
||||
"동일한 상품으로는 변경할 수 없습니다",
|
||||
List.of("현재 이용 중인 상품과 동일합니다: " + productCode));
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.unicorn.phonebill.product.repository;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
||||
import com.unicorn.phonebill.product.domain.ProcessStatus;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 Repository 인터페이스
|
||||
*/
|
||||
public interface ProductChangeHistoryRepository {
|
||||
|
||||
/**
|
||||
* 상품변경 이력 저장
|
||||
*/
|
||||
ProductChangeHistory save(ProductChangeHistory history);
|
||||
|
||||
/**
|
||||
* 요청 ID로 이력 조회
|
||||
*/
|
||||
Optional<ProductChangeHistory> findByRequestId(String requestId);
|
||||
|
||||
/**
|
||||
* 회선번호로 이력 조회 (페이징)
|
||||
*/
|
||||
Page<ProductChangeHistory> findByLineNumber(String lineNumber, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 고객 ID로 이력 조회 (페이징)
|
||||
*/
|
||||
Page<ProductChangeHistory> findByCustomerId(String customerId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 처리 상태별 이력 조회 (페이징)
|
||||
*/
|
||||
Page<ProductChangeHistory> findByProcessStatus(ProcessStatus status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 기간별 이력 조회 (페이징)
|
||||
*/
|
||||
Page<ProductChangeHistory> findByPeriod(
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 회선번호와 기간으로 이력 조회 (페이징)
|
||||
*/
|
||||
Page<ProductChangeHistory> findByLineNumberAndPeriod(
|
||||
String lineNumber,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 처리 중인 요청 조회 (타임아웃 체크용)
|
||||
*/
|
||||
List<ProductChangeHistory> findProcessingRequestsOlderThan(LocalDateTime timeoutThreshold);
|
||||
|
||||
/**
|
||||
* 특정 회선번호의 최근 성공한 상품변경 이력 조회
|
||||
*/
|
||||
Optional<ProductChangeHistory> findLatestSuccessfulChangeByLineNumber(String lineNumber);
|
||||
|
||||
/**
|
||||
* 상품변경 통계 조회 (특정 기간)
|
||||
*/
|
||||
List<Object[]> getChangeStatisticsByPeriod(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* 상품 간 변경 횟수 조회
|
||||
*/
|
||||
long countSuccessfulChangesByProductCodesSince(
|
||||
String currentProductCode,
|
||||
String targetProductCode,
|
||||
LocalDateTime fromDate);
|
||||
|
||||
/**
|
||||
* 회선별 진행 중인 요청 개수 조회
|
||||
*/
|
||||
long countInProgressRequestsByLineNumber(String lineNumber);
|
||||
|
||||
/**
|
||||
* 요청 ID 존재 여부 확인
|
||||
*/
|
||||
boolean existsByRequestId(String requestId);
|
||||
|
||||
/**
|
||||
* 이력 삭제 (관리용)
|
||||
*/
|
||||
void deleteById(Long id);
|
||||
|
||||
/**
|
||||
* 전체 개수 조회
|
||||
*/
|
||||
long count();
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.unicorn.phonebill.product.repository;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
||||
import com.unicorn.phonebill.product.domain.ProcessStatus;
|
||||
import com.unicorn.phonebill.product.repository.entity.ProductChangeHistoryEntity;
|
||||
import com.unicorn.phonebill.product.repository.jpa.ProductChangeHistoryJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 Repository 구현체
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ProductChangeHistoryRepositoryImpl implements ProductChangeHistoryRepository {
|
||||
|
||||
private final ProductChangeHistoryJpaRepository jpaRepository;
|
||||
|
||||
@Override
|
||||
public ProductChangeHistory save(ProductChangeHistory history) {
|
||||
log.debug("상품변경 이력 저장: requestId={}", history.getRequestId());
|
||||
|
||||
ProductChangeHistoryEntity entity = ProductChangeHistoryEntity.fromDomain(history);
|
||||
ProductChangeHistoryEntity savedEntity = jpaRepository.save(entity);
|
||||
|
||||
log.info("상품변경 이력 저장 완료: id={}, requestId={}",
|
||||
savedEntity.getId(), savedEntity.getRequestId());
|
||||
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProductChangeHistory> findByRequestId(String requestId) {
|
||||
log.debug("요청 ID로 이력 조회: requestId={}", requestId);
|
||||
|
||||
return jpaRepository.findByRequestId(requestId)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductChangeHistory> findByLineNumber(String lineNumber, Pageable pageable) {
|
||||
log.debug("회선번호로 이력 조회: lineNumber={}, page={}, size={}",
|
||||
lineNumber, pageable.getPageNumber(), pageable.getPageSize());
|
||||
|
||||
return jpaRepository.findByLineNumberOrderByRequestedAtDesc(lineNumber, pageable)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductChangeHistory> findByCustomerId(String customerId, Pageable pageable) {
|
||||
log.debug("고객 ID로 이력 조회: customerId={}, page={}, size={}",
|
||||
customerId, pageable.getPageNumber(), pageable.getPageSize());
|
||||
|
||||
return jpaRepository.findByCustomerIdOrderByRequestedAtDesc(customerId, pageable)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductChangeHistory> findByProcessStatus(ProcessStatus status, Pageable pageable) {
|
||||
log.debug("처리 상태별 이력 조회: status={}, page={}, size={}",
|
||||
status, pageable.getPageNumber(), pageable.getPageSize());
|
||||
|
||||
return jpaRepository.findByProcessStatusOrderByRequestedAtDesc(status, pageable)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductChangeHistory> findByPeriod(
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Pageable pageable) {
|
||||
|
||||
log.debug("기간별 이력 조회: startDate={}, endDate={}, page={}, size={}",
|
||||
startDate, endDate, pageable.getPageNumber(), pageable.getPageSize());
|
||||
|
||||
return jpaRepository.findByRequestedAtBetweenOrderByRequestedAtDesc(
|
||||
startDate, endDate, pageable)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<ProductChangeHistory> findByLineNumberAndPeriod(
|
||||
String lineNumber,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Pageable pageable) {
|
||||
|
||||
log.debug("회선번호와 기간으로 이력 조회: lineNumber={}, startDate={}, endDate={}",
|
||||
lineNumber, startDate, endDate);
|
||||
|
||||
return jpaRepository.findByLineNumberAndRequestedAtBetweenOrderByRequestedAtDesc(
|
||||
lineNumber, startDate, endDate, pageable)
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductChangeHistory> findProcessingRequestsOlderThan(LocalDateTime timeoutThreshold) {
|
||||
log.debug("타임아웃 처리 중인 요청 조회: timeoutThreshold={}", timeoutThreshold);
|
||||
|
||||
return jpaRepository.findProcessingRequestsOlderThan(timeoutThreshold)
|
||||
.stream()
|
||||
.map(ProductChangeHistoryEntity::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProductChangeHistory> findLatestSuccessfulChangeByLineNumber(String lineNumber) {
|
||||
log.debug("최근 성공한 상품변경 이력 조회: lineNumber={}", lineNumber);
|
||||
|
||||
Pageable pageable = PageRequest.of(0, 1);
|
||||
Page<ProductChangeHistoryEntity> page = jpaRepository
|
||||
.findLatestSuccessfulChangeByLineNumber(lineNumber, pageable);
|
||||
|
||||
return page.getContent().stream()
|
||||
.findFirst()
|
||||
.map(ProductChangeHistoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Object[]> getChangeStatisticsByPeriod(
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate) {
|
||||
|
||||
log.debug("상품변경 통계 조회: startDate={}, endDate={}", startDate, endDate);
|
||||
|
||||
return jpaRepository.getChangeStatisticsByPeriod(startDate, endDate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countSuccessfulChangesByProductCodesSince(
|
||||
String currentProductCode,
|
||||
String targetProductCode,
|
||||
LocalDateTime fromDate) {
|
||||
|
||||
log.debug("상품 간 변경 횟수 조회: currentProductCode={}, targetProductCode={}, fromDate={}",
|
||||
currentProductCode, targetProductCode, fromDate);
|
||||
|
||||
return jpaRepository.countSuccessfulChangesByProductCodesSince(
|
||||
currentProductCode, targetProductCode, fromDate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countInProgressRequestsByLineNumber(String lineNumber) {
|
||||
log.debug("회선별 진행 중인 요청 개수 조회: lineNumber={}", lineNumber);
|
||||
|
||||
return jpaRepository.countInProgressRequestsByLineNumber(lineNumber);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByRequestId(String requestId) {
|
||||
log.debug("요청 ID 존재 여부 확인: requestId={}", requestId);
|
||||
|
||||
return jpaRepository.existsByRequestId(requestId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(Long id) {
|
||||
log.info("상품변경 이력 삭제: id={}", id);
|
||||
|
||||
jpaRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return jpaRepository.count();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.unicorn.phonebill.product.repository;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 상품 Repository 인터페이스
|
||||
* Redis 캐시를 통한 KOS 연동 데이터 관리
|
||||
*/
|
||||
public interface ProductRepository {
|
||||
|
||||
/**
|
||||
* 상품 코드로 상품 조회
|
||||
*/
|
||||
Optional<Product> findByProductCode(String productCode);
|
||||
|
||||
/**
|
||||
* 판매 중인 상품 목록 조회
|
||||
*/
|
||||
List<Product> findAvailableProducts();
|
||||
|
||||
/**
|
||||
* 사업자별 판매 중인 상품 목록 조회
|
||||
*/
|
||||
List<Product> findAvailableProductsByOperator(String operatorCode);
|
||||
|
||||
/**
|
||||
* 상품 상태별 조회
|
||||
*/
|
||||
List<Product> findByStatus(ProductStatus status);
|
||||
|
||||
/**
|
||||
* 상품 정보 캐시에 저장
|
||||
*/
|
||||
void cacheProduct(Product product);
|
||||
|
||||
/**
|
||||
* 상품 목록 캐시에 저장
|
||||
*/
|
||||
void cacheProducts(List<Product> products, String cacheKey);
|
||||
|
||||
/**
|
||||
* 상품 캐시 무효화
|
||||
*/
|
||||
void evictProductCache(String productCode);
|
||||
|
||||
/**
|
||||
* 전체 상품 캐시 무효화
|
||||
*/
|
||||
void evictAllProductsCache();
|
||||
|
||||
/**
|
||||
* 캐시 적중률 확인
|
||||
*/
|
||||
double getProductCacheHitRate();
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package com.unicorn.phonebill.product.repository;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Redis 캐시를 활용한 상품 Repository 구현체
|
||||
* KOS 시스템 연동 데이터를 캐시로 관리
|
||||
*/
|
||||
@Repository
|
||||
public class ProductRepositoryImpl implements ProductRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductRepositoryImpl.class);
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
// 캐시 키 접두사
|
||||
private static final String PRODUCT_CACHE_PREFIX = "product:";
|
||||
private static final String PRODUCTS_CACHE_PREFIX = "products:";
|
||||
private static final String AVAILABLE_PRODUCTS_KEY = "products:available";
|
||||
private static final String CACHE_STATS_KEY = "cache:product:stats";
|
||||
|
||||
// 캐시 TTL (초)
|
||||
private static final long PRODUCT_CACHE_TTL = 3600; // 1시간
|
||||
private static final long PRODUCTS_CACHE_TTL = 1800; // 30분
|
||||
|
||||
public ProductRepositoryImpl(RedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Product> findByProductCode(String productCode) {
|
||||
try {
|
||||
String cacheKey = PRODUCT_CACHE_PREFIX + productCode;
|
||||
Object cached = redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cached instanceof Product) {
|
||||
logger.debug("Cache hit for product: {}", productCode);
|
||||
incrementCacheHits();
|
||||
return Optional.of((Product) cached);
|
||||
}
|
||||
|
||||
logger.debug("Cache miss for product: {}", productCode);
|
||||
incrementCacheMisses();
|
||||
|
||||
// TODO: KOS API 호출로 실제 데이터 조회
|
||||
// 현재는 테스트 데이터 반환
|
||||
return createTestProduct(productCode);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error finding product by code: {}", productCode, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Product> findAvailableProducts() {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(AVAILABLE_PRODUCTS_KEY);
|
||||
|
||||
if (cached != null) {
|
||||
logger.debug("Cache hit for available products");
|
||||
incrementCacheHits();
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.debug("Cache miss for available products");
|
||||
incrementCacheMisses();
|
||||
|
||||
// TODO: KOS API 호출로 실제 데이터 조회
|
||||
// 현재는 테스트 데이터 반환
|
||||
List<Product> products = createTestAvailableProducts();
|
||||
cacheProducts(products, AVAILABLE_PRODUCTS_KEY);
|
||||
return products;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error finding available products", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Product> findAvailableProductsByOperator(String operatorCode) {
|
||||
try {
|
||||
String cacheKey = PRODUCTS_CACHE_PREFIX + "operator:" + operatorCode;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cached != null) {
|
||||
logger.debug("Cache hit for operator products: {}", operatorCode);
|
||||
incrementCacheHits();
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.debug("Cache miss for operator products: {}", operatorCode);
|
||||
incrementCacheMisses();
|
||||
|
||||
// TODO: KOS API 호출로 실제 데이터 조회
|
||||
// 현재는 테스트 데이터 반환
|
||||
List<Product> products = createTestProductsByOperator(operatorCode);
|
||||
cacheProducts(products, cacheKey);
|
||||
return products;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error finding products by operator: {}", operatorCode, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Product> findByStatus(ProductStatus status) {
|
||||
try {
|
||||
String cacheKey = PRODUCTS_CACHE_PREFIX + "status:" + status;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cached != null) {
|
||||
logger.debug("Cache hit for products by status: {}", status);
|
||||
incrementCacheHits();
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.debug("Cache miss for products by status: {}", status);
|
||||
incrementCacheMisses();
|
||||
|
||||
// TODO: KOS API 호출로 실제 데이터 조회
|
||||
// 현재는 테스트 데이터 반환
|
||||
List<Product> products = createTestProductsByStatus(status);
|
||||
cacheProducts(products, cacheKey);
|
||||
return products;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error finding products by status: {}", status, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cacheProduct(Product product) {
|
||||
try {
|
||||
String cacheKey = PRODUCT_CACHE_PREFIX + product.getProductCode();
|
||||
redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_CACHE_TTL, TimeUnit.SECONDS);
|
||||
logger.debug("Cached product: {}", product.getProductCode());
|
||||
} catch (Exception e) {
|
||||
logger.error("Error caching product: {}", product.getProductCode(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cacheProducts(List<Product> products, String cacheKey) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(cacheKey, products, PRODUCTS_CACHE_TTL, TimeUnit.SECONDS);
|
||||
logger.debug("Cached products list with key: {}", cacheKey);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error caching products list: {}", cacheKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evictProductCache(String productCode) {
|
||||
try {
|
||||
String cacheKey = PRODUCT_CACHE_PREFIX + productCode;
|
||||
redisTemplate.delete(cacheKey);
|
||||
logger.debug("Evicted product cache: {}", productCode);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error evicting product cache: {}", productCode, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evictAllProductsCache() {
|
||||
try {
|
||||
redisTemplate.delete(redisTemplate.keys(PRODUCT_CACHE_PREFIX + "*"));
|
||||
redisTemplate.delete(redisTemplate.keys(PRODUCTS_CACHE_PREFIX + "*"));
|
||||
logger.info("Evicted all product caches");
|
||||
} catch (Exception e) {
|
||||
logger.error("Error evicting all product caches", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getProductCacheHitRate() {
|
||||
try {
|
||||
Long hits = (Long) redisTemplate.opsForHash().get(CACHE_STATS_KEY, "hits");
|
||||
Long misses = (Long) redisTemplate.opsForHash().get(CACHE_STATS_KEY, "misses");
|
||||
|
||||
if (hits == null) hits = 0L;
|
||||
if (misses == null) misses = 0L;
|
||||
|
||||
long total = hits + misses;
|
||||
return total > 0 ? (double) hits / total : 0.0;
|
||||
} catch (Exception e) {
|
||||
logger.error("Error getting cache hit rate", e);
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementCacheHits() {
|
||||
try {
|
||||
redisTemplate.opsForHash().increment(CACHE_STATS_KEY, "hits", 1);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error incrementing cache hits", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void incrementCacheMisses() {
|
||||
try {
|
||||
redisTemplate.opsForHash().increment(CACHE_STATS_KEY, "misses", 1);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error incrementing cache misses", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 데이터 생성 메서드들 (실제 운영에서는 KOS API 호출로 대체)
|
||||
private Optional<Product> createTestProduct(String productCode) {
|
||||
Product product = Product.builder()
|
||||
.productCode(productCode)
|
||||
.productName("테스트 상품 " + productCode)
|
||||
.monthlyFee(new java.math.BigDecimal("50000"))
|
||||
.dataAllowance("50GB")
|
||||
.voiceAllowance("무제한")
|
||||
.smsAllowance("무제한")
|
||||
.status(ProductStatus.ACTIVE)
|
||||
.operatorCode("SKT")
|
||||
.description("테스트용 상품입니다.")
|
||||
.build();
|
||||
|
||||
cacheProduct(product);
|
||||
return Optional.of(product);
|
||||
}
|
||||
|
||||
private List<Product> createTestAvailableProducts() {
|
||||
return List.of(
|
||||
createTestProductInstance("LTE_50G", "LTE 50GB 요금제", "50000", "50GB"),
|
||||
createTestProductInstance("LTE_100G", "LTE 100GB 요금제", "70000", "100GB"),
|
||||
createTestProductInstance("5G_100G", "5G 100GB 요금제", "80000", "100GB")
|
||||
);
|
||||
}
|
||||
|
||||
private List<Product> createTestProductsByOperator(String operatorCode) {
|
||||
return List.of(
|
||||
createTestProductInstance("LTE_30G_" + operatorCode, operatorCode + " LTE 30GB", "45000", "30GB"),
|
||||
createTestProductInstance("5G_50G_" + operatorCode, operatorCode + " 5G 50GB", "65000", "50GB")
|
||||
);
|
||||
}
|
||||
|
||||
private List<Product> createTestProductsByStatus(ProductStatus status) {
|
||||
if (status == ProductStatus.ACTIVE) {
|
||||
return createTestAvailableProducts();
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private Product createTestProductInstance(String code, String name, String fee, String dataAllowance) {
|
||||
return Product.builder()
|
||||
.productCode(code)
|
||||
.productName(name)
|
||||
.monthlyFee(new java.math.BigDecimal(fee))
|
||||
.dataAllowance(dataAllowance)
|
||||
.voiceAllowance("무제한")
|
||||
.smsAllowance("무제한")
|
||||
.status(ProductStatus.ACTIVE)
|
||||
.operatorCode("SKT")
|
||||
.description("테스트용 상품")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.unicorn.phonebill.product.repository.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 기본 시간 정보 엔티티
|
||||
* 생성일시, 수정일시를 자동으로 관리하는 베이스 엔티티
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
@Getter
|
||||
public abstract class BaseTimeEntity {
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void onPrePersist() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void onPreUpdate() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.unicorn.phonebill.product.repository.entity;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
||||
import com.unicorn.phonebill.product.domain.ProcessStatus;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 엔티티
|
||||
* 모든 상품변경 요청 및 처리 이력을 관리
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "pc_product_change_history")
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class ProductChangeHistoryEntity extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 50)
|
||||
private String requestId;
|
||||
|
||||
@Column(name = "line_number", nullable = false, length = 20)
|
||||
private String lineNumber;
|
||||
|
||||
@Column(name = "customer_id", nullable = false, length = 50)
|
||||
private String customerId;
|
||||
|
||||
@Column(name = "current_product_code", nullable = false, length = 20)
|
||||
private String currentProductCode;
|
||||
|
||||
@Column(name = "target_product_code", nullable = false, length = 20)
|
||||
private String targetProductCode;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "process_status", nullable = false, length = 20)
|
||||
private ProcessStatus processStatus;
|
||||
|
||||
@Column(name = "validation_result", columnDefinition = "TEXT")
|
||||
private String validationResult;
|
||||
|
||||
@Column(name = "process_message", columnDefinition = "TEXT")
|
||||
private String processMessage;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "kos_request_data", columnDefinition = "jsonb")
|
||||
private Map<String, Object> kosRequestData;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "kos_response_data", columnDefinition = "jsonb")
|
||||
private Map<String, Object> kosResponseData;
|
||||
|
||||
@Column(name = "requested_at", nullable = false)
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@Column(name = "validated_at")
|
||||
private LocalDateTime validatedAt;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version = 0L;
|
||||
|
||||
@Builder
|
||||
public ProductChangeHistoryEntity(
|
||||
String requestId,
|
||||
String lineNumber,
|
||||
String customerId,
|
||||
String currentProductCode,
|
||||
String targetProductCode,
|
||||
ProcessStatus processStatus,
|
||||
String validationResult,
|
||||
String processMessage,
|
||||
Map<String, Object> kosRequestData,
|
||||
Map<String, Object> kosResponseData,
|
||||
LocalDateTime requestedAt,
|
||||
LocalDateTime validatedAt,
|
||||
LocalDateTime processedAt) {
|
||||
this.requestId = requestId;
|
||||
this.lineNumber = lineNumber;
|
||||
this.customerId = customerId;
|
||||
this.currentProductCode = currentProductCode;
|
||||
this.targetProductCode = targetProductCode;
|
||||
this.processStatus = processStatus != null ? processStatus : ProcessStatus.REQUESTED;
|
||||
this.validationResult = validationResult;
|
||||
this.processMessage = processMessage;
|
||||
this.kosRequestData = kosRequestData;
|
||||
this.kosResponseData = kosResponseData;
|
||||
this.requestedAt = requestedAt != null ? requestedAt : LocalDateTime.now();
|
||||
this.validatedAt = validatedAt;
|
||||
this.processedAt = processedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인 모델로 변환
|
||||
*/
|
||||
public ProductChangeHistory toDomain() {
|
||||
return ProductChangeHistory.builder()
|
||||
.id(this.id)
|
||||
.requestId(this.requestId)
|
||||
.lineNumber(this.lineNumber)
|
||||
.customerId(this.customerId)
|
||||
.currentProductCode(this.currentProductCode)
|
||||
.targetProductCode(this.targetProductCode)
|
||||
.processStatus(this.processStatus)
|
||||
.validationResult(this.validationResult)
|
||||
.processMessage(this.processMessage)
|
||||
.kosRequestData(this.kosRequestData)
|
||||
.kosResponseData(this.kosResponseData)
|
||||
.requestedAt(this.requestedAt)
|
||||
.validatedAt(this.validatedAt)
|
||||
.processedAt(this.processedAt)
|
||||
.version(this.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인 모델에서 엔티티로 변환
|
||||
*/
|
||||
public static ProductChangeHistoryEntity fromDomain(ProductChangeHistory domain) {
|
||||
return ProductChangeHistoryEntity.builder()
|
||||
.requestId(domain.getRequestId())
|
||||
.lineNumber(domain.getLineNumber())
|
||||
.customerId(domain.getCustomerId())
|
||||
.currentProductCode(domain.getCurrentProductCode())
|
||||
.targetProductCode(domain.getTargetProductCode())
|
||||
.processStatus(domain.getProcessStatus())
|
||||
.validationResult(domain.getValidationResult())
|
||||
.processMessage(domain.getProcessMessage())
|
||||
.kosRequestData(domain.getKosRequestData())
|
||||
.kosResponseData(domain.getKosResponseData())
|
||||
.requestedAt(domain.getRequestedAt())
|
||||
.validatedAt(domain.getValidatedAt())
|
||||
.processedAt(domain.getProcessedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 완료로 변경
|
||||
*/
|
||||
public void markAsCompleted(String message, Map<String, Object> kosResponseData) {
|
||||
this.processStatus = ProcessStatus.COMPLETED;
|
||||
this.processMessage = message;
|
||||
this.kosResponseData = kosResponseData;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 실패로 변경
|
||||
*/
|
||||
public void markAsFailed(String message) {
|
||||
this.processStatus = ProcessStatus.FAILED;
|
||||
this.processMessage = message;
|
||||
this.processedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 완료로 상태 변경
|
||||
*/
|
||||
public void markAsValidated(String validationResult) {
|
||||
this.processStatus = ProcessStatus.VALIDATED;
|
||||
this.validationResult = validationResult;
|
||||
this.validatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중으로 상태 변경
|
||||
*/
|
||||
public void markAsProcessing() {
|
||||
this.processStatus = ProcessStatus.PROCESSING;
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 요청 데이터 설정
|
||||
*/
|
||||
public void setKosRequestData(Map<String, Object> kosRequestData) {
|
||||
this.kosRequestData = kosRequestData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 메시지 업데이트
|
||||
*/
|
||||
public void updateProcessMessage(String message) {
|
||||
this.processMessage = message;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.unicorn.phonebill.product.repository.jpa;
|
||||
|
||||
import com.unicorn.phonebill.product.domain.ProcessStatus;
|
||||
import com.unicorn.phonebill.product.repository.entity.ProductChangeHistoryEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 상품변경 이력 JPA Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface ProductChangeHistoryJpaRepository extends JpaRepository<ProductChangeHistoryEntity, Long> {
|
||||
|
||||
/**
|
||||
* 요청 ID로 이력 조회
|
||||
*/
|
||||
Optional<ProductChangeHistoryEntity> findByRequestId(String requestId);
|
||||
|
||||
/**
|
||||
* 회선번호로 이력 조회 (최신순)
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"ORDER BY h.requestedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findByLineNumberOrderByRequestedAtDesc(
|
||||
@Param("lineNumber") String lineNumber,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 고객 ID로 이력 조회 (최신순)
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.customerId = :customerId " +
|
||||
"ORDER BY h.requestedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findByCustomerIdOrderByRequestedAtDesc(
|
||||
@Param("customerId") String customerId,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 처리 상태별 이력 조회
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.processStatus = :status " +
|
||||
"ORDER BY h.requestedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findByProcessStatusOrderByRequestedAtDesc(
|
||||
@Param("status") ProcessStatus status,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 기간별 이력 조회
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.requestedAt BETWEEN :startDate AND :endDate " +
|
||||
"ORDER BY h.requestedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findByRequestedAtBetweenOrderByRequestedAtDesc(
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 회선번호와 기간으로 이력 조회
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"AND h.requestedAt BETWEEN :startDate AND :endDate " +
|
||||
"ORDER BY h.requestedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findByLineNumberAndRequestedAtBetweenOrderByRequestedAtDesc(
|
||||
@Param("lineNumber") String lineNumber,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 처리 중인 요청 조회 (타임아웃 체크용)
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.processStatus IN ('PROCESSING', 'VALIDATED') " +
|
||||
"AND h.requestedAt < :timeoutThreshold " +
|
||||
"ORDER BY h.requestedAt ASC")
|
||||
List<ProductChangeHistoryEntity> findProcessingRequestsOlderThan(
|
||||
@Param("timeoutThreshold") LocalDateTime timeoutThreshold);
|
||||
|
||||
/**
|
||||
* 특정 회선번호의 최근 성공한 상품변경 이력 조회
|
||||
*/
|
||||
@Query("SELECT h FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"AND h.processStatus = 'COMPLETED' " +
|
||||
"ORDER BY h.processedAt DESC")
|
||||
Page<ProductChangeHistoryEntity> findLatestSuccessfulChangeByLineNumber(
|
||||
@Param("lineNumber") String lineNumber,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 특정 기간 동안의 상품변경 통계 조회
|
||||
*/
|
||||
@Query("SELECT h.processStatus, COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.requestedAt BETWEEN :startDate AND :endDate " +
|
||||
"GROUP BY h.processStatus")
|
||||
List<Object[]> getChangeStatisticsByPeriod(
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* 현재 상품코드에서 대상 상품코드로의 변경 횟수 조회
|
||||
*/
|
||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.currentProductCode = :currentProductCode " +
|
||||
"AND h.targetProductCode = :targetProductCode " +
|
||||
"AND h.processStatus = 'COMPLETED' " +
|
||||
"AND h.processedAt >= :fromDate")
|
||||
long countSuccessfulChangesByProductCodesSince(
|
||||
@Param("currentProductCode") String currentProductCode,
|
||||
@Param("targetProductCode") String targetProductCode,
|
||||
@Param("fromDate") LocalDateTime fromDate);
|
||||
|
||||
/**
|
||||
* 회선별 진행 중인 요청이 있는지 확인
|
||||
*/
|
||||
@Query("SELECT COUNT(h) FROM ProductChangeHistoryEntity h " +
|
||||
"WHERE h.lineNumber = :lineNumber " +
|
||||
"AND h.processStatus IN ('PROCESSING', 'VALIDATED')")
|
||||
long countInProgressRequestsByLineNumber(@Param("lineNumber") String lineNumber);
|
||||
|
||||
/**
|
||||
* 요청 ID 존재 여부 확인
|
||||
*/
|
||||
boolean existsByRequestId(String requestId);
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package com.unicorn.phonebill.product.service;
|
||||
|
||||
import com.unicorn.phonebill.product.dto.CustomerInfoResponse;
|
||||
import com.unicorn.phonebill.product.dto.ProductInfoDto;
|
||||
import com.unicorn.phonebill.product.dto.ProductChangeResultResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 상품 서비스 캐시 관리 서비스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - Redis를 활용한 성능 최적화
|
||||
* - 데이터 특성에 맞는 TTL 적용
|
||||
* - 캐시 무효화 처리
|
||||
* - 캐시 키 관리
|
||||
*/
|
||||
@Service
|
||||
public class ProductCacheService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductCacheService.class);
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
// 캐시 키 접두사
|
||||
private static final String CUSTOMER_PRODUCT_PREFIX = "customerProduct:";
|
||||
private static final String CURRENT_PRODUCT_PREFIX = "currentProduct:";
|
||||
private static final String AVAILABLE_PRODUCTS_PREFIX = "availableProducts:";
|
||||
private static final String PRODUCT_STATUS_PREFIX = "productStatus:";
|
||||
private static final String LINE_STATUS_PREFIX = "lineStatus:";
|
||||
private static final String MENU_INFO_PREFIX = "menuInfo:";
|
||||
private static final String PRODUCT_CHANGE_RESULT_PREFIX = "productChangeResult:";
|
||||
|
||||
public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
// ========== 고객상품정보 캐시 (TTL: 4시간) ==========
|
||||
|
||||
/**
|
||||
* 고객상품정보 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "customerProductInfo", key = "#lineNumber", unless = "#result == null")
|
||||
public CustomerInfoResponse.CustomerInfo getCustomerProductInfo(String lineNumber) {
|
||||
logger.debug("고객상품정보 캐시 조회: {}", lineNumber);
|
||||
return null; // 캐시 미스 시 null 반환, 실제 조회는 호출측에서 처리
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객상품정보 캐시 저장
|
||||
*/
|
||||
public void cacheCustomerProductInfo(String lineNumber, CustomerInfoResponse.CustomerInfo customerInfo) {
|
||||
if (StringUtils.hasText(lineNumber) && customerInfo != null) {
|
||||
String key = CUSTOMER_PRODUCT_PREFIX + lineNumber;
|
||||
redisTemplate.opsForValue().set(key, customerInfo, Duration.ofHours(4));
|
||||
logger.debug("고객상품정보 캐시 저장: {}", lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 현재상품정보 캐시 (TTL: 2시간) ==========
|
||||
|
||||
/**
|
||||
* 현재상품정보 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "currentProductInfo", key = "#productCode", unless = "#result == null")
|
||||
public ProductInfoDto getCurrentProductInfo(String productCode) {
|
||||
logger.debug("현재상품정보 캐시 조회: {}", productCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재상품정보 캐시 저장
|
||||
*/
|
||||
public void cacheCurrentProductInfo(String productCode, ProductInfoDto productInfo) {
|
||||
if (StringUtils.hasText(productCode) && productInfo != null) {
|
||||
String key = CURRENT_PRODUCT_PREFIX + productCode;
|
||||
redisTemplate.opsForValue().set(key, productInfo, Duration.ofHours(2));
|
||||
logger.debug("현재상품정보 캐시 저장: {}", productCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 가용상품목록 캐시 (TTL: 24시간) ==========
|
||||
|
||||
/**
|
||||
* 가용상품목록 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "availableProducts", key = "#operatorCode ?: 'all'", unless = "#result == null")
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<ProductInfoDto> getAvailableProducts(String operatorCode) {
|
||||
logger.debug("가용상품목록 캐시 조회: {}", operatorCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가용상품목록 캐시 저장
|
||||
*/
|
||||
public void cacheAvailableProducts(String operatorCode, List<ProductInfoDto> products) {
|
||||
if (products != null) {
|
||||
String key = AVAILABLE_PRODUCTS_PREFIX + (operatorCode != null ? operatorCode : "all");
|
||||
redisTemplate.opsForValue().set(key, products, Duration.ofHours(24));
|
||||
logger.debug("가용상품목록 캐시 저장: {} ({}개)", operatorCode, products.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 상품상태 캐시 (TTL: 1시간) ==========
|
||||
|
||||
/**
|
||||
* 상품상태 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "productStatus", key = "#productCode", unless = "#result == null")
|
||||
public String getProductStatus(String productCode) {
|
||||
logger.debug("상품상태 캐시 조회: {}", productCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품상태 캐시 저장
|
||||
*/
|
||||
public void cacheProductStatus(String productCode, String status) {
|
||||
if (StringUtils.hasText(productCode) && StringUtils.hasText(status)) {
|
||||
String key = PRODUCT_STATUS_PREFIX + productCode;
|
||||
redisTemplate.opsForValue().set(key, status, Duration.ofHours(1));
|
||||
logger.debug("상품상태 캐시 저장: {} = {}", productCode, status);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 회선상태 캐시 (TTL: 30분) ==========
|
||||
|
||||
/**
|
||||
* 회선상태 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "lineStatus", key = "#lineNumber", unless = "#result == null")
|
||||
public String getLineStatus(String lineNumber) {
|
||||
logger.debug("회선상태 캐시 조회: {}", lineNumber);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회선상태 캐시 저장
|
||||
*/
|
||||
public void cacheLineStatus(String lineNumber, String status) {
|
||||
if (StringUtils.hasText(lineNumber) && StringUtils.hasText(status)) {
|
||||
String key = LINE_STATUS_PREFIX + lineNumber;
|
||||
redisTemplate.opsForValue().set(key, status, Duration.ofMinutes(30));
|
||||
logger.debug("회선상태 캐시 저장: {} = {}", lineNumber, status);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 메뉴정보 캐시 (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시간) ==========
|
||||
|
||||
/**
|
||||
* 상품변경결과 캐시 조회
|
||||
*/
|
||||
@Cacheable(value = "productChangeResult", key = "#requestId", unless = "#result == null")
|
||||
public ProductChangeResultResponse.ProductChangeResult getProductChangeResult(String requestId) {
|
||||
logger.debug("상품변경결과 캐시 조회: {}", requestId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경결과 캐시 저장
|
||||
*/
|
||||
public void cacheProductChangeResult(String requestId, ProductChangeResultResponse.ProductChangeResult result) {
|
||||
if (StringUtils.hasText(requestId) && result != null) {
|
||||
String key = PRODUCT_CHANGE_RESULT_PREFIX + requestId;
|
||||
redisTemplate.opsForValue().set(key, result, Duration.ofHours(1));
|
||||
logger.debug("상품변경결과 캐시 저장: {}", requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 캐시 무효화 ==========
|
||||
|
||||
/**
|
||||
* 고객 관련 모든 캐시 무효화
|
||||
*/
|
||||
public void evictCustomerCaches(String lineNumber, String customerId) {
|
||||
evictCustomerProductInfo(lineNumber);
|
||||
evictLineStatus(lineNumber);
|
||||
if (StringUtils.hasText(customerId)) {
|
||||
evictMenuInfo(customerId);
|
||||
}
|
||||
logger.info("고객 관련 캐시 무효화 완료: lineNumber={}, customerId={}", lineNumber, customerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 관련 모든 캐시 무효화
|
||||
*/
|
||||
public void evictProductCaches(String productCode, String operatorCode) {
|
||||
evictCurrentProductInfo(productCode);
|
||||
evictProductStatus(productCode);
|
||||
evictAvailableProducts(operatorCode);
|
||||
logger.info("상품 관련 캐시 무효화 완료: productCode={}, operatorCode={}", productCode, operatorCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 완료 후 관련 캐시 무효화
|
||||
*/
|
||||
public void evictProductChangeCaches(String lineNumber, String customerId, String oldProductCode, String newProductCode) {
|
||||
// 고객 정보 관련 캐시 무효화
|
||||
evictCustomerCaches(lineNumber, customerId);
|
||||
|
||||
// 변경 전후 상품 캐시 무효화
|
||||
if (StringUtils.hasText(oldProductCode)) {
|
||||
evictCurrentProductInfo(oldProductCode);
|
||||
evictProductStatus(oldProductCode);
|
||||
}
|
||||
if (StringUtils.hasText(newProductCode)) {
|
||||
evictCurrentProductInfo(newProductCode);
|
||||
evictProductStatus(newProductCode);
|
||||
}
|
||||
|
||||
logger.info("상품변경 관련 캐시 무효화 완료: lineNumber={}, oldProduct={}, newProduct={}",
|
||||
lineNumber, oldProductCode, newProductCode);
|
||||
}
|
||||
|
||||
// ========== 개별 캐시 무효화 메서드들 ==========
|
||||
|
||||
@CacheEvict(value = "customerProductInfo", key = "#lineNumber")
|
||||
public void evictCustomerProductInfo(String lineNumber) {
|
||||
logger.debug("고객상품정보 캐시 무효화: {}", lineNumber);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "currentProductInfo", key = "#productCode")
|
||||
public void evictCurrentProductInfo(String productCode) {
|
||||
logger.debug("현재상품정보 캐시 무효화: {}", productCode);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "availableProducts", key = "#operatorCode ?: 'all'")
|
||||
public void evictAvailableProducts(String operatorCode) {
|
||||
logger.debug("가용상품목록 캐시 무효화: {}", operatorCode);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "productStatus", key = "#productCode")
|
||||
public void evictProductStatus(String productCode) {
|
||||
logger.debug("상품상태 캐시 무효화: {}", productCode);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "lineStatus", key = "#lineNumber")
|
||||
public void evictLineStatus(String lineNumber) {
|
||||
logger.debug("회선상태 캐시 무효화: {}", lineNumber);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "menuInfo", key = "#userId")
|
||||
public void evictMenuInfo(String userId) {
|
||||
logger.debug("메뉴정보 캐시 무효화: {}", userId);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "productChangeResult", key = "#requestId")
|
||||
public void evictProductChangeResult(String requestId) {
|
||||
logger.debug("상품변경결과 캐시 무효화: {}", requestId);
|
||||
}
|
||||
|
||||
// ========== 캐시 통계 및 모니터링 ==========
|
||||
|
||||
/**
|
||||
* 캐시 히트율 통계 (모니터링용)
|
||||
*/
|
||||
public void logCacheStatistics() {
|
||||
logger.info("Redis 캐시 통계 정보 로깅 (구현 필요)");
|
||||
// 실제 구현 시 Redis INFO 명령어 또는 Micrometer 메트릭 활용
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 패턴의 캐시 키 개수 조회
|
||||
*/
|
||||
public long getCacheKeyCount(String pattern) {
|
||||
try {
|
||||
return redisTemplate.keys(pattern).size();
|
||||
} catch (Exception e) {
|
||||
logger.warn("캐시 키 개수 조회 실패: {}", pattern, e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.unicorn.phonebill.product.service;
|
||||
|
||||
import com.unicorn.phonebill.product.dto.*;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 상품 관리 서비스 인터페이스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 메뉴 조회
|
||||
* - 고객 및 상품 정보 조회
|
||||
* - 상품변경 처리
|
||||
* - 상품변경 이력 관리
|
||||
*/
|
||||
public interface ProductService {
|
||||
|
||||
/**
|
||||
* 상품변경 메뉴 조회
|
||||
* UFR-PROD-010 구현
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @return 메뉴 응답
|
||||
*/
|
||||
ProductMenuResponse getProductMenu(String userId);
|
||||
|
||||
/**
|
||||
* 고객 정보 조회
|
||||
* UFR-PROD-020 구현
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @return 고객 정보 응답
|
||||
*/
|
||||
CustomerInfoResponse getCustomerInfo(String lineNumber);
|
||||
|
||||
/**
|
||||
* 변경 가능한 상품 목록 조회
|
||||
* UFR-PROD-020 구현
|
||||
*
|
||||
* @param currentProductCode 현재 상품코드 (필터링용)
|
||||
* @param operatorCode 사업자 코드 (필터링용)
|
||||
* @return 가용 상품 목록 응답
|
||||
*/
|
||||
AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode);
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크
|
||||
* UFR-PROD-030 구현
|
||||
*
|
||||
* @param request 상품변경 검증 요청
|
||||
* @return 검증 결과 응답
|
||||
*/
|
||||
ProductChangeValidationResponse validateProductChange(ProductChangeValidationRequest request);
|
||||
|
||||
/**
|
||||
* 상품변경 요청 처리
|
||||
* UFR-PROD-040 구현
|
||||
*
|
||||
* @param request 상품변경 요청
|
||||
* @param userId 요청 사용자 ID
|
||||
* @return 상품변경 처리 응답 (동기 처리 시)
|
||||
*/
|
||||
ProductChangeResponse requestProductChange(ProductChangeRequest request, String userId);
|
||||
|
||||
/**
|
||||
* 상품변경 비동기 요청 처리
|
||||
* UFR-PROD-040 구현
|
||||
*
|
||||
* @param request 상품변경 요청
|
||||
* @param userId 요청 사용자 ID
|
||||
* @return 상품변경 비동기 응답 (접수 완료 시)
|
||||
*/
|
||||
ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId);
|
||||
|
||||
/**
|
||||
* 상품변경 결과 조회
|
||||
*
|
||||
* @param requestId 상품변경 요청 ID
|
||||
* @return 상품변경 결과 응답
|
||||
*/
|
||||
ProductChangeResultResponse getProductChangeResult(String requestId);
|
||||
|
||||
/**
|
||||
* 상품변경 이력 조회
|
||||
* UFR-PROD-040 구현 (이력 관리)
|
||||
*
|
||||
* @param lineNumber 회선번호 (선택)
|
||||
* @param startDate 조회 시작일 (선택)
|
||||
* @param endDate 조회 종료일 (선택)
|
||||
* @param pageable 페이징 정보
|
||||
* @return 상품변경 이력 응답
|
||||
*/
|
||||
ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable);
|
||||
}
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
package com.unicorn.phonebill.product.service;
|
||||
|
||||
import com.unicorn.phonebill.product.dto.*;
|
||||
import com.unicorn.phonebill.product.domain.Product;
|
||||
import com.unicorn.phonebill.product.domain.ProductChangeHistory;
|
||||
import com.unicorn.phonebill.product.repository.ProductRepository;
|
||||
import com.unicorn.phonebill.product.repository.ProductChangeHistoryRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 상품 관리 서비스 구현체
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 전체 프로세스 관리
|
||||
* - KOS 시스템 연동 조율
|
||||
* - 캐시 전략 적용
|
||||
* - 트랜잭션 관리
|
||||
*/
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class ProductServiceImpl implements ProductService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductServiceImpl.class);
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductChangeHistoryRepository historyRepository;
|
||||
private final ProductValidationService validationService;
|
||||
private final ProductCacheService cacheService;
|
||||
// TODO: KOS 연동 서비스 추가 예정
|
||||
// private final KosClientService kosClientService;
|
||||
|
||||
public ProductServiceImpl(ProductRepository productRepository,
|
||||
ProductChangeHistoryRepository historyRepository,
|
||||
ProductValidationService validationService,
|
||||
ProductCacheService cacheService) {
|
||||
this.productRepository = productRepository;
|
||||
this.historyRepository = historyRepository;
|
||||
this.validationService = validationService;
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductMenuResponse getProductMenu(String userId) {
|
||||
logger.info("상품변경 메뉴 조회: userId={}", userId);
|
||||
|
||||
try {
|
||||
// 캐시에서 메뉴 정보 조회
|
||||
Object cachedMenu = cacheService.getMenuInfo(userId);
|
||||
if (cachedMenu instanceof ProductMenuResponse) {
|
||||
logger.debug("메뉴 정보 캐시 히트: userId={}", userId);
|
||||
return (ProductMenuResponse) cachedMenu;
|
||||
}
|
||||
|
||||
// 메뉴 정보 생성 (실제로는 사용자 권한에 따라 동적 생성)
|
||||
ProductMenuResponse.MenuData menuData = createMenuData(userId);
|
||||
ProductMenuResponse response = ProductMenuResponse.builder()
|
||||
.success(true)
|
||||
.data(menuData)
|
||||
.build();
|
||||
|
||||
// 캐시에 저장
|
||||
cacheService.cacheMenuInfo(userId, response);
|
||||
|
||||
logger.info("상품변경 메뉴 조회 완료: userId={}", userId);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 메뉴 조회 중 오류: userId={}", userId, e);
|
||||
throw new RuntimeException("메뉴 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CustomerInfoResponse getCustomerInfo(String lineNumber) {
|
||||
logger.info("고객 정보 조회: lineNumber={}", lineNumber);
|
||||
|
||||
try {
|
||||
// 캐시에서 고객 정보 조회
|
||||
CustomerInfoResponse.CustomerInfo cachedCustomerInfo = cacheService.getCustomerProductInfo(lineNumber);
|
||||
if (cachedCustomerInfo != null) {
|
||||
logger.debug("고객 정보 캐시 히트: lineNumber={}", lineNumber);
|
||||
return CustomerInfoResponse.success(cachedCustomerInfo);
|
||||
}
|
||||
|
||||
// 캐시 미스 시 실제 조회 (TODO: KOS 연동)
|
||||
CustomerInfoResponse.CustomerInfo customerInfo = getCustomerInfoFromDataSource(lineNumber);
|
||||
if (customerInfo == null) {
|
||||
throw new RuntimeException("고객 정보를 찾을 수 없습니다: " + lineNumber);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
cacheService.cacheCustomerProductInfo(lineNumber, customerInfo);
|
||||
|
||||
logger.info("고객 정보 조회 완료: lineNumber={}, customerId={}",
|
||||
lineNumber, customerInfo.getCustomerId());
|
||||
return CustomerInfoResponse.success(customerInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("고객 정보 조회 중 오류: lineNumber={}", lineNumber, e);
|
||||
throw new RuntimeException("고객 정보 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AvailableProductsResponse getAvailableProducts(String currentProductCode, String operatorCode) {
|
||||
logger.info("가용 상품 목록 조회: currentProductCode={}, operatorCode={}", currentProductCode, operatorCode);
|
||||
|
||||
try {
|
||||
// 캐시에서 상품 목록 조회
|
||||
List<ProductInfoDto> cachedProducts = cacheService.getAvailableProducts(operatorCode);
|
||||
if (cachedProducts != null && !cachedProducts.isEmpty()) {
|
||||
logger.debug("상품 목록 캐시 히트: operatorCode={}, count={}", operatorCode, cachedProducts.size());
|
||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(cachedProducts, currentProductCode);
|
||||
return AvailableProductsResponse.success(filteredProducts);
|
||||
}
|
||||
|
||||
// 캐시 미스 시 실제 조회
|
||||
List<Product> products = productRepository.findAvailableProductsByOperator(operatorCode);
|
||||
List<ProductInfoDto> productDtos = products.stream()
|
||||
.map(this::convertToDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 캐시에 저장
|
||||
cacheService.cacheAvailableProducts(operatorCode, productDtos);
|
||||
|
||||
// 현재 상품 기준 필터링
|
||||
List<ProductInfoDto> filteredProducts = filterProductsByCurrentProduct(productDtos, currentProductCode);
|
||||
|
||||
logger.info("가용 상품 목록 조회 완료: operatorCode={}, totalCount={}, filteredCount={}",
|
||||
operatorCode, productDtos.size(), filteredProducts.size());
|
||||
return AvailableProductsResponse.success(filteredProducts);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("가용 상품 목록 조회 중 오류: operatorCode={}", operatorCode, e);
|
||||
throw new RuntimeException("상품 목록 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductChangeValidationResponse validateProductChange(ProductChangeValidationRequest request) {
|
||||
logger.info("상품변경 사전체크: lineNumber={}, current={}, target={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(), request.getTargetProductCode());
|
||||
|
||||
return validationService.validateProductChange(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ProductChangeResponse requestProductChange(ProductChangeRequest request, String userId) {
|
||||
logger.info("상품변경 동기 처리 요청: lineNumber={}, current={}, target={}, userId={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), userId);
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
try {
|
||||
// 1. 사전체크 재실행
|
||||
ProductChangeValidationRequest validationRequest = ProductChangeValidationRequest.builder()
|
||||
.lineNumber(request.getLineNumber())
|
||||
.currentProductCode(request.getCurrentProductCode())
|
||||
.targetProductCode(request.getTargetProductCode())
|
||||
.build();
|
||||
|
||||
ProductChangeValidationResponse validationResponse = validationService.validateProductChange(validationRequest);
|
||||
if (validationResponse.getData().getValidationResult() == ProductChangeValidationResponse.ValidationResult.FAILURE) {
|
||||
throw new RuntimeException("사전체크 실패: " + validationResponse.getData().getFailureReason());
|
||||
}
|
||||
|
||||
// 2. 이력 저장 (진행중 상태)
|
||||
ProductChangeHistory history = createProductChangeHistory(requestId, request, userId);
|
||||
history.markAsProcessing();
|
||||
historyRepository.save(history);
|
||||
|
||||
// 3. KOS 연동 처리 (TODO: 실제 KOS 연동 구현)
|
||||
ProductChangeResult changeResult = processProductChangeWithKos(request, requestId);
|
||||
|
||||
// 4. 처리 결과에 따른 이력 업데이트
|
||||
if (changeResult.isSuccess()) {
|
||||
// KOS 응답 데이터를 Map으로 변환
|
||||
Map<String, Object> kosResponseData = Map.of(
|
||||
"resultCode", changeResult.getResultCode(),
|
||||
"resultMessage", changeResult.getResultMessage(),
|
||||
"processedAt", LocalDateTime.now().toString()
|
||||
);
|
||||
history = history.markAsCompleted(changeResult.getResultMessage(), kosResponseData);
|
||||
|
||||
// 캐시 무효화
|
||||
cacheService.evictProductChangeCaches(
|
||||
request.getLineNumber(),
|
||||
userId, // customerId 대신 사용
|
||||
request.getCurrentProductCode(),
|
||||
request.getTargetProductCode()
|
||||
);
|
||||
} else {
|
||||
history = history.markAsFailed(changeResult.getResultCode(), changeResult.getFailureReason());
|
||||
}
|
||||
|
||||
historyRepository.save(history);
|
||||
|
||||
// 5. 응답 생성
|
||||
if (changeResult.isSuccess()) {
|
||||
ProductInfoDto changedProduct = getProductInfo(request.getTargetProductCode());
|
||||
logger.info("상품변경 동기 처리 완료: requestId={}, result=SUCCESS", requestId);
|
||||
return ProductChangeResponse.success(requestId, changeResult.getResultCode(),
|
||||
changeResult.getResultMessage(), changedProduct);
|
||||
} else {
|
||||
logger.error("상품변경 동기 처리 실패: requestId={}, reason={}", requestId, changeResult.getFailureReason());
|
||||
throw new RuntimeException("상품변경 처리 실패: " + changeResult.getFailureReason());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 동기 처리 중 오류: requestId={}", requestId, e);
|
||||
|
||||
// 실패 이력 저장
|
||||
try {
|
||||
Optional<ProductChangeHistory> historyOpt = historyRepository.findByRequestId(requestId);
|
||||
if (historyOpt.isPresent()) {
|
||||
ProductChangeHistory history = historyOpt.get();
|
||||
history = history.markAsFailed("SYSTEM_ERROR", e.getMessage());
|
||||
historyRepository.save(history);
|
||||
}
|
||||
} catch (Exception historyError) {
|
||||
logger.error("실패 이력 저장 중 오류: requestId={}", requestId, historyError);
|
||||
}
|
||||
|
||||
throw new RuntimeException("상품변경 처리 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ProductChangeAsyncResponse requestProductChangeAsync(ProductChangeRequest request, String userId) {
|
||||
logger.info("상품변경 비동기 처리 요청: lineNumber={}, current={}, target={}, userId={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), userId);
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
try {
|
||||
// 1. 사전체크 재실행
|
||||
ProductChangeValidationRequest validationRequest = ProductChangeValidationRequest.builder()
|
||||
.lineNumber(request.getLineNumber())
|
||||
.currentProductCode(request.getCurrentProductCode())
|
||||
.targetProductCode(request.getTargetProductCode())
|
||||
.build();
|
||||
|
||||
ProductChangeValidationResponse validationResponse = validationService.validateProductChange(validationRequest);
|
||||
if (validationResponse.getData().getValidationResult() == ProductChangeValidationResponse.ValidationResult.FAILURE) {
|
||||
throw new RuntimeException("사전체크 실패: " + validationResponse.getData().getFailureReason());
|
||||
}
|
||||
|
||||
// 2. 이력 저장 (접수 대기 상태)
|
||||
ProductChangeHistory history = createProductChangeHistory(requestId, request, userId);
|
||||
historyRepository.save(history);
|
||||
|
||||
// 3. 비동기 처리 큐에 등록 (TODO: 메시지 큐 연동)
|
||||
// messageQueueService.sendProductChangeRequest(request, requestId, userId);
|
||||
|
||||
logger.info("상품변경 비동기 처리 접수 완료: requestId={}", requestId);
|
||||
return ProductChangeAsyncResponse.accepted(requestId, "상품 변경 요청이 접수되었습니다");
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 비동기 처리 접수 중 오류: requestId={}", requestId, e);
|
||||
throw new RuntimeException("상품변경 요청 접수 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductChangeResultResponse getProductChangeResult(String requestId) {
|
||||
logger.info("상품변경 결과 조회: requestId={}", requestId);
|
||||
|
||||
try {
|
||||
// 캐시에서 결과 조회
|
||||
ProductChangeResultResponse.ProductChangeResult cachedResult = cacheService.getProductChangeResult(requestId);
|
||||
if (cachedResult != null) {
|
||||
logger.debug("상품변경 결과 캐시 히트: requestId={}", requestId);
|
||||
return ProductChangeResultResponse.success(cachedResult);
|
||||
}
|
||||
|
||||
// 캐시 미스 시 DB에서 조회
|
||||
Optional<ProductChangeHistory> historyOpt = historyRepository.findByRequestId(requestId);
|
||||
if (!historyOpt.isPresent()) {
|
||||
throw new RuntimeException("요청 정보를 찾을 수 없습니다: " + requestId);
|
||||
}
|
||||
|
||||
ProductChangeHistory history = historyOpt.get();
|
||||
|
||||
ProductChangeResultResponse.ProductChangeResult result = convertToResultDto(history);
|
||||
|
||||
// 완료된 결과만 캐시에 저장
|
||||
if (history.getProcessStatus().equals("COMPLETED") || history.getProcessStatus().equals("FAILED")) {
|
||||
cacheService.cacheProductChangeResult(requestId, result);
|
||||
}
|
||||
|
||||
logger.info("상품변경 결과 조회 완료: requestId={}, status={}", requestId, history.getProcessStatus());
|
||||
return ProductChangeResultResponse.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 결과 조회 중 오류: requestId={}", requestId, e);
|
||||
throw new RuntimeException("상품변경 결과 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductChangeHistoryResponse getProductChangeHistory(String lineNumber, String startDate, String endDate, Pageable pageable) {
|
||||
logger.info("상품변경 이력 조회: lineNumber={}, startDate={}, endDate={}, page={}",
|
||||
lineNumber, startDate, endDate, pageable.getPageNumber());
|
||||
|
||||
try {
|
||||
LocalDate start = StringUtils.hasText(startDate) ? LocalDate.parse(startDate) : null;
|
||||
LocalDate end = StringUtils.hasText(endDate) ? LocalDate.parse(endDate) : null;
|
||||
|
||||
Page<ProductChangeHistory> historyPage;
|
||||
if (start != null && end != null) {
|
||||
LocalDateTime startDateTime = start.atStartOfDay();
|
||||
LocalDateTime endDateTime = end.atTime(23, 59, 59);
|
||||
historyPage = historyRepository.findByLineNumberAndPeriod(lineNumber, startDateTime, endDateTime, pageable);
|
||||
} else if (StringUtils.hasText(lineNumber)) {
|
||||
historyPage = historyRepository.findByLineNumber(lineNumber, pageable);
|
||||
} else {
|
||||
// 전체 이력 조회
|
||||
historyPage = historyRepository.findByPeriod(
|
||||
start != null ? start.atStartOfDay() : LocalDateTime.now().minusMonths(1),
|
||||
end != null ? end.atTime(23, 59, 59) : LocalDateTime.now(),
|
||||
pageable
|
||||
);
|
||||
}
|
||||
|
||||
List<ProductChangeHistoryResponse.ProductChangeHistoryItem> historyItems = historyPage.getContent().stream()
|
||||
.map(this::convertToHistoryItem)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
ProductChangeHistoryResponse.PaginationInfo paginationInfo = ProductChangeHistoryResponse.PaginationInfo.builder()
|
||||
.page(pageable.getPageNumber() + 1) // 0-based to 1-based
|
||||
.size(pageable.getPageSize())
|
||||
.totalElements(historyPage.getTotalElements())
|
||||
.totalPages(historyPage.getTotalPages())
|
||||
.hasNext(historyPage.hasNext())
|
||||
.hasPrevious(historyPage.hasPrevious())
|
||||
.build();
|
||||
|
||||
logger.info("상품변경 이력 조회 완료: lineNumber={}, totalElements={}", lineNumber, historyPage.getTotalElements());
|
||||
return ProductChangeHistoryResponse.success(historyItems, paginationInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 이력 조회 중 오류: lineNumber={}", lineNumber, e);
|
||||
throw new RuntimeException("상품변경 이력 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터소스에서 고객 정보 조회
|
||||
*/
|
||||
private CustomerInfoResponse.CustomerInfo getCustomerInfoFromDataSource(String lineNumber) {
|
||||
// TODO: 실제 KOS 연동 또는 DB 조회 구현
|
||||
// 현재는 임시 데이터 반환
|
||||
ProductInfoDto currentProduct = ProductInfoDto.builder()
|
||||
.productCode("PLAN001")
|
||||
.productName("5G 베이직 플랜")
|
||||
.monthlyFee(new java.math.BigDecimal("45000"))
|
||||
.dataAllowance("50GB")
|
||||
.voiceAllowance("무제한")
|
||||
.smsAllowance("기본 무료")
|
||||
.isAvailable(true)
|
||||
.operatorCode("MVNO001")
|
||||
.build();
|
||||
|
||||
return CustomerInfoResponse.CustomerInfo.builder()
|
||||
.customerId("CUST001")
|
||||
.lineNumber(lineNumber)
|
||||
.customerName("홍길동")
|
||||
.currentProduct(currentProduct)
|
||||
.lineStatus("ACTIVE")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 상품 기준 필터링
|
||||
*/
|
||||
private List<ProductInfoDto> filterProductsByCurrentProduct(List<ProductInfoDto> products, String currentProductCode) {
|
||||
if (!StringUtils.hasText(currentProductCode)) {
|
||||
return products;
|
||||
}
|
||||
|
||||
return products.stream()
|
||||
.filter(product -> !product.getProductCode().equals(currentProductCode))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain을 DTO로 변환
|
||||
*/
|
||||
private ProductInfoDto convertToDto(Product product) {
|
||||
return ProductInfoDto.builder()
|
||||
.productCode(product.getProductCode())
|
||||
.productName(product.getProductName())
|
||||
.monthlyFee(product.getMonthlyFee())
|
||||
.dataAllowance(product.getDataAllowance())
|
||||
.voiceAllowance(product.getVoiceAllowance())
|
||||
.smsAllowance(product.getSmsAllowance())
|
||||
.isAvailable(product.canChangeTo(null)) // 변경 가능 여부
|
||||
.operatorCode(product.getOperatorCode())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 이력 객체 생성
|
||||
*/
|
||||
private ProductChangeHistory createProductChangeHistory(String requestId, ProductChangeRequest request, String userId) {
|
||||
return ProductChangeHistory.createNew(
|
||||
requestId,
|
||||
request.getLineNumber(),
|
||||
userId, // customerId로 사용
|
||||
request.getCurrentProductCode(),
|
||||
request.getTargetProductCode()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 연동 상품변경 처리 (임시 구현)
|
||||
*/
|
||||
private ProductChangeResult processProductChangeWithKos(ProductChangeRequest request, String requestId) {
|
||||
// TODO: 실제 KOS 연동 구현
|
||||
// 현재는 임시 성공 결과 반환
|
||||
try {
|
||||
Thread.sleep(100); // 처리 시간 시뮬레이션
|
||||
return ProductChangeResult.builder()
|
||||
.success(true)
|
||||
.resultCode("SUCCESS")
|
||||
.resultMessage("상품 변경이 완료되었습니다")
|
||||
.build();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return ProductChangeResult.builder()
|
||||
.success(false)
|
||||
.resultCode("SYSTEM_ERROR")
|
||||
.failureReason("처리 중 시스템 오류 발생")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 정보 조회
|
||||
*/
|
||||
private ProductInfoDto getProductInfo(String productCode) {
|
||||
ProductInfoDto cached = cacheService.getCurrentProductInfo(productCode);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
Optional<Product> productOpt = productRepository.findByProductCode(productCode);
|
||||
if (productOpt.isPresent()) {
|
||||
Product product = productOpt.get();
|
||||
ProductInfoDto dto = convertToDto(product);
|
||||
cacheService.cacheCurrentProductInfo(productCode, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductChangeHistory를 ProductChangeResult DTO로 변환
|
||||
*/
|
||||
private ProductChangeResultResponse.ProductChangeResult convertToResultDto(ProductChangeHistory history) {
|
||||
return ProductChangeResultResponse.ProductChangeResult.builder()
|
||||
.requestId(history.getRequestId())
|
||||
.lineNumber(history.getLineNumber())
|
||||
.processStatus(ProductChangeResultResponse.ProcessStatus.valueOf(history.getProcessStatus().name()))
|
||||
.currentProductCode(history.getCurrentProductCode())
|
||||
.targetProductCode(history.getTargetProductCode())
|
||||
.requestedAt(history.getRequestedAt())
|
||||
.processedAt(history.getProcessedAt())
|
||||
.resultCode(history.getResultCode())
|
||||
.resultMessage(history.getResultMessage())
|
||||
.failureReason(history.getFailureReason())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductChangeHistory를 HistoryItem DTO로 변환
|
||||
*/
|
||||
private ProductChangeHistoryResponse.ProductChangeHistoryItem convertToHistoryItem(ProductChangeHistory history) {
|
||||
return ProductChangeHistoryResponse.ProductChangeHistoryItem.builder()
|
||||
.requestId(history.getRequestId())
|
||||
.lineNumber(history.getLineNumber())
|
||||
.processStatus(history.getProcessStatus().name())
|
||||
.currentProductCode(history.getCurrentProductCode())
|
||||
.currentProductName("현재상품명") // TODO: 상품명 조회 로직 추가
|
||||
.targetProductCode(history.getTargetProductCode())
|
||||
.targetProductName("변경상품명") // TODO: 상품명 조회 로직 추가
|
||||
.requestedAt(history.getRequestedAt())
|
||||
.processedAt(history.getProcessedAt())
|
||||
.resultMessage(history.getResultMessage())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package com.unicorn.phonebill.product.service;
|
||||
|
||||
import com.unicorn.phonebill.product.dto.ProductChangeValidationRequest;
|
||||
import com.unicorn.phonebill.product.dto.ProductChangeValidationResponse;
|
||||
import com.unicorn.phonebill.product.dto.ProductInfoDto;
|
||||
import com.unicorn.phonebill.product.repository.ProductRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 상품변경 검증 서비스
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 상품변경 사전체크 로직
|
||||
* - 판매중인 상품 확인
|
||||
* - 사업자 일치 확인
|
||||
* - 회선 사용상태 확인
|
||||
* - 검증 결과 상세 정보 제공
|
||||
*/
|
||||
@Service
|
||||
public class ProductValidationService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProductValidationService.class);
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductCacheService productCacheService;
|
||||
|
||||
public ProductValidationService(ProductRepository productRepository,
|
||||
ProductCacheService productCacheService) {
|
||||
this.productRepository = productRepository;
|
||||
this.productCacheService = productCacheService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 사전체크 실행
|
||||
*
|
||||
* @param request 상품변경 검증 요청
|
||||
* @return 검증 결과
|
||||
*/
|
||||
public ProductChangeValidationResponse validateProductChange(ProductChangeValidationRequest request) {
|
||||
logger.info("상품변경 사전체크 시작: lineNumber={}, current={}, target={}",
|
||||
request.getLineNumber(), request.getCurrentProductCode(), request.getTargetProductCode());
|
||||
|
||||
List<ProductChangeValidationResponse.ValidationData.ValidationDetail> validationDetails = new ArrayList<>();
|
||||
boolean overallSuccess = true;
|
||||
StringBuilder failureReasonBuilder = new StringBuilder();
|
||||
|
||||
try {
|
||||
// 1. 대상 상품 판매 여부 확인
|
||||
boolean isProductAvailable = validateProductAvailability(request.getTargetProductCode(), validationDetails);
|
||||
if (!isProductAvailable) {
|
||||
overallSuccess = false;
|
||||
failureReasonBuilder.append("변경 대상 상품이 판매중이 아닙니다. ");
|
||||
}
|
||||
|
||||
// 2. 사업자 일치 확인
|
||||
boolean isOperatorMatch = validateOperatorMatch(request.getCurrentProductCode(),
|
||||
request.getTargetProductCode(), validationDetails);
|
||||
if (!isOperatorMatch) {
|
||||
overallSuccess = false;
|
||||
failureReasonBuilder.append("현재 상품과 변경 대상 상품의 사업자가 일치하지 않습니다. ");
|
||||
}
|
||||
|
||||
// 3. 회선 상태 확인
|
||||
boolean isLineStatusValid = validateLineStatus(request.getLineNumber(), validationDetails);
|
||||
if (!isLineStatusValid) {
|
||||
overallSuccess = false;
|
||||
failureReasonBuilder.append("회선 상태가 상품변경이 불가능한 상태입니다. ");
|
||||
}
|
||||
|
||||
// 검증 결과 생성
|
||||
ProductChangeValidationResponse.ValidationData validationData =
|
||||
ProductChangeValidationResponse.ValidationData.builder()
|
||||
.validationResult(overallSuccess ?
|
||||
ProductChangeValidationResponse.ValidationResult.SUCCESS :
|
||||
ProductChangeValidationResponse.ValidationResult.FAILURE)
|
||||
.validationDetails(validationDetails)
|
||||
.failureReason(overallSuccess ? null : failureReasonBuilder.toString().trim())
|
||||
.build();
|
||||
|
||||
logger.info("상품변경 사전체크 완료: lineNumber={}, result={}",
|
||||
request.getLineNumber(), overallSuccess ? "SUCCESS" : "FAILURE");
|
||||
|
||||
return ProductChangeValidationResponse.success(validationData);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품변경 사전체크 중 오류 발생: lineNumber={}", request.getLineNumber(), e);
|
||||
|
||||
// 오류 발생 시 실패 처리
|
||||
List<ProductChangeValidationResponse.ValidationData.ValidationDetail> errorDetails = new ArrayList<>();
|
||||
errorDetails.add(ProductChangeValidationResponse.ValidationData.ValidationDetail.builder()
|
||||
.checkType(ProductChangeValidationResponse.CheckType.PRODUCT_AVAILABLE)
|
||||
.result(ProductChangeValidationResponse.CheckResult.FAIL)
|
||||
.message("검증 중 시스템 오류가 발생했습니다")
|
||||
.build());
|
||||
|
||||
return ProductChangeValidationResponse.failure("시스템 오류로 인해 사전체크를 완료할 수 없습니다", errorDetails);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 판매 가능 여부 검증
|
||||
*/
|
||||
private boolean validateProductAvailability(String targetProductCode,
|
||||
List<ProductChangeValidationResponse.ValidationData.ValidationDetail> details) {
|
||||
logger.debug("상품 판매 가능 여부 검증: {}", targetProductCode);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 상품 상태 조회
|
||||
String cachedStatus = productCacheService.getProductStatus(targetProductCode);
|
||||
if (StringUtils.hasText(cachedStatus)) {
|
||||
boolean isAvailable = "AVAILABLE".equals(cachedStatus);
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.PRODUCT_AVAILABLE,
|
||||
isAvailable, isAvailable ? "판매중인 상품입니다" : "판매 중단된 상품입니다");
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 시 Repository에서 조회
|
||||
Optional<com.unicorn.phonebill.product.domain.Product> productOpt = productRepository.findByProductCode(targetProductCode);
|
||||
if (!productOpt.isPresent()) {
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.PRODUCT_AVAILABLE,
|
||||
false, "존재하지 않는 상품코드입니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
com.unicorn.phonebill.product.domain.Product product = productOpt.get();
|
||||
boolean isAvailable = product.isActive();
|
||||
String message = isAvailable ? "판매중인 상품입니다" : "판매 중단된 상품입니다";
|
||||
|
||||
// 캐시에 저장
|
||||
productCacheService.cacheProductStatus(targetProductCode, isAvailable ? "AVAILABLE" : "UNAVAILABLE");
|
||||
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.PRODUCT_AVAILABLE,
|
||||
isAvailable, message);
|
||||
return isAvailable;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("상품 판매 가능 여부 검증 중 오류: {}", targetProductCode, e);
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.PRODUCT_AVAILABLE,
|
||||
false, "상품 정보 조회 중 오류가 발생했습니다");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업자 일치 여부 검증
|
||||
*/
|
||||
private boolean validateOperatorMatch(String currentProductCode, String targetProductCode,
|
||||
List<ProductChangeValidationResponse.ValidationData.ValidationDetail> details) {
|
||||
logger.debug("사업자 일치 여부 검증: current={}, target={}", currentProductCode, targetProductCode);
|
||||
|
||||
try {
|
||||
// 현재 상품 정보 조회
|
||||
ProductInfoDto currentProduct = getCurrentProductInfo(currentProductCode);
|
||||
if (currentProduct == null) {
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.OPERATOR_MATCH,
|
||||
false, "현재 상품 정보를 찾을 수 없습니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 대상 상품 정보 조회
|
||||
ProductInfoDto targetProduct = getCurrentProductInfo(targetProductCode);
|
||||
if (targetProduct == null) {
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.OPERATOR_MATCH,
|
||||
false, "변경 대상 상품 정보를 찾을 수 없습니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 사업자 코드 일치 확인
|
||||
String currentOperator = currentProduct.getOperatorCode();
|
||||
String targetOperator = targetProduct.getOperatorCode();
|
||||
|
||||
boolean isMatch = StringUtils.hasText(currentOperator) && currentOperator.equals(targetOperator);
|
||||
String message = isMatch ? "사업자가 일치합니다" :
|
||||
String.format("사업자가 일치하지 않습니다 (현재: %s, 변경: %s)", currentOperator, targetOperator);
|
||||
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.OPERATOR_MATCH, isMatch, message);
|
||||
return isMatch;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("사업자 일치 여부 검증 중 오류: current={}, target={}", currentProductCode, targetProductCode, e);
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.OPERATOR_MATCH,
|
||||
false, "사업자 정보 조회 중 오류가 발생했습니다");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회선 상태 검증
|
||||
*/
|
||||
private boolean validateLineStatus(String lineNumber,
|
||||
List<ProductChangeValidationResponse.ValidationData.ValidationDetail> details) {
|
||||
logger.debug("회선 상태 검증: {}", lineNumber);
|
||||
|
||||
try {
|
||||
// 1. 캐시에서 회선 상태 조회
|
||||
String cachedStatus = productCacheService.getLineStatus(lineNumber);
|
||||
if (StringUtils.hasText(cachedStatus)) {
|
||||
boolean isValid = isValidLineStatus(cachedStatus);
|
||||
String message = getLineStatusMessage(cachedStatus);
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.LINE_STATUS, isValid, message);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 시 실제 조회 (여기서는 임시 로직, 실제로는 KOS 연동)
|
||||
String lineStatus = getLineStatusFromRepository(lineNumber);
|
||||
if (!StringUtils.hasText(lineStatus)) {
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.LINE_STATUS,
|
||||
false, "회선 정보를 찾을 수 없습니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
productCacheService.cacheLineStatus(lineNumber, lineStatus);
|
||||
|
||||
boolean isValid = isValidLineStatus(lineStatus);
|
||||
String message = getLineStatusMessage(lineStatus);
|
||||
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.LINE_STATUS, isValid, message);
|
||||
return isValid;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("회선 상태 검증 중 오류: {}", lineNumber, e);
|
||||
addValidationDetail(details, ProductChangeValidationResponse.CheckType.LINE_STATUS,
|
||||
false, "회선 상태 조회 중 오류가 발생했습니다");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 정보 조회 (캐시 우선)
|
||||
*/
|
||||
private ProductInfoDto getCurrentProductInfo(String productCode) {
|
||||
// 캐시에서 먼저 조회
|
||||
ProductInfoDto cachedProduct = productCacheService.getCurrentProductInfo(productCode);
|
||||
if (cachedProduct != null) {
|
||||
return cachedProduct;
|
||||
}
|
||||
|
||||
// 캐시 미스 시 Repository에서 조회
|
||||
Optional<com.unicorn.phonebill.product.domain.Product> productOpt = productRepository.findByProductCode(productCode);
|
||||
if (productOpt.isPresent()) {
|
||||
com.unicorn.phonebill.product.domain.Product domainProduct = productOpt.get();
|
||||
ProductInfoDto product = ProductInfoDto.builder()
|
||||
.productCode(domainProduct.getProductCode())
|
||||
.productName(domainProduct.getProductName())
|
||||
.monthlyFee(domainProduct.getMonthlyFee())
|
||||
.dataAllowance(domainProduct.getDataAllowance())
|
||||
.voiceAllowance(domainProduct.getVoiceAllowance())
|
||||
.smsAllowance(domainProduct.getSmsAllowance())
|
||||
.operatorCode(domainProduct.getOperatorCode())
|
||||
.description(domainProduct.getDescription())
|
||||
.isAvailable(domainProduct.isActive())
|
||||
.build();
|
||||
productCacheService.cacheCurrentProductInfo(productCode, product);
|
||||
return product;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회선 상태 조회 (실제로는 KOS 연동 필요)
|
||||
*/
|
||||
private String getLineStatusFromRepository(String lineNumber) {
|
||||
// TODO: 실제 구현 시 KOS 시스템 연동 또는 DB 조회
|
||||
// 현재는 임시 로직
|
||||
return "ACTIVE"; // 임시 반환값
|
||||
}
|
||||
|
||||
/**
|
||||
* 회선 상태 유효성 확인
|
||||
*/
|
||||
private boolean isValidLineStatus(String status) {
|
||||
return "ACTIVE".equals(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회선 상태 메시지 생성
|
||||
*/
|
||||
private String getLineStatusMessage(String status) {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "회선이 정상 상태입니다";
|
||||
case "SUSPENDED":
|
||||
return "회선이 정지 상태입니다";
|
||||
case "TERMINATED":
|
||||
return "회선이 해지된 상태입니다";
|
||||
default:
|
||||
return "알 수 없는 회선 상태입니다: " + status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 상세 정보 추가
|
||||
*/
|
||||
private void addValidationDetail(List<ProductChangeValidationResponse.ValidationData.ValidationDetail> details,
|
||||
ProductChangeValidationResponse.CheckType checkType, boolean success, String message) {
|
||||
details.add(ProductChangeValidationResponse.ValidationData.ValidationDetail.builder()
|
||||
.checkType(checkType)
|
||||
.result(success ? ProductChangeValidationResponse.CheckResult.PASS : ProductChangeValidationResponse.CheckResult.FAIL)
|
||||
.message(message)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:product_change_db}
|
||||
username: ${DB_USERNAME:phonebill_user}
|
||||
password: ${DB_PASSWORD:phonebill_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
leak-detection-threshold: 60000
|
||||
# JPA 설정
|
||||
jpa:
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
database: ${REDIS_DATABASE:2}
|
||||
|
||||
# Cache 개발 설정 (TTL 단축)
|
||||
cache:
|
||||
redis:
|
||||
time-to-live: 3600000 # 1시간 (개발환경에서 단축)
|
||||
|
||||
# Server 개발 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
error:
|
||||
include-stacktrace: always
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
# Logging 개발 설정
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.phonebill: ${LOG_LEVEL_APP:DEBUG}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
org.springframework.web: DEBUG
|
||||
org.springframework.cache: DEBUG
|
||||
pattern:
|
||||
console: "%clr(%d{HH:mm:ss.SSS}){faint} %clr([%thread]){faint} %clr(%-5level){spring} %clr(%logger{36}){cyan} - %msg%n"
|
||||
|
||||
# Management 개발 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*"
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
show-components: always
|
||||
info:
|
||||
env:
|
||||
enabled: true
|
||||
|
||||
# OpenAPI 개발 설정
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
try-it-out-enabled: true
|
||||
api-docs:
|
||||
enabled: true
|
||||
show-actuator: true
|
||||
|
||||
# Resilience4j 개발 설정 (더 관대한 설정)
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 70
|
||||
minimum-number-of-calls: 3
|
||||
wait-duration-in-open-state: 5s
|
||||
instances:
|
||||
kosClient:
|
||||
failure-rate-threshold: 80
|
||||
wait-duration-in-open-state: 10s
|
||||
|
||||
retry:
|
||||
instances:
|
||||
kosClient:
|
||||
max-attempts: 3
|
||||
wait-duration: 1s
|
||||
|
||||
# KOS Mock 서버 설정 (개발환경용)
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL:http://localhost:9090/kos}
|
||||
connect-timeout: 5s
|
||||
read-timeout: 10s
|
||||
max-retries: 3
|
||||
retry-delay: 1s
|
||||
|
||||
# Mock 모드 설정
|
||||
mock:
|
||||
enabled: ${KOS_MOCK_ENABLED:true}
|
||||
response-delay: 500ms # Mock 응답 지연 시뮬레이션
|
||||
|
||||
endpoints:
|
||||
customer-info: /api/v1/customer/{lineNumber}
|
||||
product-info: /api/v1/product/{productCode}
|
||||
available-products: /api/v1/products/available
|
||||
product-change: /api/v1/product/change
|
||||
|
||||
headers:
|
||||
api-key: ${KOS_API_KEY:dev-api-key}
|
||||
client-id: ${KOS_CLIENT_ID:product-service-dev}
|
||||
|
||||
# 비즈니스 개발 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: ${PRODUCT_CACHE_CUSTOMER_INFO_TTL:600} # 10분 (개발환경에서 단축)
|
||||
product-info-ttl: ${PRODUCT_CACHE_PRODUCT_INFO_TTL:300} # 5분
|
||||
available-products-ttl: ${PRODUCT_CACHE_AVAILABLE_PRODUCTS_TTL:1800} # 30분
|
||||
product-status-ttl: ${PRODUCT_CACHE_PRODUCT_STATUS_TTL:300} # 5분
|
||||
line-status-ttl: ${PRODUCT_CACHE_LINE_STATUS_TTL:180} # 3분
|
||||
validation:
|
||||
enabled: ${PRODUCT_VALIDATION_ENABLED:true}
|
||||
strict-mode: ${PRODUCT_VALIDATION_STRICT_MODE:false} # 개발환경에서는 유연하게
|
||||
processing:
|
||||
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:false} # 개발환경에서는 동기 처리
|
||||
|
||||
# 개발용 테스트 데이터
|
||||
test-data:
|
||||
enabled: ${TEST_DATA_ENABLED:true}
|
||||
customers:
|
||||
- lineNumber: "01012345678"
|
||||
customerId: "CUST001"
|
||||
customerName: "홍길동"
|
||||
currentProductCode: "PLAN001"
|
||||
- lineNumber: "01087654321"
|
||||
customerId: "CUST002"
|
||||
customerName: "김철수"
|
||||
currentProductCode: "PLAN002"
|
||||
products:
|
||||
- productCode: "PLAN001"
|
||||
productName: "5G 베이직 플랜"
|
||||
monthlyFee: 45000
|
||||
dataAllowance: "50GB"
|
||||
- productCode: "PLAN002"
|
||||
productName: "5G 프리미엄 플랜"
|
||||
monthlyFee: 65000
|
||||
dataAllowance: "100GB"
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:dev-secret-key-for-testing-only}
|
||||
expiration: ${JWT_EXPIRATION:3600} # 1시간 (개발환경에서 단축)
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:*} # 개발환경에서만 허용
|
||||
|
||||
# DevTools 설정
|
||||
spring.devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
exclude: static/**,public/**,templates/**
|
||||
livereload:
|
||||
enabled: true
|
||||
port: 35729
|
||||
add-properties: true
|
||||
|
||||
# 디버깅 설정
|
||||
debug: false
|
||||
trace: false
|
||||
|
||||
# 개발 환경 정보
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
description: Product-Change Service Development Environment
|
||||
version: ${spring.application.version}
|
||||
encoding: UTF-8
|
||||
java:
|
||||
version: ${java.version}
|
||||
build:
|
||||
artifact: ${project.artifactId:product-service}
|
||||
name: ${project.name:Product Service}
|
||||
version: ${project.version:1.0.0}
|
||||
time: ${build.time:2024-03-15T10:00:00Z}
|
||||
@@ -0,0 +1,273 @@
|
||||
spring:
|
||||
# Database - 운영환경 (PostgreSQL)
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_product_prod}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
idle-timeout: 300000
|
||||
max-lifetime: 1800000
|
||||
connection-timeout: 20000
|
||||
validation-timeout: 5000
|
||||
leak-detection-threshold: 60000
|
||||
|
||||
# JPA 운영 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
use_sql_comments: false
|
||||
generate_statistics: false
|
||||
|
||||
# Redis - 운영환경 (클러스터)
|
||||
data:
|
||||
redis:
|
||||
cluster:
|
||||
nodes: ${REDIS_CLUSTER_NODES}
|
||||
password: ${REDIS_PASSWORD}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
cluster:
|
||||
refresh:
|
||||
adaptive: true
|
||||
period: 30s
|
||||
pool:
|
||||
max-active: 50
|
||||
max-idle: 20
|
||||
min-idle: 5
|
||||
max-wait: 3000ms
|
||||
|
||||
# Server 운영 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
shutdown: graceful
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 1024
|
||||
tomcat:
|
||||
connection-timeout: 30s
|
||||
max-connections: 8192
|
||||
max-threads: 200
|
||||
min-spare-threads: 10
|
||||
accept-count: 100
|
||||
error:
|
||||
include-stacktrace: never
|
||||
include-message: on-param
|
||||
include-binding-errors: never
|
||||
|
||||
# Graceful Shutdown
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
|
||||
# Logging 운영 설정
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.unicorn.phonebill: INFO
|
||||
org.springframework.security: WARN
|
||||
org.hibernate: WARN
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||
file:
|
||||
name: /app/logs/product-service.log
|
||||
max-size: 500MB
|
||||
max-history: 30
|
||||
total-size-cap: 10GB
|
||||
logback:
|
||||
rollingpolicy:
|
||||
clean-history-on-start: true
|
||||
|
||||
# Management 운영 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
show-components: never
|
||||
info:
|
||||
enabled: true
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
livenessstate:
|
||||
enabled: true
|
||||
readinessstate:
|
||||
enabled: true
|
||||
metrics:
|
||||
distribution:
|
||||
percentiles:
|
||||
http.server.requests: 0.5, 0.95, 0.99
|
||||
slo:
|
||||
http.server.requests: 50ms, 100ms, 200ms, 500ms, 1s, 2s
|
||||
|
||||
# OpenAPI 운영 설정 (비활성화)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
# Resilience4j 운영 설정
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 50
|
||||
slow-call-duration-threshold: 3s
|
||||
permitted-number-of-calls-in-half-open-state: 5
|
||||
minimum-number-of-calls: 10
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 20
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
failure-rate-threshold: 40
|
||||
wait-duration-in-open-state: 60s
|
||||
minimum-number-of-calls: 20
|
||||
|
||||
retry:
|
||||
configs:
|
||||
default:
|
||||
max-attempts: 3
|
||||
wait-duration: 2s
|
||||
exponential-backoff-multiplier: 2
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
max-attempts: 2
|
||||
wait-duration: 3s
|
||||
|
||||
timelimiter:
|
||||
configs:
|
||||
default:
|
||||
timeout-duration: 8s
|
||||
instances:
|
||||
kosClient:
|
||||
timeout-duration: 15s
|
||||
|
||||
# KOS 서버 설정 (운영환경)
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL}
|
||||
connect-timeout: 10s
|
||||
read-timeout: 30s
|
||||
max-retries: 2
|
||||
retry-delay: 3s
|
||||
|
||||
endpoints:
|
||||
customer-info: /api/v1/customer/{lineNumber}
|
||||
product-info: /api/v1/product/{productCode}
|
||||
available-products: /api/v1/products/available
|
||||
product-change: /api/v1/product/change
|
||||
|
||||
headers:
|
||||
api-key: ${KOS_API_KEY}
|
||||
client-id: ${KOS_CLIENT_ID:product-service}
|
||||
|
||||
# 운영환경 보안 설정
|
||||
ssl:
|
||||
enabled: true
|
||||
trust-store: ${SSL_TRUST_STORE:/app/certs/truststore.jks}
|
||||
trust-store-password: ${SSL_TRUST_STORE_PASSWORD}
|
||||
key-store: ${SSL_KEY_STORE:/app/certs/keystore.jks}
|
||||
key-store-password: ${SSL_KEY_STORE_PASSWORD}
|
||||
|
||||
# 비즈니스 운영 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: 14400 # 4시간
|
||||
product-info-ttl: 7200 # 2시간
|
||||
available-products-ttl: 86400 # 24시간
|
||||
product-status-ttl: 3600 # 1시간
|
||||
line-status-ttl: 1800 # 30분
|
||||
validation:
|
||||
enabled: true
|
||||
strict-mode: true
|
||||
max-retry-attempts: 2
|
||||
validation-timeout: 10s
|
||||
processing:
|
||||
async-enabled: true
|
||||
max-concurrent-requests: 500
|
||||
request-timeout: 60s
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 86400 # 24시간
|
||||
refresh-expiration: 604800 # 7일
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS}
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed-headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Accept
|
||||
- X-Requested-With
|
||||
- X-Forwarded-For
|
||||
- X-Forwarded-Proto
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
|
||||
# 모니터링 설정
|
||||
monitoring:
|
||||
health-check:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
metrics:
|
||||
enabled: true
|
||||
export-interval: 60s
|
||||
alerts:
|
||||
email-enabled: ${ALERT_EMAIL_ENABLED:false}
|
||||
slack-enabled: ${ALERT_SLACK_ENABLED:false}
|
||||
webhook-url: ${ALERT_WEBHOOK_URL:}
|
||||
|
||||
# 운영 환경 정보
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
description: Product-Change Service Production Environment
|
||||
version: ${spring.application.version}
|
||||
environment: production
|
||||
build:
|
||||
artifact: product-service
|
||||
version: ${BUILD_VERSION:1.0.0}
|
||||
time: ${BUILD_TIME}
|
||||
commit: ${GIT_COMMIT:unknown}
|
||||
branch: ${GIT_BRANCH:main}
|
||||
|
||||
# JVM 튜닝 설정 (환경변수로 설정)
|
||||
# JAVA_OPTS=-Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
# -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/heapdumps/
|
||||
# -Dspring.profiles.active=prod
|
||||
|
||||
# 외부 의존성 URLs
|
||||
external:
|
||||
auth-service:
|
||||
url: ${AUTH_SERVICE_URL:http://auth-service:8080}
|
||||
bill-inquiry-service:
|
||||
url: ${BILL_INQUIRY_SERVICE_URL:http://bill-inquiry-service:8081}
|
||||
|
||||
# 데이터베이스 마이그레이션 (Flyway)
|
||||
spring:
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
validate-on-migrate: true
|
||||
@@ -0,0 +1,258 @@
|
||||
spring:
|
||||
application:
|
||||
name: product-service
|
||||
version: 1.0.0
|
||||
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
|
||||
# Database 기본 설정
|
||||
datasource:
|
||||
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:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
use_sql_comments: false
|
||||
jdbc:
|
||||
batch_size: 25
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
connection:
|
||||
provider_disables_autocommit: true
|
||||
|
||||
# Redis 기본 설정
|
||||
data:
|
||||
redis:
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 8
|
||||
min-idle: 2
|
||||
max-wait: -1ms
|
||||
time-between-eviction-runs: 30s
|
||||
|
||||
# Cache 설정
|
||||
cache:
|
||||
type: redis
|
||||
cache-names:
|
||||
- customerInfo
|
||||
- productInfo
|
||||
- availableProducts
|
||||
- productStatus
|
||||
- lineStatus
|
||||
redis:
|
||||
time-to-live: 14400000 # 4시간 (ms)
|
||||
cache-null-values: false
|
||||
use-key-prefix: true
|
||||
key-prefix: "product-service:"
|
||||
|
||||
# Security 기본 설정
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: ${JWT_ISSUER_URI:http://localhost:8080/auth}
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
write-durations-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
adjust-dates-to-context-time-zone: false
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd'T'HH:mm:ss
|
||||
|
||||
# HTTP 설정
|
||||
webflux:
|
||||
base-path: /api/v1
|
||||
|
||||
# Server 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
servlet:
|
||||
context-path: /api/v1
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
||||
http2:
|
||||
enabled: true
|
||||
error:
|
||||
include-stacktrace: never
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
# Management & Actuator
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
show-components: always
|
||||
health:
|
||||
circuitbreakers:
|
||||
enabled: true
|
||||
redis:
|
||||
enabled: true
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
distribution:
|
||||
percentiles-histogram:
|
||||
http.server.requests: true
|
||||
percentiles:
|
||||
http.server.requests: 0.5, 0.95, 0.99
|
||||
slo:
|
||||
http.server.requests: 50ms, 100ms, 200ms, 300ms, 500ms, 1s
|
||||
info:
|
||||
git:
|
||||
mode: full
|
||||
build:
|
||||
enabled: true
|
||||
|
||||
# Logging 설정
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.unicorn.phonebill: ${LOG_LEVEL_APP:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
|
||||
org.hibernate.type: WARN
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE:logs/product-service.log}
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
max-history: 7
|
||||
total-size-cap: 100MB
|
||||
|
||||
# OpenAPI/Swagger 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
operations-sorter: method
|
||||
tags-sorter: alpha
|
||||
show-actuator: false
|
||||
group-configs:
|
||||
- group: product-service
|
||||
display-name: Product Change Service API
|
||||
paths-to-match: /products/**
|
||||
|
||||
# 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: 3
|
||||
minimum-number-of-calls: 5
|
||||
wait-duration-in-open-state: 10s
|
||||
sliding-window-type: count-based
|
||||
sliding-window-size: 10
|
||||
record-exceptions:
|
||||
- java.net.ConnectException
|
||||
- java.util.concurrent.TimeoutException
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
ignore-exceptions:
|
||||
- java.lang.IllegalArgumentException
|
||||
- jakarta.validation.ValidationException
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
failure-rate-threshold: 60
|
||||
wait-duration-in-open-state: 30s
|
||||
|
||||
retry:
|
||||
configs:
|
||||
default:
|
||||
max-attempts: 3
|
||||
wait-duration: 1s
|
||||
exponential-backoff-multiplier: 2
|
||||
retry-exceptions:
|
||||
- java.net.ConnectException
|
||||
- java.util.concurrent.TimeoutException
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
max-attempts: 2
|
||||
wait-duration: 2s
|
||||
|
||||
timelimiter:
|
||||
configs:
|
||||
default:
|
||||
timeout-duration: 5s
|
||||
instances:
|
||||
kosClient:
|
||||
base-config: default
|
||||
timeout-duration: 10s
|
||||
|
||||
# 비즈니스 설정
|
||||
app:
|
||||
product:
|
||||
cache:
|
||||
customer-info-ttl: 14400 # 4시간 (초)
|
||||
product-info-ttl: 7200 # 2시간 (초)
|
||||
available-products-ttl: 86400 # 24시간 (초)
|
||||
product-status-ttl: 3600 # 1시간 (초)
|
||||
line-status-ttl: 1800 # 30분 (초)
|
||||
validation:
|
||||
max-retry-attempts: 3
|
||||
validation-timeout: 5s
|
||||
processing:
|
||||
async-enabled: ${PRODUCT_PROCESSING_ASYNC_ENABLED:true}
|
||||
max-concurrent-requests: ${PRODUCT_PROCESSING_MAX_CONCURRENT_REQUESTS:100}
|
||||
request-timeout: ${PRODUCT_PROCESSING_REQUEST_TIMEOUT:30s}
|
||||
|
||||
security:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:product-service-secret-key-change-in-production}
|
||||
expiration: ${JWT_EXPIRATION:86400} # 24시간
|
||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800} # 7일
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:3000
|
||||
- https://mvno.com
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
allowed-headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Accept
|
||||
- X-Requested-With
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
Reference in New Issue
Block a user