mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2025-12-05 23:56:23 +00:00
release
This commit is contained in:
parent
7ec8a682c6
commit
b489c73201
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
# Eclipse
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
# NetBeans
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Linux
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Spring Boot
|
||||
*.pid
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Claude downloads
|
||||
claude/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
|
||||
# Debug images
|
||||
debug/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.dev
|
||||
.env.prod
|
||||
|
||||
# Certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@ -1,3 +0,0 @@
|
||||
# 디폴트 무시된 파일
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" project-jdk-name="24" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/phonebill.iml" filepath="$PROJECT_DIR$/.idea/phonebill.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/phonebill.iml
generated
9
.idea/phonebill.iml
generated
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB |
19
CLAUDE.md
19
CLAUDE.md
@ -492,3 +492,22 @@ QA Engineer
|
||||
- "@develop-help": "개발실행프롬프트 내용을 터미널에 출력"
|
||||
- "@deploy-help": "배포실행프롬프트 내용을 터미널에 출력"
|
||||
```
|
||||
|
||||
# Lessons Learned
|
||||
|
||||
## 개발 워크플로우
|
||||
- **❗ 핵심 원칙**: 코드 수정 → 컴파일 → 사람에게 서버 시작 요청 → 테스트
|
||||
- **소스 수정**: Spring Boot는 코드 변경 후 반드시 컴파일 + 재시작 필요
|
||||
- **컴파일**: 최상위 루트에서 `./gradlew {service-name}:compileJava` 명령 사용
|
||||
- **서버 시작**: AI가 직접 서버를 시작하지 말고 반드시 사람에게 요청할것
|
||||
|
||||
## 실행 프로파일 작성 경험
|
||||
- **Gradle 실행 프로파일**: Spring Boot가 아닌 Gradle 실행 프로파일 사용 필수
|
||||
- **환경변수 매핑**: `<entry key="..." value="..." />` 형태로 환경변수 설정
|
||||
- **컴포넌트 스캔 이슈**: common 모듈의 @Component가 인식되지 않는 경우 발생
|
||||
- **의존성 주입 오류**: JwtTokenProvider 빈을 찾을 수 없는 오류 확인됨
|
||||
|
||||
## 백킹서비스 연결 정보
|
||||
- **LoadBalancer External IP**: kubectl 명령으로 실제 IP 확인 후 환경변수 설정
|
||||
- **DB 연결정보**: 각 서비스별 별도 DB 사용 (auth, bill_inquiry, product_change)
|
||||
- **Redis 공유**: 모든 서비스가 동일한 Redis 인스턴스 사용
|
||||
|
||||
40
api-gateway/.run/api-gateway.run.xml
Normal file
40
api-gateway/.run/api-gateway.run.xml
Normal file
@ -0,0 +1,40 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="api-gateway" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||
<entry key="SERVER_PORT" value="8080" />
|
||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
||||
<entry key="AUTH_SERVICE_URL" value="http://localhost:8081" />
|
||||
<entry key="BILL_SERVICE_URL" value="http://localhost:8082" />
|
||||
<entry key="PRODUCT_SERVICE_URL" value="http://localhost:8083" />
|
||||
<entry key="KOS_MOCK_SERVICE_URL" value="http://localhost:8084" />
|
||||
<entry key="LOG_FILE" value="logs/api-gateway.log" />
|
||||
<entry key="LOG_LEVEL_GATEWAY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SPRING_CLOUD_GATEWAY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_REACTOR_NETTY" value="INFO" />
|
||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
||||
</map>
|
||||
</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="api-gateway:bootRun" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" value="" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
330
api-gateway/README.md
Normal file
330
api-gateway/README.md
Normal file
@ -0,0 +1,330 @@
|
||||
# PhoneBill API Gateway
|
||||
|
||||
통신요금 관리 서비스의 API Gateway 모듈입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
Spring Cloud Gateway를 사용하여 구현된 API Gateway로, 마이크로서비스들의 단일 진입점 역할을 담당합니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
- **JWT 토큰 기반 인증/인가**: 모든 요청에 대한 통합 인증 처리
|
||||
- **서비스별 라우팅**: 각 마이크로서비스로의 지능형 라우팅
|
||||
- **Rate Limiting**: Redis 기반 요청 제한
|
||||
- **Circuit Breaker**: 외부 시스템 장애 격리
|
||||
- **CORS 설정**: 크로스 오리진 요청 처리
|
||||
- **API 문서화 통합**: 모든 서비스의 Swagger 문서 통합
|
||||
- **헬스체크**: 시스템 상태 모니터링
|
||||
- **Fallback 처리**: 서비스 장애 시 대체 응답
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Java 17**
|
||||
- **Spring Boot 3.2.1**
|
||||
- **Spring Cloud Gateway**
|
||||
- **Spring Data Redis Reactive**
|
||||
- **JWT (JJWT 0.12.3)**
|
||||
- **Resilience4j** (Circuit Breaker)
|
||||
- **SpringDoc OpenAPI 3**
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 라우팅 구성
|
||||
|
||||
```
|
||||
/auth/** -> auth-service (인증 서비스)
|
||||
/bills/** -> bill-service (요금조회 서비스)
|
||||
/products/** -> product-service (상품변경 서비스)
|
||||
/kos/** -> kos-mock-service (KOS 목업 서비스)
|
||||
```
|
||||
|
||||
### 패키지 구조
|
||||
|
||||
```
|
||||
com.unicorn.phonebill.gateway/
|
||||
├── config/ # 설정 클래스
|
||||
│ ├── GatewayConfig # Gateway 라우팅 설정
|
||||
│ ├── RedisConfig # Redis 및 Rate Limiting 설정
|
||||
│ ├── SwaggerConfig # API 문서화 설정
|
||||
│ └── WebConfig # Web 설정
|
||||
├── controller/ # 컨트롤러
|
||||
│ └── HealthController # 헬스체크 API
|
||||
├── dto/ # 데이터 전송 객체
|
||||
│ └── TokenValidationResult # JWT 검증 결과
|
||||
├── exception/ # 예외 클래스
|
||||
│ └── GatewayException # Gateway 예외
|
||||
├── filter/ # Gateway 필터
|
||||
│ └── JwtAuthenticationGatewayFilterFactory # JWT 인증 필터
|
||||
├── handler/ # 핸들러
|
||||
│ └── FallbackHandler # Circuit Breaker Fallback 핸들러
|
||||
├── service/ # 서비스
|
||||
│ └── JwtTokenService # JWT 토큰 검증 서비스
|
||||
└── util/ # 유틸리티
|
||||
└── JwtUtil # JWT 유틸리티
|
||||
```
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
### 개발 환경
|
||||
|
||||
```bash
|
||||
# 의존성 설치 및 빌드
|
||||
./gradlew build
|
||||
|
||||
# 개발 환경 실행
|
||||
./gradlew bootRun --args='--spring.profiles.active=dev'
|
||||
|
||||
# 또는
|
||||
./gradlew bootRun -Pdev
|
||||
```
|
||||
|
||||
### 운영 환경
|
||||
|
||||
```bash
|
||||
# 운영용 JAR 빌드
|
||||
./gradlew bootJar
|
||||
|
||||
# 운영 환경 실행
|
||||
java -jar api-gateway-1.0.0.jar --spring.profiles.active=prod
|
||||
```
|
||||
|
||||
## 환경 설정
|
||||
|
||||
### 개발 환경 (application-dev.yml)
|
||||
|
||||
- JWT 토큰 유효시간: 1시간 (개발 편의성)
|
||||
- Redis: localhost:6379
|
||||
- Rate Limiting: 1000 requests/minute
|
||||
- Circuit Breaker: 관대한 설정
|
||||
- Swagger UI: 활성화
|
||||
|
||||
### 운영 환경 (application-prod.yml)
|
||||
|
||||
- JWT 토큰 유효시간: 30분 (보안 강화)
|
||||
- Redis: 클러스터 설정
|
||||
- Rate Limiting: 500 requests/minute
|
||||
- Circuit Breaker: 엄격한 설정
|
||||
- Swagger UI: 비활성화
|
||||
|
||||
### 환경 변수
|
||||
|
||||
운영 환경에서는 다음 환경 변수를 설정해야 합니다:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=your-256-bit-secret-key
|
||||
REDIS_HOST=redis-cluster.domain.com
|
||||
REDIS_PASSWORD=your-redis-password
|
||||
AUTH_SERVICE_URL=https://auth-service.internal.domain.com
|
||||
BILL_SERVICE_URL=https://bill-service.internal.domain.com
|
||||
PRODUCT_SERVICE_URL=https://product-service.internal.domain.com
|
||||
KOS_MOCK_SERVICE_URL=https://kos-mock.internal.domain.com
|
||||
```
|
||||
|
||||
## API 문서
|
||||
|
||||
### 개발 환경
|
||||
|
||||
Swagger UI는 개발 환경에서만 활성화됩니다:
|
||||
|
||||
- **Swagger UI**: http://localhost:8080/swagger-ui.html
|
||||
- **API Docs**: http://localhost:8080/v3/api-docs
|
||||
|
||||
### 헬스체크
|
||||
|
||||
- **기본 헬스체크**: `GET /health`
|
||||
- **상세 헬스체크**: `GET /health/detailed`
|
||||
- **Actuator 헬스체크**: `GET /actuator/health`
|
||||
|
||||
## JWT 인증
|
||||
|
||||
### 토큰 형식
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
### 토큰 페이로드 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user123",
|
||||
"role": "USER",
|
||||
"iat": 1704700800,
|
||||
"exp": 1704704400,
|
||||
"jti": "token-unique-id"
|
||||
}
|
||||
```
|
||||
|
||||
### 인증 제외 경로
|
||||
|
||||
- `/auth/login` (로그인)
|
||||
- `/auth/refresh` (토큰 갱신)
|
||||
- `/health` (헬스체크)
|
||||
- `/actuator/health` (Actuator 헬스체크)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### 제한 정책
|
||||
|
||||
- **일반 사용자**: 100 requests/minute
|
||||
- **VIP 사용자**: 500 requests/minute
|
||||
- **IP 기반 제한**: Fallback으로 사용
|
||||
|
||||
### Key Resolver
|
||||
|
||||
1. **userKeyResolver**: JWT에서 사용자 ID 추출 (기본)
|
||||
2. **ipKeyResolver**: 클라이언트 IP 기반
|
||||
3. **pathKeyResolver**: API 경로 기반
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
### 설정
|
||||
|
||||
- **실패율 임계값**: 50% (auth), 60% (bill, product), 70% (kos)
|
||||
- **최소 호출 수**: 5-20회
|
||||
- **Open 상태 대기시간**: 10-60초
|
||||
- **Half-Open 상태 허용 호출**: 3-10회
|
||||
|
||||
### Fallback
|
||||
|
||||
Circuit Breaker가 Open 상태일 때 Fallback 응답을 제공:
|
||||
|
||||
- **인증 서비스**: 503 Service Unavailable
|
||||
- **요금조회 서비스**: 캐시된 메뉴 데이터 제공 가능
|
||||
- **상품변경 서비스**: 고객센터 안내 메시지
|
||||
- **KOS 서비스**: 외부 시스템 점검 안내
|
||||
|
||||
## 모니터링
|
||||
|
||||
### Actuator 엔드포인트
|
||||
|
||||
```bash
|
||||
# 애플리케이션 상태
|
||||
GET /actuator/health
|
||||
|
||||
# Gateway 라우트 정보
|
||||
GET /actuator/gateway/routes
|
||||
|
||||
# 메트릭 정보
|
||||
GET /actuator/metrics
|
||||
|
||||
# 환경 정보 (개발환경만)
|
||||
GET /actuator/env
|
||||
```
|
||||
|
||||
### 로깅
|
||||
|
||||
- **개발환경**: DEBUG 레벨, 상세한 요청/응답 로그
|
||||
- **운영환경**: INFO 레벨, 성능 고려한 최적화된 로그
|
||||
|
||||
## 보안
|
||||
|
||||
### HTTPS
|
||||
|
||||
운영 환경에서는 반드시 HTTPS를 사용해야 합니다.
|
||||
|
||||
### CORS
|
||||
|
||||
- **개발환경**: 모든 localhost 오리진 허용
|
||||
- **운영환경**: 특정 도메인만 허용
|
||||
|
||||
### 보안 헤더
|
||||
|
||||
- X-Content-Type-Options: nosniff
|
||||
- X-Frame-Options: DENY
|
||||
- X-XSS-Protection: 1; mode=block
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 일반적인 문제
|
||||
|
||||
1. **Redis 연결 실패**
|
||||
```bash
|
||||
# Redis 서비스 상태 확인
|
||||
systemctl status redis
|
||||
|
||||
# Redis 연결 테스트
|
||||
redis-cli ping
|
||||
```
|
||||
|
||||
2. **JWT 검증 실패**
|
||||
```bash
|
||||
# JWT 시크릿 키 확인
|
||||
echo $JWT_SECRET
|
||||
|
||||
# 토큰 유효성 확인 (개발용)
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:8080/health
|
||||
```
|
||||
|
||||
3. **Circuit Breaker Open**
|
||||
```bash
|
||||
# Circuit Breaker 상태 확인
|
||||
curl http://localhost:8080/actuator/circuitbreakers
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
```bash
|
||||
# 개발환경 로그
|
||||
tail -f logs/api-gateway-dev.log
|
||||
|
||||
# 운영환경 로그
|
||||
tail -f /var/log/api-gateway/api-gateway.log
|
||||
```
|
||||
|
||||
## 성능 튜닝
|
||||
|
||||
### JVM 옵션 (운영환경)
|
||||
|
||||
```bash
|
||||
java -server \
|
||||
-Xms512m -Xmx1024m \
|
||||
-XX:+UseG1GC \
|
||||
-XX:G1HeapRegionSize=16m \
|
||||
-XX:+UseStringDeduplication \
|
||||
-jar api-gateway-1.0.0.jar
|
||||
```
|
||||
|
||||
### Redis 최적화
|
||||
|
||||
- Connection Pool 설정 조정
|
||||
- Pipeline 사용 고려
|
||||
- 클러스터 모드 활용
|
||||
|
||||
## 개발 가이드
|
||||
|
||||
### 새로운 서비스 추가
|
||||
|
||||
1. `GatewayConfig`에 라우팅 규칙 추가
|
||||
2. `SwaggerConfig`에 API 문서 URL 추가
|
||||
3. `FallbackHandler`에 Fallback 로직 추가
|
||||
4. Circuit Breaker 설정 추가
|
||||
|
||||
### 커스텀 필터 추가
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class CustomGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {
|
||||
// 필터 구현
|
||||
}
|
||||
```
|
||||
|
||||
## 릴리스 노트
|
||||
|
||||
### v1.0.0 (2025-01-08)
|
||||
|
||||
- 초기 릴리스
|
||||
- JWT 인증 시스템 구현
|
||||
- 4개 마이크로서비스 라우팅 지원
|
||||
- Circuit Breaker 및 Rate Limiting 구현
|
||||
- Swagger 통합 문서화
|
||||
- 헬스체크 및 모니터링 기능
|
||||
|
||||
## 라이선스
|
||||
|
||||
이 프로젝트는 회사 내부 프로젝트입니다.
|
||||
|
||||
## 기여
|
||||
|
||||
- **개발팀**: 이개발(백엔더)
|
||||
- **검토**: 김기획(기획자), 박화면(프론트), 최운영(데옵스), 정테스트(QA매니저)
|
||||
87
api-gateway/build.gradle
Normal file
87
api-gateway/build.gradle
Normal file
@ -0,0 +1,87 @@
|
||||
// API Gateway 모듈
|
||||
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
|
||||
// API Gateway는 WebFlux를 사용하므로 일부 다른 설정 필요
|
||||
|
||||
// Spring Cloud 버전 정의
|
||||
ext {
|
||||
set('springCloudVersion', '2023.0.0')
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Common module dependency
|
||||
implementation project(':common')
|
||||
|
||||
// Spring Cloud Gateway (api-gateway specific)
|
||||
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
|
||||
|
||||
// Circuit Breaker (api-gateway specific)
|
||||
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'
|
||||
|
||||
// Monitoring (api-gateway specific)
|
||||
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
// Logging (api-gateway specific)
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
|
||||
|
||||
// Netty macOS DNS resolver (api-gateway specific)
|
||||
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.100.Final:osx-aarch_64'
|
||||
|
||||
// Test Dependencies (api-gateway specific)
|
||||
testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 테스트 설정 (루트에서 기본 설정됨)
|
||||
tasks.named('test') {
|
||||
systemProperty 'spring.profiles.active', 'test'
|
||||
}
|
||||
|
||||
// JAR 파일명 설정
|
||||
jar {
|
||||
archiveBaseName = 'api-gateway'
|
||||
enabled = false
|
||||
}
|
||||
|
||||
bootJar {
|
||||
archiveBaseName = 'api-gateway'
|
||||
|
||||
// 빌드 정보 추가
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'PhoneBill API Gateway',
|
||||
'Implementation-Version': "${version}",
|
||||
'Built-By': System.getProperty('user.name'),
|
||||
'Built-JDK': System.getProperty('java.version'),
|
||||
'Build-Time': new Date().format('yyyy-MM-dd HH:mm:ss')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경 실행 설정
|
||||
if (project.hasProperty('dev')) {
|
||||
bootRun {
|
||||
systemProperty 'spring.profiles.active', 'dev'
|
||||
jvmArgs = ['-Dspring.devtools.restart.enabled=true']
|
||||
}
|
||||
}
|
||||
|
||||
// 프로덕션 환경 실행 설정
|
||||
if (project.hasProperty('prod')) {
|
||||
bootRun {
|
||||
systemProperty 'spring.profiles.active', 'prod'
|
||||
jvmArgs = [
|
||||
'-server',
|
||||
'-Xms512m',
|
||||
'-Xmx1024m',
|
||||
'-XX:+UseG1GC',
|
||||
'-XX:G1HeapRegionSize=16m',
|
||||
'-XX:+UseStringDeduplication',
|
||||
'-XX:+OptimizeStringConcat'
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.unicorn.phonebill.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.gateway.config.GatewayLoadBalancerProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* API Gateway 애플리케이션 메인 클래스
|
||||
*
|
||||
* Spring Cloud Gateway를 사용하여 마이크로서비스들의 단일 진입점 역할을 담당합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - JWT 토큰 기반 인증/인가
|
||||
* - 서비스별 라우팅 (user-service, bill-service, product-service, kos-mock)
|
||||
* - CORS 설정
|
||||
* - Circuit Breaker 패턴 적용
|
||||
* - Rate Limiting
|
||||
* - API 문서화 통합
|
||||
* - 모니터링 및 헬스체크
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@SpringBootApplication(exclude = {
|
||||
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class
|
||||
})
|
||||
@EnableConfigurationProperties(GatewayLoadBalancerProperties.class)
|
||||
public class ApiGatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 시스템 프로퍼티 설정 (성능 최적화)
|
||||
System.setProperty("spring.main.lazy-initialization", "true");
|
||||
System.setProperty("reactor.bufferSize.small", "256");
|
||||
|
||||
SpringApplication.run(ApiGatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package com.unicorn.phonebill.gateway.config;
|
||||
|
||||
import com.unicorn.phonebill.gateway.filter.JwtAuthenticationGatewayFilterFactory;
|
||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.reactive.CorsWebFilter;
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Spring Cloud Gateway 라우팅 및 CORS 설정
|
||||
*
|
||||
* 마이크로서비스별 라우팅 규칙과 CORS 정책을 정의합니다.
|
||||
*
|
||||
* 라우팅 구성:
|
||||
* - /auth/** -> auth-service (인증 서비스)
|
||||
* - /bills/** -> bill-service (요금조회 서비스)
|
||||
* - /products/** -> product-service (상품변경 서비스)
|
||||
* - /kos/** -> kos-mock (KOS 목업 서비스)
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Configuration
|
||||
public class GatewayConfig {
|
||||
|
||||
private final JwtAuthenticationGatewayFilterFactory jwtAuthFilter;
|
||||
|
||||
public GatewayConfig(JwtAuthenticationGatewayFilterFactory jwtAuthFilter) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우팅 규칙 정의
|
||||
*
|
||||
* 각 마이크로서비스로의 라우팅 규칙과 필터를 설정합니다.
|
||||
* JWT 인증이 필요한 경로와 불필요한 경로를 구분하여 처리합니다.
|
||||
*
|
||||
* @param builder RouteLocatorBuilder
|
||||
* @return RouteLocator
|
||||
*/
|
||||
@Bean
|
||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||
return builder.routes()
|
||||
// Auth Service 라우팅 (인증 불필요)
|
||||
.route("auth-service", r -> r
|
||||
.path("/auth/login", "/auth/refresh")
|
||||
.and()
|
||||
.method("POST")
|
||||
.uri("lb://auth-service"))
|
||||
|
||||
// Auth Service 라우팅 (인증 필요)
|
||||
.route("auth-service-authenticated", r -> r
|
||||
.path("/auth/**")
|
||||
.filters(f -> f
|
||||
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
||||
.circuitBreaker(cb -> cb
|
||||
.setName("auth-service-cb")
|
||||
.setFallbackUri("forward:/fallback/auth"))
|
||||
.retry(retry -> retry
|
||||
.setRetries(3)
|
||||
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true)))
|
||||
.uri("lb://auth-service"))
|
||||
|
||||
// Bill-Inquiry Service 라우팅 (인증 필요)
|
||||
.route("bill-service", r -> r
|
||||
.path("/bills/**")
|
||||
.filters(f -> f
|
||||
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
||||
.circuitBreaker(cb -> cb
|
||||
.setName("bill-service-cb")
|
||||
.setFallbackUri("forward:/fallback/bill"))
|
||||
.retry(retry -> retry
|
||||
.setRetries(3)
|
||||
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true))
|
||||
)
|
||||
.uri("lb://bill-service"))
|
||||
|
||||
// Product-Change Service 라우팅 (인증 필요)
|
||||
.route("product-service", r -> r
|
||||
.path("/products/**")
|
||||
.filters(f -> f
|
||||
.filter(jwtAuthFilter.apply(new JwtAuthenticationGatewayFilterFactory.Config()))
|
||||
.circuitBreaker(cb -> cb
|
||||
.setName("product-service-cb")
|
||||
.setFallbackUri("forward:/fallback/product"))
|
||||
.retry(retry -> retry
|
||||
.setRetries(3)
|
||||
.setBackoff(java.time.Duration.ofSeconds(2), java.time.Duration.ofSeconds(10), 2, true))
|
||||
)
|
||||
.uri("lb://product-service"))
|
||||
|
||||
// KOS Mock Service 라우팅 (내부 서비스용)
|
||||
.route("kos-mock-service", r -> r
|
||||
.path("/kos/**")
|
||||
.filters(f -> f
|
||||
.circuitBreaker(cb -> cb
|
||||
.setName("kos-mock-cb")
|
||||
.setFallbackUri("forward:/fallback/kos"))
|
||||
.retry(retry -> retry
|
||||
.setRetries(5)
|
||||
.setBackoff(java.time.Duration.ofSeconds(1), java.time.Duration.ofSeconds(5), 2, true)))
|
||||
.uri("lb://kos-mock-service"))
|
||||
|
||||
// Health Check 라우팅 (인증 불필요)
|
||||
.route("health-check", r -> r
|
||||
.path("/health", "/actuator/health")
|
||||
.uri("http://localhost:8080"))
|
||||
|
||||
// Swagger UI 라우팅 (개발환경에서만 사용)
|
||||
.route("swagger-ui", r -> r
|
||||
.path("/swagger-ui/**", "/v3/api-docs/**")
|
||||
.uri("http://localhost:8080"))
|
||||
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
*
|
||||
* 프론트엔드에서 API Gateway로의 크로스 오리진 요청을 허용합니다.
|
||||
* 개발/운영 환경에 따라 허용 오리진을 다르게 설정합니다.
|
||||
*
|
||||
* @return CorsWebFilter
|
||||
*/
|
||||
@Bean
|
||||
public CorsWebFilter corsWebFilter() {
|
||||
CorsConfiguration corsConfig = new CorsConfiguration();
|
||||
|
||||
// 허용할 Origin 설정
|
||||
corsConfig.setAllowedOriginPatterns(Arrays.asList(
|
||||
"http://localhost:3000", // React 개발 서버
|
||||
"http://localhost:3001", // Next.js 개발 서버
|
||||
"https://*.unicorn.com", // 운영 도메인
|
||||
"https://*.phonebill.com" // 운영 도메인
|
||||
));
|
||||
|
||||
// 허용할 HTTP 메서드
|
||||
corsConfig.setAllowedMethods(Arrays.asList(
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"
|
||||
));
|
||||
|
||||
// 허용할 헤더
|
||||
corsConfig.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"X-Request-ID",
|
||||
"X-User-Agent"
|
||||
));
|
||||
|
||||
// 노출할 헤더 (클라이언트가 접근 가능한 헤더)
|
||||
corsConfig.setExposedHeaders(Arrays.asList(
|
||||
"X-Request-ID",
|
||||
"X-Response-Time",
|
||||
"X-Rate-Limit-Remaining"
|
||||
));
|
||||
|
||||
// 자격 증명 허용 (쿠키, Authorization 헤더 등)
|
||||
corsConfig.setAllowCredentials(true);
|
||||
|
||||
// Preflight 요청 캐시 시간 (초)
|
||||
corsConfig.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", corsConfig);
|
||||
|
||||
return new CorsWebFilter(source);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
package com.unicorn.phonebill.gateway.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springdoc.core.properties.SwaggerUiConfigParameters;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Swagger 통합 문서화 설정
|
||||
*
|
||||
* API Gateway를 통해 모든 마이크로서비스의 OpenAPI 문서를 통합하여 제공합니다.
|
||||
* 개발 환경에서만 활성화되며, 각 서비스별 API 문서를 중앙집중식으로 관리합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 마이크로서비스별 OpenAPI 문서 통합
|
||||
* - Swagger UI 커스터마이징
|
||||
* - JWT 인증 정보 포함
|
||||
* - 환경별 설정 (개발환경에서만 활성화)
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("!prod") // 운영환경에서는 비활성화
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Value("${services.auth-service.url:http://localhost:8081}")
|
||||
private String authServiceUrl;
|
||||
|
||||
@Value("${services.bill-service.url:http://localhost:8082}")
|
||||
private String billServiceUrl;
|
||||
|
||||
@Value("${services.product-service.url:http://localhost:8083}")
|
||||
private String productServiceUrl;
|
||||
|
||||
@Value("${services.kos-mock-service.url:http://localhost:8084}")
|
||||
private String kosMockServiceUrl;
|
||||
|
||||
/**
|
||||
* Swagger UI 설정 파라미터
|
||||
*
|
||||
* @return SwaggerUiConfigParameters
|
||||
*/
|
||||
@Bean
|
||||
public SwaggerUiConfigParameters swaggerUiConfigParameters() {
|
||||
// Spring Boot 3.x에서는 SwaggerUiConfigParameters 생성자가 변경됨
|
||||
SwaggerUiConfigParameters parameters = new SwaggerUiConfigParameters(
|
||||
new org.springdoc.core.properties.SwaggerUiConfigProperties()
|
||||
);
|
||||
|
||||
// 각 마이크로서비스의 OpenAPI 문서 URL 설정
|
||||
List<String> urls = new ArrayList<>();
|
||||
urls.add("Gateway::/v3/api-docs");
|
||||
urls.add("Auth Service::" + authServiceUrl + "/v3/api-docs");
|
||||
urls.add("Bill Service::" + billServiceUrl + "/v3/api-docs");
|
||||
urls.add("Product Service::" + productServiceUrl + "/v3/api-docs");
|
||||
urls.add("KOS Mock::" + kosMockServiceUrl + "/v3/api-docs");
|
||||
|
||||
// Spring Boot 3.x 호환성을 위한 설정
|
||||
System.setProperty("springdoc.swagger-ui.urls", String.join(",", urls));
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Gateway OpenAPI 그룹 정의
|
||||
*
|
||||
* @return GroupedOpenApi
|
||||
*/
|
||||
@Bean
|
||||
public GroupedOpenApi gatewayApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("gateway")
|
||||
.displayName("API Gateway")
|
||||
.pathsToMatch("/health/**", "/actuator/**")
|
||||
.addOpenApiCustomizer(openApi -> {
|
||||
openApi.info(new io.swagger.v3.oas.models.info.Info()
|
||||
.title("PhoneBill API Gateway")
|
||||
.version("1.0.0")
|
||||
.description("통신요금 관리 서비스 API Gateway\n\n" +
|
||||
"이 문서는 API Gateway의 헬스체크 및 관리 기능을 설명합니다.")
|
||||
);
|
||||
|
||||
// JWT 보안 스키마 추가
|
||||
openApi.addSecurityItem(
|
||||
new io.swagger.v3.oas.models.security.SecurityRequirement()
|
||||
.addList("bearerAuth")
|
||||
);
|
||||
|
||||
openApi.getComponents()
|
||||
.addSecuritySchemes("bearerAuth",
|
||||
new io.swagger.v3.oas.models.security.SecurityScheme()
|
||||
.type(io.swagger.v3.oas.models.security.SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
.description("JWT 토큰을 Authorization 헤더에 포함시켜 주세요.\n" +
|
||||
"형식: Authorization: Bearer {token}")
|
||||
);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Swagger UI 리다이렉트 라우터
|
||||
*
|
||||
* @return RouterFunction
|
||||
*/
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> swaggerRouterFunction() {
|
||||
return RouterFunctions.route()
|
||||
// 루트 경로에서 Swagger UI로 리다이렉트
|
||||
.GET("/", request ->
|
||||
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
|
||||
|
||||
// docs 경로에서 Swagger UI로 리다이렉트
|
||||
.GET("/docs", request ->
|
||||
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
|
||||
|
||||
// api-docs 경로에서 Swagger UI로 리다이렉트
|
||||
.GET("/api-docs", request ->
|
||||
ServerResponse.temporaryRedirect(URI.create("/swagger-ui.html")).build())
|
||||
|
||||
// 서비스별 API 문서 프록시
|
||||
.GET("/v3/api-docs/auth", request ->
|
||||
proxyApiDocs(authServiceUrl + "/v3/api-docs"))
|
||||
|
||||
.GET("/v3/api-docs/bills", request ->
|
||||
proxyApiDocs(billServiceUrl + "/v3/api-docs"))
|
||||
|
||||
.GET("/v3/api-docs/products", request ->
|
||||
proxyApiDocs(productServiceUrl + "/v3/api-docs"))
|
||||
|
||||
.GET("/v3/api-docs/kos", request ->
|
||||
proxyApiDocs(kosMockServiceUrl + "/v3/api-docs"))
|
||||
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* API 문서 프록시
|
||||
*
|
||||
* 각 마이크로서비스의 OpenAPI 문서를 프록시하여 제공합니다.
|
||||
*
|
||||
* @param apiDocsUrl API 문서 URL
|
||||
* @return ServerResponse
|
||||
*/
|
||||
private Mono<ServerResponse> proxyApiDocs(String apiDocsUrl) {
|
||||
// 실제 구현에서는 WebClient를 사용하여 마이크로서비스의 API 문서를 가져와야 합니다.
|
||||
// 현재는 임시로 빈 문서를 반환합니다.
|
||||
return ServerResponse.ok()
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.bodyValue("{\n" +
|
||||
" \"openapi\": \"3.0.1\",\n" +
|
||||
" \"info\": {\n" +
|
||||
" \"title\": \"Service API\",\n" +
|
||||
" \"version\": \"1.0.0\",\n" +
|
||||
" \"description\": \"마이크로서비스 API 문서\\n\\n" +
|
||||
"실제 서비스가 시작되면 상세한 API 문서가 표시됩니다.\"\n" +
|
||||
" },\n" +
|
||||
" \"paths\": {\n" +
|
||||
" \"/status\": {\n" +
|
||||
" \"get\": {\n" +
|
||||
" \"summary\": \"서비스 상태 확인\",\n" +
|
||||
" \"responses\": {\n" +
|
||||
" \"200\": {\n" +
|
||||
" \"description\": \"서비스 정상\"\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"}");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.unicorn.phonebill.gateway.config;
|
||||
|
||||
import com.unicorn.phonebill.gateway.handler.FallbackHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
/**
|
||||
* Web 설정 및 라우터 함수 정의
|
||||
*
|
||||
* Spring WebFlux의 함수형 라우팅을 사용하여 Fallback 엔드포인트를 정의합니다.
|
||||
* Circuit Breaker에서 호출할 Fallback 경로를 설정합니다.
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
private final FallbackHandler fallbackHandler;
|
||||
|
||||
public WebConfig(FallbackHandler fallbackHandler) {
|
||||
this.fallbackHandler = fallbackHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback 라우터 함수
|
||||
*
|
||||
* Circuit Breaker에서 사용할 Fallback 엔드포인트를 정의합니다.
|
||||
*
|
||||
* @return RouterFunction
|
||||
*/
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> fallbackRouterFunction() {
|
||||
return RouterFunctions.route()
|
||||
// 인증 서비스 Fallback
|
||||
.GET("/fallback/auth", fallbackHandler::authServiceFallback)
|
||||
.POST("/fallback/auth", fallbackHandler::authServiceFallback)
|
||||
|
||||
// 요금조회 서비스 Fallback
|
||||
.GET("/fallback/bill", fallbackHandler::billServiceFallback)
|
||||
.POST("/fallback/bill", fallbackHandler::billServiceFallback)
|
||||
|
||||
// 상품변경 서비스 Fallback
|
||||
.GET("/fallback/product", fallbackHandler::productServiceFallback)
|
||||
.POST("/fallback/product", fallbackHandler::productServiceFallback)
|
||||
.PUT("/fallback/product", fallbackHandler::productServiceFallback)
|
||||
|
||||
// KOS Mock 서비스 Fallback
|
||||
.GET("/fallback/kos", fallbackHandler::kosServiceFallback)
|
||||
.POST("/fallback/kos", fallbackHandler::kosServiceFallback)
|
||||
|
||||
// Rate Limit Fallback
|
||||
.GET("/fallback/ratelimit", fallbackHandler::rateLimitFallback)
|
||||
.POST("/fallback/ratelimit", fallbackHandler::rateLimitFallback)
|
||||
|
||||
// 일반 Fallback (기타 모든 경로)
|
||||
.GET("/fallback/**", fallbackHandler::genericFallback)
|
||||
.POST("/fallback/**", fallbackHandler::genericFallback)
|
||||
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.unicorn.phonebill.gateway.config;
|
||||
|
||||
import org.springframework.boot.web.codec.CodecCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.codec.ServerCodecConfigurer;
|
||||
|
||||
/**
|
||||
* WebFlux 설정
|
||||
*
|
||||
* Spring Cloud Gateway에서 필요한 WebFlux 관련 빈들을 정의합니다.
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Configuration
|
||||
public class WebFluxConfig {
|
||||
|
||||
/**
|
||||
* ServerCodecConfigurer 빈 정의
|
||||
*
|
||||
* Spring Cloud Gateway가 요구하는 ServerCodecConfigurer를 직접 정의합니다.
|
||||
*
|
||||
* @return ServerCodecConfigurer
|
||||
*/
|
||||
@Bean
|
||||
public ServerCodecConfigurer serverCodecConfigurer() {
|
||||
return ServerCodecConfigurer.create();
|
||||
}
|
||||
|
||||
/**
|
||||
* CodecCustomizer 빈 정의 (선택적)
|
||||
*
|
||||
* 필요한 경우 코덱을 커스터마이징할 수 있습니다.
|
||||
*
|
||||
* @return CodecCustomizer
|
||||
*/
|
||||
@Bean
|
||||
public CodecCustomizer codecCustomizer() {
|
||||
return configurer -> {
|
||||
// 최대 메모리 크기 설정 (기본값: 256KB)
|
||||
configurer.defaultCodecs().maxInMemorySize(1024 * 1024); // 1MB
|
||||
|
||||
// 기타 필요한 코덱 설정
|
||||
configurer.defaultCodecs().enableLoggingRequestDetails(true);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,251 @@
|
||||
package com.unicorn.phonebill.gateway.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* API Gateway 헬스체크 컨트롤러
|
||||
*
|
||||
* API Gateway와 연관된 시스템들의 상태를 점검합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - Gateway 자체 상태 확인
|
||||
* - Redis 연결 상태 확인
|
||||
* - 각 마이크로서비스 연결 상태 확인
|
||||
* - 전체 시스템 상태 요약
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
private final ReactiveRedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Autowired
|
||||
public HealthController(ReactiveRedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 헬스체크 엔드포인트
|
||||
*
|
||||
* @return 상태 응답
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> health() {
|
||||
return checkSystemHealth()
|
||||
.map(healthStatus -> {
|
||||
HttpStatus status = healthStatus.get("status").equals("UP")
|
||||
? HttpStatus.OK
|
||||
: HttpStatus.SERVICE_UNAVAILABLE;
|
||||
|
||||
return ResponseEntity.status(status).body(healthStatus);
|
||||
})
|
||||
.onErrorReturn(
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.<String, Object>of(
|
||||
"status", "DOWN",
|
||||
"error", "Health check failed",
|
||||
"timestamp", Instant.now().toString()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 헬스체크 엔드포인트
|
||||
*
|
||||
* @return 상세 상태 정보
|
||||
*/
|
||||
@GetMapping("/health/detailed")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> detailedHealth() {
|
||||
return Mono.zip(
|
||||
checkGatewayHealth(),
|
||||
checkRedisHealth(),
|
||||
checkDownstreamServices()
|
||||
).map(tuple -> {
|
||||
Map<String, Object> gatewayHealth = tuple.getT1();
|
||||
Map<String, Object> redisHealth = tuple.getT2();
|
||||
Map<String, Object> servicesHealth = tuple.getT3();
|
||||
|
||||
boolean allHealthy =
|
||||
"UP".equals(gatewayHealth.get("status")) &&
|
||||
"UP".equals(redisHealth.get("status")) &&
|
||||
"UP".equals(servicesHealth.get("status"));
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"status", allHealthy ? "UP" : "DOWN",
|
||||
"timestamp", Instant.now().toString(),
|
||||
"components", Map.of(
|
||||
"gateway", gatewayHealth,
|
||||
"redis", redisHealth,
|
||||
"services", servicesHealth
|
||||
)
|
||||
);
|
||||
|
||||
HttpStatus status = allHealthy ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
|
||||
return ResponseEntity.status(status).body(response);
|
||||
}).onErrorReturn(
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.<String, Object>of(
|
||||
"status", "DOWN",
|
||||
"error", "Detailed health check failed",
|
||||
"timestamp", Instant.now().toString()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 간단한 상태 확인 엔드포인트
|
||||
*
|
||||
* @return 상태 응답
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> status() {
|
||||
return Mono.just(ResponseEntity.ok(Map.<String, Object>of(
|
||||
"status", "UP",
|
||||
"service", "API Gateway",
|
||||
"timestamp", Instant.now().toString(),
|
||||
"version", "1.0.0"
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 시스템 상태 점검
|
||||
*
|
||||
* @return 시스템 상태
|
||||
*/
|
||||
private Mono<Map<String, Object>> checkSystemHealth() {
|
||||
return Mono.zip(
|
||||
checkGatewayHealth(),
|
||||
checkRedisHealth()
|
||||
).map(tuple -> {
|
||||
Map<String, Object> gatewayHealth = tuple.getT1();
|
||||
Map<String, Object> redisHealth = tuple.getT2();
|
||||
|
||||
boolean allHealthy =
|
||||
"UP".equals(gatewayHealth.get("status")) &&
|
||||
"UP".equals(redisHealth.get("status"));
|
||||
|
||||
return Map.<String, Object>of(
|
||||
"status", allHealthy ? "UP" : "DOWN",
|
||||
"timestamp", Instant.now().toString(),
|
||||
"version", "1.0.0",
|
||||
"uptime", getUptime()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 자체 상태 점검
|
||||
*
|
||||
* @return Gateway 상태
|
||||
*/
|
||||
private Mono<Map<String, Object>> checkGatewayHealth() {
|
||||
return Mono.fromCallable(() -> {
|
||||
// 메모리 사용량 확인
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long totalMemory = runtime.totalMemory();
|
||||
long freeMemory = runtime.freeMemory();
|
||||
long usedMemory = totalMemory - freeMemory;
|
||||
double memoryUsage = (double) usedMemory / totalMemory * 100;
|
||||
|
||||
return Map.<String, Object>of(
|
||||
"status", memoryUsage < 90 ? "UP" : "DOWN",
|
||||
"memory", Map.<String, Object>of(
|
||||
"used", usedMemory,
|
||||
"total", totalMemory,
|
||||
"usage_percent", String.format("%.2f%%", memoryUsage)
|
||||
),
|
||||
"threads", Thread.activeCount(),
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 연결 상태 점검
|
||||
*
|
||||
* @return Redis 상태
|
||||
*/
|
||||
private Mono<Map<String, Object>> checkRedisHealth() {
|
||||
return redisTemplate.hasKey("health:check")
|
||||
.timeout(Duration.ofSeconds(3))
|
||||
.map(result -> Map.<String, Object>of(
|
||||
"status", "UP",
|
||||
"connection", "OK",
|
||||
"response_time", "< 3s",
|
||||
"timestamp", Instant.now().toString()
|
||||
))
|
||||
.onErrorReturn(Map.<String, Object>of(
|
||||
"status", "DOWN",
|
||||
"connection", "FAILED",
|
||||
"error", "Connection timeout or error",
|
||||
"timestamp", Instant.now().toString()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운스트림 서비스 상태 점검
|
||||
*
|
||||
* @return 서비스 상태
|
||||
*/
|
||||
private Mono<Map<String, Object>> checkDownstreamServices() {
|
||||
// 실제 구현에서는 Circuit Breaker 상태를 확인하거나
|
||||
// 각 서비스에 대한 간단한 health check를 수행할 수 있습니다.
|
||||
return Mono.fromCallable(() -> Map.<String, Object>of(
|
||||
"status", "UP",
|
||||
"services", Map.<String, Object>of(
|
||||
"auth-service", "UNKNOWN",
|
||||
"bill-service", "UNKNOWN",
|
||||
"product-service", "UNKNOWN",
|
||||
"kos-mock-service", "UNKNOWN"
|
||||
),
|
||||
"note", "Service health checks not implemented yet",
|
||||
"timestamp", Instant.now().toString()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 업타임 계산
|
||||
*
|
||||
* @return 업타임 문자열
|
||||
*/
|
||||
private String getUptime() {
|
||||
long uptimeMs = System.currentTimeMillis() - getStartTime();
|
||||
long seconds = uptimeMs / 1000;
|
||||
long minutes = seconds / 60;
|
||||
long hours = minutes / 60;
|
||||
long days = hours / 24;
|
||||
|
||||
if (days > 0) {
|
||||
return String.format("%dd %dh %dm", days, hours % 24, minutes % 60);
|
||||
} else if (hours > 0) {
|
||||
return String.format("%dh %dm %ds", hours, minutes % 60, seconds % 60);
|
||||
} else if (minutes > 0) {
|
||||
return String.format("%dm %ds", minutes, seconds % 60);
|
||||
} else {
|
||||
return String.format("%ds", seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 시작 시간 반환 (임시 구현)
|
||||
*
|
||||
* @return 시작 시간 (밀리초)
|
||||
*/
|
||||
private long getStartTime() {
|
||||
// 실제 구현에서는 ApplicationContext에서 시작 시간을 가져와야 합니다.
|
||||
return System.currentTimeMillis() - 300000; // 임시로 5분 전으로 설정
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package com.unicorn.phonebill.gateway.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 결과 DTO
|
||||
*
|
||||
* JWT 토큰 검증 결과와 관련 정보를 담는 데이터 전송 객체입니다.
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
public class TokenValidationResult {
|
||||
|
||||
private final boolean valid;
|
||||
private final String userId;
|
||||
private final String userRole;
|
||||
private final Instant expiresAt;
|
||||
private final boolean needsRefresh;
|
||||
private final String failureReason;
|
||||
|
||||
private TokenValidationResult(boolean valid, String userId, String userRole,
|
||||
Instant expiresAt, boolean needsRefresh, String failureReason) {
|
||||
this.valid = valid;
|
||||
this.userId = userId;
|
||||
this.userRole = userRole;
|
||||
this.expiresAt = expiresAt;
|
||||
this.needsRefresh = needsRefresh;
|
||||
this.failureReason = failureReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 토큰 결과 생성
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userRole 사용자 역할
|
||||
* @param expiresAt 만료 시간
|
||||
* @param needsRefresh 갱신 필요 여부
|
||||
* @return TokenValidationResult
|
||||
*/
|
||||
public static TokenValidationResult valid(String userId, String userRole,
|
||||
Instant expiresAt, boolean needsRefresh) {
|
||||
return new TokenValidationResult(true, userId, userRole, expiresAt, needsRefresh, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 토큰 결과 생성 (갱신 불필요)
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userRole 사용자 역할
|
||||
* @param expiresAt 만료 시간
|
||||
* @return TokenValidationResult
|
||||
*/
|
||||
public static TokenValidationResult valid(String userId, String userRole, Instant expiresAt) {
|
||||
return new TokenValidationResult(true, userId, userRole, expiresAt, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효하지 않은 토큰 결과 생성
|
||||
*
|
||||
* @param failureReason 실패 원인
|
||||
* @return TokenValidationResult
|
||||
*/
|
||||
public static TokenValidationResult invalid(String failureReason) {
|
||||
return new TokenValidationResult(false, null, null, null, false, failureReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 여부
|
||||
*
|
||||
* @return 유효성 여부
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할
|
||||
*
|
||||
* @return 사용자 역할
|
||||
*/
|
||||
public String getUserRole() {
|
||||
return userRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간
|
||||
*
|
||||
* @return 만료 시간
|
||||
*/
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신 필요 여부
|
||||
*
|
||||
* @return 갱신 필요 여부
|
||||
*/
|
||||
public boolean needsRefresh() {
|
||||
return needsRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 원인
|
||||
*
|
||||
* @return 실패 원인
|
||||
*/
|
||||
public String getFailureReason() {
|
||||
return failureReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰이 유효하지 않은지 확인
|
||||
*
|
||||
* @return 무효성 여부
|
||||
*/
|
||||
public boolean isInvalid() {
|
||||
return !valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보가 있는지 확인
|
||||
*
|
||||
* @return 사용자 정보 존재 여부
|
||||
*/
|
||||
public boolean hasUserInfo() {
|
||||
return valid && userId != null && !userId.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한 확인
|
||||
*
|
||||
* @return 관리자 권한 여부
|
||||
*/
|
||||
public boolean isAdmin() {
|
||||
return valid && "ADMIN".equalsIgnoreCase(userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* VIP 사용자 확인
|
||||
*
|
||||
* @return VIP 사용자 여부
|
||||
*/
|
||||
public boolean isVipUser() {
|
||||
return valid && ("VIP".equalsIgnoreCase(userRole) || "PREMIUM".equalsIgnoreCase(userRole));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
if (valid) {
|
||||
return String.format(
|
||||
"TokenValidationResult{valid=true, userId='%s', userRole='%s', expiresAt=%s, needsRefresh=%s}",
|
||||
userId, userRole, expiresAt, needsRefresh
|
||||
);
|
||||
} else {
|
||||
return String.format(
|
||||
"TokenValidationResult{valid=false, failureReason='%s'}",
|
||||
failureReason
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package com.unicorn.phonebill.gateway.exception;
|
||||
|
||||
/**
|
||||
* API Gateway 전용 예외 클래스
|
||||
*
|
||||
* Gateway에서 발생할 수 있는 다양한 예외 상황을 표현합니다.
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
public class GatewayException extends RuntimeException {
|
||||
|
||||
private final String errorCode;
|
||||
private final int httpStatus;
|
||||
|
||||
public GatewayException(String message) {
|
||||
super(message);
|
||||
this.errorCode = "GATEWAY_ERROR";
|
||||
this.httpStatus = 500;
|
||||
}
|
||||
|
||||
public GatewayException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = "GATEWAY_ERROR";
|
||||
this.httpStatus = 500;
|
||||
}
|
||||
|
||||
public GatewayException(String errorCode, String message, int httpStatus) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public GatewayException(String errorCode, String message, int httpStatus, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public int getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 인증 관련 예외
|
||||
*/
|
||||
class JwtAuthenticationException extends GatewayException {
|
||||
|
||||
public JwtAuthenticationException(String message) {
|
||||
super("JWT_AUTH_ERROR", message, 401);
|
||||
}
|
||||
|
||||
public JwtAuthenticationException(String message, Throwable cause) {
|
||||
super("JWT_AUTH_ERROR", message, 401, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 연결 관련 예외
|
||||
*/
|
||||
class ServiceConnectionException extends GatewayException {
|
||||
|
||||
public ServiceConnectionException(String serviceName, String message) {
|
||||
super("SERVICE_CONNECTION_ERROR",
|
||||
String.format("Service '%s' connection failed: %s", serviceName, message),
|
||||
503);
|
||||
}
|
||||
|
||||
public ServiceConnectionException(String serviceName, String message, Throwable cause) {
|
||||
super("SERVICE_CONNECTION_ERROR",
|
||||
String.format("Service '%s' connection failed: %s", serviceName, message),
|
||||
503, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limit 관련 예외
|
||||
*/
|
||||
class RateLimitExceededException extends GatewayException {
|
||||
|
||||
public RateLimitExceededException(String message) {
|
||||
super("RATE_LIMIT_EXCEEDED", message, 429);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 관련 예외
|
||||
*/
|
||||
class GatewayConfigurationException extends GatewayException {
|
||||
|
||||
public GatewayConfigurationException(String message) {
|
||||
super("GATEWAY_CONFIG_ERROR", message, 500);
|
||||
}
|
||||
|
||||
public GatewayConfigurationException(String message, Throwable cause) {
|
||||
super("GATEWAY_CONFIG_ERROR", message, 500, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,197 @@
|
||||
package com.unicorn.phonebill.gateway.filter;
|
||||
|
||||
import com.unicorn.phonebill.gateway.service.JwtTokenService;
|
||||
import com.unicorn.phonebill.gateway.dto.TokenValidationResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilter;
|
||||
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* JWT 인증 Gateway Filter Factory
|
||||
*
|
||||
* Spring Cloud Gateway에서 JWT 토큰 기반 인증을 처리하는 필터입니다.
|
||||
* Authorization 헤더의 Bearer 토큰을 검증하고, 유효하지 않은 경우 요청을 차단합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - JWT 토큰 유효성 검증
|
||||
* - 토큰 만료 검사
|
||||
* - 사용자 정보 추출 및 헤더 전달
|
||||
* - 인증 실패 시 적절한 HTTP 응답 반환
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationGatewayFilterFactory
|
||||
extends AbstractGatewayFilterFactory<JwtAuthenticationGatewayFilterFactory.Config> {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationGatewayFilterFactory.class);
|
||||
|
||||
private final JwtTokenService jwtTokenService;
|
||||
|
||||
public JwtAuthenticationGatewayFilterFactory(JwtTokenService jwtTokenService) {
|
||||
super(Config.class);
|
||||
this.jwtTokenService = jwtTokenService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewayFilter apply(Config config) {
|
||||
return (exchange, chain) -> {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
String requestPath = request.getPath().value();
|
||||
String requestId = request.getHeaders().getFirst("X-Request-ID");
|
||||
|
||||
logger.debug("JWT Authentication Filter - Path: {}, Request-ID: {}", requestPath, requestId);
|
||||
|
||||
// Authorization 헤더 추출
|
||||
String authHeader = request.getHeaders().getFirst("Authorization");
|
||||
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
logger.warn("Missing or invalid Authorization header - Path: {}, Request-ID: {}",
|
||||
requestPath, requestId);
|
||||
return handleAuthenticationError(exchange, "인증 토큰이 없습니다", HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Bearer 토큰 추출
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
// JWT 토큰 검증 (비동기)
|
||||
return jwtTokenService.validateToken(token)
|
||||
.flatMap(validationResult -> {
|
||||
if (validationResult.isValid()) {
|
||||
// 인증 성공 - 사용자 정보를 헤더에 추가하여 하위 서비스로 전달
|
||||
ServerHttpRequest modifiedRequest = request.mutate()
|
||||
.header("X-User-ID", validationResult.getUserId())
|
||||
.header("X-User-Role", validationResult.getUserRole())
|
||||
.header("X-Token-Expires-At", String.valueOf(validationResult.getExpiresAt()))
|
||||
.header("X-Request-ID", requestId != null ? requestId : generateRequestId())
|
||||
.build();
|
||||
|
||||
logger.debug("JWT Authentication success - User: {}, Role: {}, Path: {}, Request-ID: {}",
|
||||
validationResult.getUserId(), validationResult.getUserRole(),
|
||||
requestPath, requestId);
|
||||
|
||||
return chain.filter(exchange.mutate().request(modifiedRequest).build());
|
||||
} else {
|
||||
// 인증 실패
|
||||
logger.warn("JWT Authentication failed - Reason: {}, Path: {}, Request-ID: {}",
|
||||
validationResult.getFailureReason(), requestPath, requestId);
|
||||
|
||||
HttpStatus status = determineHttpStatus(validationResult.getFailureReason());
|
||||
return handleAuthenticationError(exchange, validationResult.getFailureReason(), status);
|
||||
}
|
||||
})
|
||||
.onErrorResume(throwable -> {
|
||||
logger.error("JWT Authentication error - Path: {}, Request-ID: {}, Error: {}",
|
||||
requestPath, requestId, throwable.getMessage(), throwable);
|
||||
|
||||
return handleAuthenticationError(exchange, "인증 처리 중 오류가 발생했습니다",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 원인에 따른 HTTP 상태 코드 결정
|
||||
*
|
||||
* @param failureReason 실패 원인
|
||||
* @return HTTP 상태 코드
|
||||
*/
|
||||
private HttpStatus determineHttpStatus(String failureReason) {
|
||||
if (failureReason == null) {
|
||||
return HttpStatus.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
if (failureReason.contains("만료")) {
|
||||
return HttpStatus.UNAUTHORIZED;
|
||||
} else if (failureReason.contains("권한")) {
|
||||
return HttpStatus.FORBIDDEN;
|
||||
} else if (failureReason.contains("형식")) {
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
}
|
||||
|
||||
return HttpStatus.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 오류 응답 처리
|
||||
*
|
||||
* @param exchange ServerWebExchange
|
||||
* @param message 오류 메시지
|
||||
* @param status HTTP 상태 코드
|
||||
* @return Mono<Void>
|
||||
*/
|
||||
private Mono<Void> handleAuthenticationError(
|
||||
org.springframework.web.server.ServerWebExchange exchange,
|
||||
String message,
|
||||
HttpStatus status) {
|
||||
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
response.setStatusCode(status);
|
||||
response.getHeaders().add("Content-Type", MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
// 표준 오류 응답 형식
|
||||
String jsonResponse = String.format(
|
||||
"{\n" +
|
||||
" \"success\": false,\n" +
|
||||
" \"error\": {\n" +
|
||||
" \"code\": \"AUTH%03d\",\n" +
|
||||
" \"message\": \"%s\",\n" +
|
||||
" \"timestamp\": \"%s\"\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
status.value(),
|
||||
message,
|
||||
java.time.Instant.now().toString()
|
||||
);
|
||||
|
||||
DataBuffer buffer = response.bufferFactory().wrap(jsonResponse.getBytes(StandardCharsets.UTF_8));
|
||||
return response.writeWith(Mono.just(buffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 ID 생성
|
||||
*
|
||||
* @return 생성된 요청 ID
|
||||
*/
|
||||
private String generateRequestId() {
|
||||
return "REQ-" + System.currentTimeMillis() + "-" +
|
||||
(int)(Math.random() * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter 설정 클래스
|
||||
*/
|
||||
public static class Config {
|
||||
// 필요에 따라 설정 프로퍼티 추가 가능
|
||||
private boolean enabled = true;
|
||||
private String[] excludePaths = {};
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String[] getExcludePaths() {
|
||||
return excludePaths;
|
||||
}
|
||||
|
||||
public void setExcludePaths(String[] excludePaths) {
|
||||
this.excludePaths = excludePaths;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
package com.unicorn.phonebill.gateway.handler;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Circuit Breaker Fallback 핸들러
|
||||
*
|
||||
* Circuit Breaker가 Open 상태일 때 또는 서비스 호출이 실패했을 때
|
||||
* 대체 응답을 제공하는 핸들러입니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 서비스별 개별 fallback 응답
|
||||
* - 표준화된 오류 응답 형식
|
||||
* - 적절한 HTTP 상태 코드 반환
|
||||
* - 로깅 및 모니터링 지원
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Component
|
||||
public class FallbackHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FallbackHandler.class);
|
||||
|
||||
/**
|
||||
* 인증 서비스 Fallback
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> authServiceFallback(ServerRequest request) {
|
||||
logger.warn("Auth service fallback triggered - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"AUTH503",
|
||||
"인증 서비스가 일시적으로 사용할 수 없습니다",
|
||||
"잠시 후 다시 시도해 주세요",
|
||||
"auth-service"
|
||||
);
|
||||
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 서비스 Fallback
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> billServiceFallback(ServerRequest request) {
|
||||
logger.warn("Bill service fallback triggered - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"BILL503",
|
||||
"요금조회 서비스가 일시적으로 사용할 수 없습니다",
|
||||
"시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
|
||||
"bill-service"
|
||||
);
|
||||
|
||||
// 요금조회의 경우 캐시된 데이터 제공 가능한지 확인
|
||||
if (request.path().contains("/bills/menu")) {
|
||||
return provideCachedMenuData();
|
||||
}
|
||||
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품변경 서비스 Fallback
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> productServiceFallback(ServerRequest request) {
|
||||
logger.warn("Product service fallback triggered - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"PROD503",
|
||||
"상품변경 서비스가 일시적으로 사용할 수 없습니다",
|
||||
"시스템 점검 중입니다. 고객센터로 문의하시거나 잠시 후 다시 시도해 주세요",
|
||||
"product-service"
|
||||
);
|
||||
|
||||
// 상품변경 요청의 경우 더 신중한 처리 필요
|
||||
if (request.method().name().equals("POST")) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(createCriticalServiceFallback("상품변경"));
|
||||
}
|
||||
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Mock 서비스 Fallback
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> kosServiceFallback(ServerRequest request) {
|
||||
logger.warn("KOS service fallback triggered - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"KOS503",
|
||||
"외부 연동 시스템이 일시적으로 사용할 수 없습니다",
|
||||
"통신사 시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
|
||||
"kos-mock-service"
|
||||
);
|
||||
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 Fallback (알 수 없는 서비스)
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> genericFallback(ServerRequest request) {
|
||||
logger.warn("Generic fallback triggered - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"SYS503",
|
||||
"서비스가 일시적으로 사용할 수 없습니다",
|
||||
"시스템 점검 중입니다. 잠시 후 다시 시도해 주세요",
|
||||
"unknown-service"
|
||||
);
|
||||
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 Fallback 응답 생성
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 사용자 메시지
|
||||
* @param details 상세 설명
|
||||
* @param service 서비스명
|
||||
* @return JSON 형식 응답
|
||||
*/
|
||||
private String createFallbackResponse(String errorCode, String message, String details, String service) {
|
||||
return String.format(
|
||||
"{\n" +
|
||||
" \"success\": false,\n" +
|
||||
" \"error\": {\n" +
|
||||
" \"code\": \"%s\",\n" +
|
||||
" \"message\": \"%s\",\n" +
|
||||
" \"details\": \"%s\",\n" +
|
||||
" \"service\": \"%s\",\n" +
|
||||
" \"timestamp\": \"%s\",\n" +
|
||||
" \"retry_after\": \"30\"\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
errorCode,
|
||||
message,
|
||||
details,
|
||||
service,
|
||||
Instant.now().toString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중요 서비스 Fallback 응답 생성
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @return JSON 형식 응답
|
||||
*/
|
||||
private String createCriticalServiceFallback(String serviceName) {
|
||||
return String.format(
|
||||
"{\n" +
|
||||
" \"success\": false,\n" +
|
||||
" \"error\": {\n" +
|
||||
" \"code\": \"CRITICAL_SERVICE_UNAVAILABLE\",\n" +
|
||||
" \"message\": \"%s 서비스가 현재 이용할 수 없습니다\",\n" +
|
||||
" \"details\": \"중요한 작업이므로 시스템이 안정된 후 다시 시도해 주시기 바랍니다\",\n" +
|
||||
" \"action\": \"CONTACT_SUPPORT\",\n" +
|
||||
" \"support_phone\": \"1588-0000\",\n" +
|
||||
" \"timestamp\": \"%s\",\n" +
|
||||
" \"retry_after\": \"300\"\n" +
|
||||
" }\n" +
|
||||
"}",
|
||||
serviceName,
|
||||
Instant.now().toString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시된 메뉴 데이터 제공
|
||||
*
|
||||
* 요금조회 메뉴는 변경이 적으므로 캐시된 데이터를 제공할 수 있습니다.
|
||||
*
|
||||
* @return ServerResponse
|
||||
*/
|
||||
private Mono<ServerResponse> provideCachedMenuData() {
|
||||
String cachedMenuResponse =
|
||||
"{\n" +
|
||||
" \"success\": true,\n" +
|
||||
" \"message\": \"캐시된 메뉴 정보입니다\",\n" +
|
||||
" \"data\": {\n" +
|
||||
" \"menus\": [\n" +
|
||||
" {\n" +
|
||||
" \"id\": \"bill_inquiry\",\n" +
|
||||
" \"name\": \"요금조회\",\n" +
|
||||
" \"description\": \"현재 요금 정보를 조회합니다\",\n" +
|
||||
" \"available\": true\n" +
|
||||
" }\n" +
|
||||
" ]\n" +
|
||||
" },\n" +
|
||||
" \"cache_info\": {\n" +
|
||||
" \"cached\": true,\n" +
|
||||
" \"timestamp\": \"" + Instant.now().toString() + "\",\n" +
|
||||
" \"note\": \"서비스 점검 중이므로 캐시된 정보를 제공합니다\"\n" +
|
||||
" }\n" +
|
||||
"}";
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Cache", "HIT")
|
||||
.header("X-Cache-Reason", "SERVICE_UNAVAILABLE")
|
||||
.bodyValue(cachedMenuResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limit 초과 Fallback
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return ServerResponse
|
||||
*/
|
||||
public Mono<ServerResponse> rateLimitFallback(ServerRequest request) {
|
||||
logger.warn("Rate limit exceeded fallback - URI: {}", request.uri());
|
||||
|
||||
String fallbackResponse = createFallbackResponse(
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"요청 한도를 초과했습니다",
|
||||
"잠시 후 다시 시도해 주세요",
|
||||
"rate-limiter"
|
||||
);
|
||||
|
||||
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Retry-After", "60")
|
||||
.bodyValue(fallbackResponse);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.unicorn.phonebill.gateway.health;
|
||||
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* API Gateway Health Indicator
|
||||
*
|
||||
* Spring Boot Actuator의 HealthIndicator 인터페이스를 구현하여
|
||||
* API Gateway의 상태를 점검합니다.
|
||||
*
|
||||
* 주요 점검 항목:
|
||||
* - 메모리 사용률
|
||||
* - 시스템 상태
|
||||
* - 스레드 상태
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Component("gateway")
|
||||
public class GatewayHealthIndicator implements HealthIndicator {
|
||||
|
||||
/**
|
||||
* Actuator HealthIndicator 인터페이스 구현
|
||||
*
|
||||
* @return Health 상태
|
||||
*/
|
||||
@Override
|
||||
public Health health() {
|
||||
try {
|
||||
// 메모리 사용률 확인
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long totalMemory = runtime.totalMemory();
|
||||
long freeMemory = runtime.freeMemory();
|
||||
long usedMemory = totalMemory - freeMemory;
|
||||
double memoryUsage = (double) usedMemory / totalMemory * 100;
|
||||
|
||||
Health.Builder healthBuilder = Health.up()
|
||||
.withDetail("service", "API Gateway")
|
||||
.withDetail("timestamp", Instant.now().toString())
|
||||
.withDetail("memory", String.format("%.2f%%", memoryUsage))
|
||||
.withDetail("threads", Thread.activeCount())
|
||||
.withDetail("system", "Gateway routing only");
|
||||
|
||||
// 메모리 사용률이 90% 이상이면 DOWN
|
||||
if (memoryUsage >= 90.0) {
|
||||
return healthBuilder.down()
|
||||
.withDetail("status", "Memory usage too high")
|
||||
.build();
|
||||
}
|
||||
|
||||
return healthBuilder.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
return Health.down()
|
||||
.withDetail("service", "API Gateway")
|
||||
.withDetail("error", e.getMessage())
|
||||
.withDetail("timestamp", Instant.now().toString())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package com.unicorn.phonebill.gateway.service;
|
||||
|
||||
import com.unicorn.phonebill.gateway.dto.TokenValidationResult;
|
||||
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 io.jsonwebtoken.security.SignatureException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 서비스
|
||||
*
|
||||
* JWT 토큰의 유효성을 검증합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - JWT 토큰 파싱 및 서명 검증
|
||||
* - 토큰 만료 검사
|
||||
* - 사용자 정보 추출
|
||||
* - 토큰 갱신 필요 여부 판단
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
@Service
|
||||
public class JwtTokenService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtTokenService.class);
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long accessTokenValidityInSeconds;
|
||||
private final long refreshTokenValidityInSeconds;
|
||||
|
||||
public JwtTokenService(
|
||||
@Value("${app.jwt.secret}") String jwtSecret,
|
||||
@Value("${app.jwt.access-token-validity-in-seconds:1800}") long accessTokenValidityInSeconds,
|
||||
@Value("${app.jwt.refresh-token-validity-in-seconds:86400}") long refreshTokenValidityInSeconds) {
|
||||
|
||||
this.secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
this.accessTokenValidityInSeconds = accessTokenValidityInSeconds;
|
||||
this.refreshTokenValidityInSeconds = refreshTokenValidityInSeconds;
|
||||
|
||||
logger.info("JWT Token Service initialized - Access token validity: {}s, Refresh token validity: {}s",
|
||||
accessTokenValidityInSeconds, refreshTokenValidityInSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 (비동기)
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return TokenValidationResult
|
||||
*/
|
||||
public Mono<TokenValidationResult> validateToken(String token) {
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
return Mono.just(TokenValidationResult.invalid("토큰이 비어있습니다"));
|
||||
}
|
||||
|
||||
try {
|
||||
// JWT 토큰 파싱 및 서명 검증
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
// 기본 정보 추출
|
||||
String userId = claims.getSubject();
|
||||
String userRole = claims.get("role", String.class);
|
||||
Instant expiresAt = claims.getExpiration().toInstant();
|
||||
String tokenId = claims.getId(); // jti claim
|
||||
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
return Mono.just(TokenValidationResult.invalid("사용자 정보가 없습니다"));
|
||||
}
|
||||
|
||||
// 토큰 만료 검사
|
||||
if (expiresAt.isBefore(Instant.now())) {
|
||||
return Mono.just(TokenValidationResult.invalid("토큰이 만료되었습니다"));
|
||||
}
|
||||
|
||||
// 토큰 갱신 필요 여부 판단 (만료 10분 전)
|
||||
boolean needsRefresh = expiresAt.isBefore(
|
||||
Instant.now().plus(Duration.ofMinutes(10))
|
||||
);
|
||||
|
||||
return Mono.just(TokenValidationResult.valid(userId, userRole, expiresAt, needsRefresh));
|
||||
|
||||
} catch (ExpiredJwtException e) {
|
||||
logger.debug("JWT token expired: {}", e.getMessage());
|
||||
return Mono.just(TokenValidationResult.invalid("토큰이 만료되었습니다"));
|
||||
|
||||
} catch (UnsupportedJwtException e) {
|
||||
logger.debug("Unsupported JWT token: {}", e.getMessage());
|
||||
return Mono.just(TokenValidationResult.invalid("지원하지 않는 토큰 형식입니다"));
|
||||
|
||||
} catch (MalformedJwtException e) {
|
||||
logger.debug("Malformed JWT token: {}", e.getMessage());
|
||||
return Mono.just(TokenValidationResult.invalid("잘못된 토큰 형식입니다"));
|
||||
|
||||
} catch (SignatureException e) {
|
||||
logger.debug("Invalid JWT signature: {}", e.getMessage());
|
||||
return Mono.just(TokenValidationResult.invalid("토큰 서명이 유효하지 않습니다"));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.debug("Empty JWT token: {}", e.getMessage());
|
||||
return Mono.just(TokenValidationResult.invalid("토큰이 비어있습니다"));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("JWT token validation error: {}", e.getMessage(), e);
|
||||
return Mono.just(TokenValidationResult.invalid("토큰 검증 중 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 블랙리스트 기능은 API Gateway에서 제거됨
|
||||
// 필요한 경우 각 마이크로서비스에서 직접 처리
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자 ID 추출 (검증 없이)
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public String extractUserIdWithoutValidation(String token) {
|
||||
try {
|
||||
// 서명 검증 없이 클레임만 추출
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.getSubject();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to extract user ID from token: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간까지 남은 시간 계산
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @return 남은 시간 (초)
|
||||
*/
|
||||
public Long getTokenRemainingTime(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
Instant expiresAt = claims.getExpiration().toInstant();
|
||||
Duration remaining = Duration.between(Instant.now(), expiresAt);
|
||||
|
||||
return remaining.isNegative() ? 0L : remaining.getSeconds();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get token remaining time: {}", e.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
package com.unicorn.phonebill.gateway.util;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 유틸리티 클래스
|
||||
*
|
||||
* JWT 토큰 관련 유틸리티 메서드를 제공합니다.
|
||||
* 주로 디버깅이나 개발 과정에서 사용되는 헬퍼 메서드들입니다.
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-01-08
|
||||
*/
|
||||
public class JwtUtil {
|
||||
|
||||
private static final String DEFAULT_SECRET = "phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-required";
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 클레임 추출 (검증 없이)
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return Claims
|
||||
*/
|
||||
public static Claims extractClaimsWithoutVerification(String token, String secretKey) {
|
||||
try {
|
||||
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 ID 추출
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 사용자 ID
|
||||
*/
|
||||
public static String extractUserId(String token, String secretKey) {
|
||||
Claims claims = extractClaimsWithoutVerification(token, secretKey);
|
||||
return claims != null ? claims.getSubject() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 역할 추출
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 사용자 역할
|
||||
*/
|
||||
public static String extractUserRole(String token, String secretKey) {
|
||||
Claims claims = extractClaimsWithoutVerification(token, secretKey);
|
||||
return claims != null ? claims.get("role", String.class) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 만료 시간 확인
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 만료 시간
|
||||
*/
|
||||
public static Instant extractExpiration(String token, String secretKey) {
|
||||
Claims claims = extractClaimsWithoutVerification(token, secretKey);
|
||||
if (claims != null && claims.getExpiration() != null) {
|
||||
return claims.getExpiration().toInstant();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 만료 여부 확인
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 만료 여부
|
||||
*/
|
||||
public static boolean isTokenExpired(String token, String secretKey) {
|
||||
Instant expiration = extractExpiration(token, secretKey);
|
||||
return expiration != null && expiration.isBefore(Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 남은 시간 계산 (초)
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 남은 시간 (초), 만료된 경우 0
|
||||
*/
|
||||
public static long getTokenRemainingTimeSeconds(String token, String secretKey) {
|
||||
Instant expiration = extractExpiration(token, secretKey);
|
||||
if (expiration == null) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
long remainingSeconds = expiration.getEpochSecond() - Instant.now().getEpochSecond();
|
||||
return Math.max(0L, remainingSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer 토큰에서 JWT 부분만 추출
|
||||
*
|
||||
* @param bearerToken Bearer 토큰 (Authorization 헤더 값)
|
||||
* @return JWT 토큰 부분
|
||||
*/
|
||||
public static String extractJwtFromBearer(String bearerToken) {
|
||||
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰의 기본 정보 요약
|
||||
*
|
||||
* @param token JWT 토큰
|
||||
* @param secretKey 비밀키
|
||||
* @return 토큰 정보 문자열
|
||||
*/
|
||||
public static String getTokenSummary(String token, String secretKey) {
|
||||
try {
|
||||
Claims claims = extractClaimsWithoutVerification(token, secretKey);
|
||||
if (claims == null) {
|
||||
return "Invalid token";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
"User: %s, Role: %s, Expires: %s, Remaining: %d seconds",
|
||||
claims.getSubject(),
|
||||
claims.get("role", String.class),
|
||||
claims.getExpiration(),
|
||||
getTokenRemainingTimeSeconds(token, secretKey)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return "Token parsing error: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개발용 임시 토큰 생성 (테스트 목적)
|
||||
*
|
||||
* @param userId 사용자 ID
|
||||
* @param userRole 사용자 역할
|
||||
* @param validitySeconds 유효 시간 (초)
|
||||
* @return JWT 토큰
|
||||
*/
|
||||
public static String createDevelopmentToken(String userId, String userRole, long validitySeconds) {
|
||||
SecretKey key = Keys.hmacShaKeyFor(DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
Instant now = Instant.now();
|
||||
Instant expiration = now.plusSeconds(validitySeconds);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.claim("role", userRole)
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiration))
|
||||
.setId("DEV-" + System.currentTimeMillis())
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
}
|
||||
128
api-gateway/src/main/resources/application-dev.yml
Normal file
128
api-gateway/src/main/resources/application-dev.yml
Normal file
@ -0,0 +1,128 @@
|
||||
# API Gateway 개발 환경 설정
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
|
||||
# Cloud Gateway 개발환경 설정
|
||||
cloud:
|
||||
gateway:
|
||||
default-filters: []
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
allowed-origin-patterns:
|
||||
- "http://localhost:*"
|
||||
- "http://127.0.0.1:*"
|
||||
- "https://localhost:*"
|
||||
allowed-methods: "*"
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
max-age: 86400 # 24시간
|
||||
|
||||
# 개발도구 설정
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
additional-paths: src/main/java,src/main/resources
|
||||
livereload:
|
||||
enabled: true
|
||||
|
||||
# JWT 설정 (개발용 - 더 긴 유효시간)
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:dev-phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-for-development}
|
||||
access-token-validity-in-seconds: 3600 # 1시간 (개발편의성)
|
||||
refresh-token-validity-in-seconds: 172800 # 48시간 (개발편의성)
|
||||
|
||||
# Circuit Breaker 설정 (개발환경 - 더 관대한 설정)
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
auth-service-cb:
|
||||
failure-rate-threshold: 80 # 개발환경은 더 관대한 임계값
|
||||
wait-duration-in-open-state: 10s
|
||||
sliding-window-size: 5
|
||||
minimum-number-of-calls: 3
|
||||
bill-service-cb:
|
||||
failure-rate-threshold: 80
|
||||
wait-duration-in-open-state: 10s
|
||||
sliding-window-size: 5
|
||||
minimum-number-of-calls: 3
|
||||
product-service-cb:
|
||||
failure-rate-threshold: 80
|
||||
wait-duration-in-open-state: 10s
|
||||
sliding-window-size: 5
|
||||
minimum-number-of-calls: 3
|
||||
kos-mock-cb:
|
||||
failure-rate-threshold: 90
|
||||
wait-duration-in-open-state: 5s
|
||||
sliding-window-size: 5
|
||||
minimum-number-of-calls: 2
|
||||
|
||||
# Actuator 설정 (개발환경 - 모든 엔드포인트 노출)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*" # 개발환경에서는 모든 엔드포인트 노출
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always # 개발환경에서는 상세 정보 항상 표시
|
||||
shutdown:
|
||||
enabled: true # 개발환경에서만 활성화
|
||||
beans:
|
||||
enabled: true
|
||||
env:
|
||||
enabled: true
|
||||
configprops:
|
||||
enabled: true
|
||||
|
||||
# 로깅 설정 (개발환경 - 더 상세한 로그)
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:DEBUG}
|
||||
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
|
||||
org.springframework.data.redis: ${LOG_LEVEL_SPRING_DATA_REDIS:DEBUG}
|
||||
org.springframework.web.reactive: ${LOG_LEVEL_SPRING_WEB_REACTIVE:DEBUG}
|
||||
reactor.netty.http.client: ${LOG_LEVEL_REACTOR_NETTY_HTTP_CLIENT:DEBUG}
|
||||
io.netty.handler.ssl: ${LOG_LEVEL_IO_NETTY_HANDLER_SSL:WARN}
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
file:
|
||||
name: ${LOG_FILE:logs/api-gateway.log}
|
||||
max-size: ${LOG_FILE_MAX_SIZE:100MB}
|
||||
max-history: ${LOG_FILE_MAX_HISTORY:7}
|
||||
|
||||
# OpenAPI 설정 (개발환경)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
try-it-out-enabled: true # 개발환경에서 Try it out 활성화
|
||||
urls:
|
||||
- name: Auth Service (Dev)
|
||||
url: http://localhost:8081/v3/api-docs
|
||||
- name: Bill Service (Dev)
|
||||
url: http://localhost:8082/v3/api-docs
|
||||
- name: Product Service (Dev)
|
||||
url: http://localhost:8083/v3/api-docs
|
||||
- name: KOS Mock Service (Dev)
|
||||
url: http://localhost:8084/v3/api-docs
|
||||
|
||||
# CORS 설정 (개발환경 - 더 관대한 설정) - 이미 위에서 설정됨
|
||||
|
||||
# 개발환경 특성 설정
|
||||
debug: false
|
||||
trace: false
|
||||
|
||||
# 애플리케이션 정보 (개발환경)
|
||||
info:
|
||||
app:
|
||||
environment: development
|
||||
debug-mode: enabled
|
||||
hot-reload: enabled
|
||||
219
api-gateway/src/main/resources/application-prod.yml
Normal file
219
api-gateway/src/main/resources/application-prod.yml
Normal file
@ -0,0 +1,219 @@
|
||||
# API Gateway 운영 환경 설정
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
netty:
|
||||
connection-timeout: 20s
|
||||
idle-timeout: 30s
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain
|
||||
http2:
|
||||
enabled: true
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: prod
|
||||
|
||||
# Redis 설정 (운영용) - 현재 사용하지 않음
|
||||
# data:
|
||||
# redis:
|
||||
# host: ${REDIS_HOST:redis-cluster.unicorn.com}
|
||||
# port: ${REDIS_PORT:6379}
|
||||
# database: ${REDIS_DATABASE:0}
|
||||
# password: ${REDIS_PASSWORD}
|
||||
# timeout: 2000ms
|
||||
# ssl: true # 운영환경에서는 SSL 사용
|
||||
# lettuce:
|
||||
# pool:
|
||||
# max-active: 20
|
||||
# max-wait: 2000ms
|
||||
# max-idle: 10
|
||||
# min-idle: 5
|
||||
# cluster:
|
||||
# refresh:
|
||||
# adaptive: true
|
||||
# period: 30s
|
||||
|
||||
# Cloud Gateway 운영환경 설정
|
||||
cloud:
|
||||
gateway:
|
||||
default-filters:
|
||||
# - name: RequestRateLimiter # Redis 사용하지 않으므로 주석처리
|
||||
# args:
|
||||
# redis-rate-limiter.replenishRate: 500 # 운영환경 적정 한도
|
||||
# redis-rate-limiter.burstCapacity: 1000
|
||||
# key-resolver: "#{@userKeyResolver}"
|
||||
- name: RequestSize
|
||||
args:
|
||||
maxSize: 5MB # 요청 크기 제한
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
allowed-origins:
|
||||
- "https://app.phonebill.com"
|
||||
- "https://admin.phonebill.com"
|
||||
- "https://*.unicorn.com"
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
allowed-headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- X-Requested-With
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
|
||||
# JWT 설정 (운영용 - 보안 강화)
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET} # 환경변수에서 주입 (필수)
|
||||
access-token-validity-in-seconds: 1800 # 30분 (보안 강화)
|
||||
refresh-token-validity-in-seconds: 86400 # 24시간
|
||||
|
||||
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
auth-service-cb:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 60
|
||||
slow-call-duration-threshold: 3s
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 100
|
||||
minimum-number-of-calls: 20
|
||||
permitted-number-of-calls-in-half-open-state: 10
|
||||
bill-service-cb:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 60
|
||||
slow-call-duration-threshold: 5s
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 100
|
||||
minimum-number-of-calls: 20
|
||||
product-service-cb:
|
||||
failure-rate-threshold: 50
|
||||
slow-call-rate-threshold: 60
|
||||
slow-call-duration-threshold: 5s
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 100
|
||||
minimum-number-of-calls: 20
|
||||
kos-mock-cb:
|
||||
failure-rate-threshold: 60
|
||||
slow-call-rate-threshold: 70
|
||||
slow-call-duration-threshold: 10s
|
||||
wait-duration-in-open-state: 60s
|
||||
sliding-window-size: 50
|
||||
minimum-number-of-calls: 10
|
||||
|
||||
retry:
|
||||
instances:
|
||||
default:
|
||||
max-attempts: 3
|
||||
wait-duration: 1s
|
||||
exponential-backoff-multiplier: 2
|
||||
retry-exceptions:
|
||||
- java.net.ConnectException
|
||||
- java.net.SocketTimeoutException
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
|
||||
# Actuator 설정 (운영환경 - 보안 강화)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus,gateway # 필요한 것만 노출
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never # 운영환경에서는 상세 정보 숨김
|
||||
show-components: never
|
||||
gateway:
|
||||
enabled: true
|
||||
shutdown:
|
||||
enabled: false # 운영환경에서는 비활성화
|
||||
health:
|
||||
redis:
|
||||
enabled: true
|
||||
circuitbreakers:
|
||||
enabled: true
|
||||
info:
|
||||
env:
|
||||
enabled: false # 환경 정보 숨김
|
||||
java:
|
||||
enabled: true
|
||||
build:
|
||||
enabled: true
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
# 로깅 설정 (운영환경 - 성능 고려)
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.phonebill.gateway: INFO
|
||||
org.springframework.cloud.gateway: WARN
|
||||
reactor.netty: WARN
|
||||
io.netty: WARN
|
||||
root: WARN
|
||||
file:
|
||||
name: /var/log/api-gateway/api-gateway.log
|
||||
max-size: 500MB
|
||||
max-history: 30
|
||||
pattern:
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||
loggers:
|
||||
org.springframework.security: WARN
|
||||
org.springframework.web: WARN
|
||||
|
||||
# OpenAPI 설정 (운영환경 - 비활성화)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false # 운영환경에서는 비활성화
|
||||
swagger-ui:
|
||||
enabled: false # 운영환경에서는 비활성화
|
||||
|
||||
# CORS 설정은 위의 spring.cloud.gateway 섹션에서 설정됨
|
||||
|
||||
# 보안 설정
|
||||
security:
|
||||
headers:
|
||||
frame:
|
||||
deny: true
|
||||
content-type:
|
||||
nosniff: true
|
||||
xss:
|
||||
protection: true
|
||||
|
||||
# JVM 튜닝 (운영환경)
|
||||
jvm:
|
||||
heap:
|
||||
initial: 512m
|
||||
maximum: 1024m
|
||||
gc:
|
||||
algorithm: G1GC
|
||||
options:
|
||||
- "-server"
|
||||
- "-XX:+UseG1GC"
|
||||
- "-XX:G1HeapRegionSize=16m"
|
||||
- "-XX:+UseStringDeduplication"
|
||||
- "-XX:+OptimizeStringConcat"
|
||||
- "-XX:+UnlockExperimentalVMOptions"
|
||||
- "-XX:+UseJVMCICompiler"
|
||||
|
||||
# 모니터링 및 트레이싱
|
||||
tracing:
|
||||
enabled: true
|
||||
sampling:
|
||||
probability: 0.1 # 10% 샘플링
|
||||
zipkin:
|
||||
base-url: ${ZIPKIN_BASE_URL:http://zipkin.monitoring.unicorn.com:9411}
|
||||
|
||||
# 애플리케이션 정보 (운영환경)
|
||||
info:
|
||||
app:
|
||||
environment: production
|
||||
security-level: high
|
||||
monitoring: enabled
|
||||
186
api-gateway/src/main/resources/application.yml
Normal file
186
api-gateway/src/main/resources/application.yml
Normal file
@ -0,0 +1,186 @@
|
||||
# API Gateway 기본 설정
|
||||
# Spring Boot 3.2 + Spring Cloud Gateway
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
netty:
|
||||
connection-timeout: ${SERVER_NETTY_CONNECTION_TIMEOUT:30s}
|
||||
idle-timeout: ${SERVER_NETTY_IDLE_TIMEOUT:60s}
|
||||
http2:
|
||||
enabled: ${SERVER_HTTP2_ENABLED:true}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: api-gateway
|
||||
|
||||
profiles:
|
||||
active: dev
|
||||
|
||||
# Spring Cloud Gateway 설정
|
||||
cloud:
|
||||
gateway:
|
||||
default-filters:
|
||||
- name: AddRequestHeader
|
||||
args:
|
||||
name: X-Gateway-Request
|
||||
value: API-Gateway
|
||||
- name: AddResponseHeader
|
||||
args:
|
||||
name: X-Gateway-Response
|
||||
value: API-Gateway
|
||||
|
||||
# Global CORS 설정
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
allowed-origin-patterns: "*"
|
||||
allowed-methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
- HEAD
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
|
||||
# Discovery 설정 비활성화 (직접 라우팅 사용)
|
||||
discovery:
|
||||
locator:
|
||||
enabled: false
|
||||
|
||||
|
||||
# JSON 설정
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
# JWT 설정
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:phonebill-api-gateway-jwt-secret-key-256-bit-minimum-length-required}
|
||||
access-token-validity-in-seconds: 1800 # 30분
|
||||
refresh-token-validity-in-seconds: 86400 # 24시간
|
||||
|
||||
# 서비스 URL 설정
|
||||
services:
|
||||
auth-service:
|
||||
url: ${AUTH_SERVICE_URL:http://localhost:8081}
|
||||
bill-service:
|
||||
url: ${BILL_SERVICE_URL:http://localhost:8082}
|
||||
product-service:
|
||||
url: ${PRODUCT_SERVICE_URL:http://localhost:8083}
|
||||
kos-mock-service:
|
||||
url: ${KOS_MOCK_SERVICE_URL:http://localhost:8084}
|
||||
|
||||
# Circuit Breaker 설정
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
instances:
|
||||
auth-service-cb:
|
||||
failure-rate-threshold: 50
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
bill-service-cb:
|
||||
failure-rate-threshold: 60
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 20
|
||||
minimum-number-of-calls: 10
|
||||
product-service-cb:
|
||||
failure-rate-threshold: 60
|
||||
wait-duration-in-open-state: 30s
|
||||
sliding-window-size: 20
|
||||
minimum-number-of-calls: 10
|
||||
kos-mock-cb:
|
||||
failure-rate-threshold: 70
|
||||
wait-duration-in-open-state: 10s
|
||||
sliding-window-size: 10
|
||||
minimum-number-of-calls: 5
|
||||
|
||||
retry:
|
||||
instances:
|
||||
default:
|
||||
max-attempts: 3
|
||||
wait-duration: 2s
|
||||
exponential-backoff-multiplier: 2
|
||||
retry-exceptions:
|
||||
- java.net.ConnectException
|
||||
- java.net.SocketTimeoutException
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
|
||||
# Actuator 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,gateway
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
show-components: always
|
||||
gateway:
|
||||
enabled: true
|
||||
health:
|
||||
redis:
|
||||
enabled: true
|
||||
circuitbreakers:
|
||||
enabled: true
|
||||
info:
|
||||
env:
|
||||
enabled: true
|
||||
java:
|
||||
enabled: true
|
||||
build:
|
||||
enabled: true
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.phonebill.gateway: ${LOG_LEVEL_GATEWAY:INFO}
|
||||
org.springframework.cloud.gateway: ${LOG_LEVEL_SPRING_CLOUD_GATEWAY:DEBUG}
|
||||
reactor.netty: ${LOG_LEVEL_REACTOR_NETTY:INFO}
|
||||
io.netty: ${LOG_LEVEL_IO_NETTY:WARN}
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
file:
|
||||
name: ${LOG_FILE:logs/api-gateway.log}
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
max-history: 7
|
||||
total-size-cap: 100MB
|
||||
pattern:
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan([%X{traceId:-},%X{spanId:-}]) %logger{36} - %msg%n"
|
||||
|
||||
# OpenAPI 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
urls:
|
||||
- name: Auth Service
|
||||
url: /v3/api-docs/auth
|
||||
- name: Bill Service
|
||||
url: /v3/api-docs/bills
|
||||
- name: Product Service
|
||||
url: /v3/api-docs/products
|
||||
|
||||
# 애플리케이션 정보
|
||||
info:
|
||||
app:
|
||||
name: PhoneBill API Gateway
|
||||
description: 통신요금 관리 서비스 API Gateway
|
||||
version: 1.0.0
|
||||
encoding: UTF-8
|
||||
java:
|
||||
version: 17
|
||||
86
bill-service/.run/bill-service.run.xml
Normal file
86
bill-service/.run/bill-service.run.xml
Normal file
@ -0,0 +1,86 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="bill-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Connection -->
|
||||
<entry key="DB_HOST" value="20.249.175.46" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
<entry key="DB_NAME" value="bill_inquiry_db" />
|
||||
<entry key="DB_USERNAME" value="bill_inquiry_user" />
|
||||
<entry key="DB_PASSWORD" value="BillUser2025!" />
|
||||
<entry key="DB_KIND" value="postgresql" />
|
||||
|
||||
<!-- Redis Connection -->
|
||||
<entry key="REDIS_HOST" value="20.249.193.103" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
|
||||
<entry key="REDIS_DATABASE" value="1" />
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<entry key="SERVER_PORT" value="8082" />
|
||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||
|
||||
<!-- JWT Configuration -->
|
||||
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
|
||||
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="JPA_DDL_AUTO" value="update" />
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<entry key="LOG_FILE_NAME" value="logs/bill-service.log" />
|
||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||
|
||||
<!-- KOS Mock URL -->
|
||||
<entry key="KOS_BASE_URL" value="http://localhost:8084" />
|
||||
|
||||
<!-- Development optimized settings -->
|
||||
<entry key="JPA_SHOW_SQL" value="true" />
|
||||
<entry key="JPA_FORMAT_SQL" value="true" />
|
||||
<entry key="JPA_SQL_COMMENTS" value="true" />
|
||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
||||
<entry key="LOG_LEVEL_SERVICE" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_REPOSITORY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_WEB" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_CACHE" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||
|
||||
<!-- Connection Pool Settings -->
|
||||
<entry key="DB_MIN_IDLE" value="5" />
|
||||
<entry key="DB_MAX_POOL" value="20" />
|
||||
<entry key="DB_CONNECTION_TIMEOUT" value="30000" />
|
||||
<entry key="DB_IDLE_TIMEOUT" value="600000" />
|
||||
<entry key="DB_MAX_LIFETIME" value="1800000" />
|
||||
<entry key="DB_LEAK_DETECTION" value="60000" />
|
||||
|
||||
<!-- Redis Pool Settings -->
|
||||
<entry key="REDIS_MAX_ACTIVE" value="8" />
|
||||
<entry key="REDIS_MAX_IDLE" value="8" />
|
||||
<entry key="REDIS_MIN_IDLE" value="0" />
|
||||
<entry key="REDIS_MAX_WAIT" value="-1" />
|
||||
<entry key="REDIS_TIMEOUT" value="2000" />
|
||||
</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="bill-service:bootRun" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" value="-Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<ForceTestExec>false</ForceTestExec>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
292
bill-service/README.md
Normal file
292
bill-service/README.md
Normal file
@ -0,0 +1,292 @@
|
||||
# Bill Service - 통신요금 조회 서비스
|
||||
|
||||
통신요금 관리 시스템의 요금조회 마이크로서비스입니다.
|
||||
|
||||
## 📋 서비스 개요
|
||||
|
||||
- **서비스명**: Bill Service (요금조회 서비스)
|
||||
- **포트**: 8081
|
||||
- **컨텍스트 패스**: /bill-service
|
||||
- **버전**: 1.0.0
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||
### 기술 스택
|
||||
- **Java**: 17
|
||||
- **Spring Boot**: 3.2
|
||||
- **Spring Security**: JWT 기반 인증
|
||||
- **Spring Data JPA**: 데이터 접근 계층
|
||||
- **MySQL**: 8.0+
|
||||
- **Redis**: 캐시 서버
|
||||
- **Resilience4j**: Circuit Breaker, Retry, TimeLimiter
|
||||
- **Swagger/OpenAPI**: API 문서화
|
||||
|
||||
### 주요 패턴
|
||||
- **Layered Architecture**: Controller → Service → Repository
|
||||
- **Circuit Breaker Pattern**: 외부 시스템 장애 격리
|
||||
- **Cache-Aside Pattern**: Redis를 통한 성능 최적화
|
||||
- **Async Pattern**: 이력 저장 비동기 처리
|
||||
|
||||
## 🚀 주요 기능
|
||||
|
||||
### 1. 요금조회 메뉴 (GET /api/bills/menu)
|
||||
- 고객 정보 및 조회 가능한 월 목록 제공
|
||||
- 캐시를 통한 빠른 응답
|
||||
|
||||
### 2. 요금조회 신청 (POST /api/bills/inquiry)
|
||||
- 실시간 요금 정보 조회
|
||||
- KOS 시스템 연동
|
||||
- Circuit Breaker를 통한 장애 격리
|
||||
- 비동기 이력 저장
|
||||
|
||||
### 3. 요금조회 결과 확인 (GET /api/bills/inquiry/{requestId})
|
||||
- 비동기 처리된 요금조회 결과 확인
|
||||
- 처리 상태별 응답 제공
|
||||
|
||||
### 4. 요금조회 이력 (GET /api/bills/history)
|
||||
- 사용자별 요금조회 이력 목록
|
||||
- 페이징, 필터링 지원
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
bill-service/
|
||||
├── src/main/java/com/phonebill/bill/
|
||||
│ ├── BillServiceApplication.java # 메인 애플리케이션
|
||||
│ ├── common/ # 공통 컴포넌트
|
||||
│ │ ├── entity/BaseTimeEntity.java # 기본 엔티티
|
||||
│ │ └── response/ApiResponse.java # API 응답 래퍼
|
||||
│ ├── config/ # 설정 클래스
|
||||
│ │ ├── CircuitBreakerConfig.java # Circuit Breaker 설정
|
||||
│ │ ├── KosProperties.java # KOS 연동 설정
|
||||
│ │ ├── RedisConfig.java # Redis 캐시 설정
|
||||
│ │ ├── RestTemplateConfig.java # HTTP 클라이언트 설정
|
||||
│ │ └── SecurityConfig.java # Spring Security 설정
|
||||
│ ├── controller/ # REST 컨트롤러
|
||||
│ │ └── BillController.java # 요금조회 API
|
||||
│ ├── dto/ # 데이터 전송 객체
|
||||
│ │ ├── BillHistoryResponse.java # 이력 응답
|
||||
│ │ ├── BillInquiryRequest.java # 조회 요청
|
||||
│ │ ├── BillInquiryResponse.java # 조회 응답
|
||||
│ │ └── BillMenuResponse.java # 메뉴 응답
|
||||
│ ├── exception/ # 예외 처리
|
||||
│ │ ├── BillInquiryException.java # 요금조회 예외
|
||||
│ │ ├── BusinessException.java # 비즈니스 예외
|
||||
│ │ ├── CircuitBreakerException.java # Circuit Breaker 예외
|
||||
│ │ ├── GlobalExceptionHandler.java # 전역 예외 핸들러
|
||||
│ │ └── KosConnectionException.java # KOS 연동 예외
|
||||
│ ├── repository/ # 데이터 접근 계층
|
||||
│ │ ├── BillInquiryHistoryRepository.java # 이력 리포지토리
|
||||
│ │ └── entity/
|
||||
│ │ └── BillInquiryHistoryEntity.java # 이력 엔티티
|
||||
│ ├── service/ # 비즈니스 로직
|
||||
│ │ ├── BillCacheService.java # 캐시 서비스
|
||||
│ │ ├── BillHistoryService.java # 이력 서비스
|
||||
│ │ ├── BillInquiryService.java # 조회 서비스 인터페이스
|
||||
│ │ ├── BillInquiryServiceImpl.java # 조회 서비스 구현
|
||||
│ │ └── KosClientService.java # KOS 연동 서비스
|
||||
│ └── model/ # 외부 시스템 모델
|
||||
│ ├── KosRequest.java # KOS 요청
|
||||
│ └── KosResponse.java # KOS 응답
|
||||
└── src/main/resources/
|
||||
├── application.yml # 기본 설정
|
||||
├── application-dev.yml # 개발환경 설정
|
||||
└── application-prod.yml # 운영환경 설정
|
||||
```
|
||||
|
||||
## 🔧 설치 및 실행
|
||||
|
||||
### 사전 요구사항
|
||||
- Java 17
|
||||
- MySQL 8.0+
|
||||
- Redis 6.0+
|
||||
- Maven 3.8+
|
||||
|
||||
### 데이터베이스 설정
|
||||
```sql
|
||||
-- 데이터베이스 생성
|
||||
CREATE DATABASE bill_service_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE DATABASE bill_service_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 사용자 생성 및 권한 부여
|
||||
CREATE USER 'dev_user'@'%' IDENTIFIED BY 'dev_pass';
|
||||
GRANT ALL PRIVILEGES ON bill_service_dev.* TO 'dev_user'@'%';
|
||||
|
||||
CREATE USER 'bill_user'@'%' IDENTIFIED BY 'bill_pass';
|
||||
GRANT ALL PRIVILEGES ON bill_service_prod.* TO 'bill_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### 테이블 생성
|
||||
```sql
|
||||
-- 요금조회 이력 테이블
|
||||
CREATE TABLE bill_inquiry_history (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
request_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
line_number VARCHAR(20) NOT NULL,
|
||||
inquiry_month VARCHAR(7) NOT NULL,
|
||||
request_time DATETIME(6) NOT NULL,
|
||||
process_time DATETIME(6),
|
||||
status VARCHAR(20) NOT NULL,
|
||||
result_summary TEXT,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
INDEX idx_line_number (line_number),
|
||||
INDEX idx_inquiry_month (inquiry_month),
|
||||
INDEX idx_request_time (request_time),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 애플리케이션 실행
|
||||
|
||||
#### 개발환경 실행
|
||||
```bash
|
||||
# 소스 컴파일 및 실행
|
||||
./mvnw clean compile
|
||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
|
||||
# 또는 JAR 실행
|
||||
./mvnw clean package
|
||||
java -jar target/bill-service-1.0.0.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
#### 운영환경 실행
|
||||
```bash
|
||||
java -Xms2g -Xmx4g \
|
||||
-XX:+UseG1GC \
|
||||
-XX:MaxGCPauseMillis=200 \
|
||||
-XX:+HeapDumpOnOutOfMemoryError \
|
||||
-XX:HeapDumpPath=/app/logs/heap-dump.hprof \
|
||||
-Djava.security.egd=file:/dev/./urandom \
|
||||
-Dspring.profiles.active=prod \
|
||||
-jar bill-service-1.0.0.jar
|
||||
```
|
||||
|
||||
## 🔗 API 문서
|
||||
|
||||
### Swagger UI
|
||||
- **개발환경**: http://localhost:8081/bill-service/swagger-ui.html
|
||||
- **API Docs**: http://localhost:8081/bill-service/v3/api-docs
|
||||
|
||||
### 주요 API 엔드포인트
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/bills/menu` | 요금조회 메뉴 조회 |
|
||||
| POST | `/api/bills/inquiry` | 요금조회 신청 |
|
||||
| GET | `/api/bills/inquiry/{requestId}` | 요금조회 결과 확인 |
|
||||
| GET | `/api/bills/history` | 요금조회 이력 목록 |
|
||||
|
||||
## 📊 모니터링
|
||||
|
||||
### Health Check
|
||||
- **URL**: http://localhost:8081/bill-service/actuator/health
|
||||
- **상태**: Database, Redis, Disk Space 상태 확인
|
||||
|
||||
### Metrics
|
||||
- **Prometheus**: http://localhost:8081/bill-service/actuator/prometheus
|
||||
- **Metrics**: http://localhost:8081/bill-service/actuator/metrics
|
||||
|
||||
### 로그 파일
|
||||
- **개발환경**: `logs/bill-service-dev.log`
|
||||
- **운영환경**: `logs/bill-service.log`
|
||||
|
||||
## ⚙️ 환경변수 설정
|
||||
|
||||
### 필수 환경변수 (운영환경)
|
||||
```bash
|
||||
# 데이터베이스 연결 정보
|
||||
export DB_URL="jdbc:mysql://prod-db-host:3306/bill_service_prod"
|
||||
export DB_USERNAME="bill_user"
|
||||
export DB_PASSWORD="secure_password"
|
||||
|
||||
# Redis 연결 정보
|
||||
export REDIS_HOST="prod-redis-host"
|
||||
export REDIS_PASSWORD="redis_password"
|
||||
|
||||
# KOS 시스템 연동
|
||||
export KOS_BASE_URL="https://kos-system.company.com"
|
||||
export KOS_API_KEY="production_api_key"
|
||||
export KOS_SECRET_KEY="production_secret_key"
|
||||
```
|
||||
|
||||
## 🚀 배포 가이드
|
||||
|
||||
### Docker 배포
|
||||
```dockerfile
|
||||
FROM openjdk:17-jre-slim
|
||||
COPY target/bill-service-1.0.0.jar app.jar
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["java", "-jar", "/app.jar"]
|
||||
```
|
||||
|
||||
### Kubernetes 배포
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bill-service
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bill-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bill-service
|
||||
spec:
|
||||
containers:
|
||||
- name: bill-service
|
||||
image: bill-service:1.0.0
|
||||
ports:
|
||||
- containerPort: 8081
|
||||
env:
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
value: "prod"
|
||||
```
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### 캐시 전략
|
||||
- **요금 데이터**: 1시간 TTL
|
||||
- **고객 정보**: 4시간 TTL
|
||||
- **조회 가능 월**: 24시간 TTL
|
||||
|
||||
### Circuit Breaker 설정
|
||||
- **실패율 임계값**: 50%
|
||||
- **응답시간 임계값**: 10초
|
||||
- **Open 상태 유지**: 60초
|
||||
|
||||
### 데이터베이스 최적화
|
||||
- 커넥션 풀 최대 크기: 50 (운영환경)
|
||||
- 배치 처리 활성화
|
||||
- 쿼리 인덱스 최적화
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### 일반적인 문제들
|
||||
|
||||
1. **데이터베이스 연결 실패**
|
||||
- 연결 정보 확인
|
||||
- 방화벽 설정 확인
|
||||
- 데이터베이스 서비스 상태 확인
|
||||
|
||||
2. **Redis 연결 실패**
|
||||
- Redis 서비스 상태 확인
|
||||
- 네트워크 연결 확인
|
||||
- 인증 정보 확인
|
||||
|
||||
3. **KOS 시스템 연동 실패**
|
||||
- Circuit Breaker 상태 확인
|
||||
- API 키/시크릿 키 확인
|
||||
- 네트워크 연결 확인
|
||||
|
||||
## 👥 개발팀
|
||||
|
||||
- **Backend Developer**: 이개발(백엔더)
|
||||
- **Email**: dev@phonebill.com
|
||||
- **Version**: 1.0.0
|
||||
- **Last Updated**: 2025-09-08
|
||||
96
bill-service/build.gradle
Normal file
96
bill-service/build.gradle
Normal file
@ -0,0 +1,96 @@
|
||||
// bill-service 모듈
|
||||
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
|
||||
|
||||
plugins {
|
||||
id 'jacoco'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Database (bill service specific)
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
implementation 'com.zaxxer:HikariCP:5.0.1'
|
||||
|
||||
// Redis (bill service specific)
|
||||
implementation 'redis.clients:jedis:4.4.6'
|
||||
|
||||
// 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'
|
||||
|
||||
// Logging (bill service specific)
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
implementation 'ch.qos.logback:logback-classic'
|
||||
|
||||
// HTTP Client
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
|
||||
// Common modules (로컬 의존성)
|
||||
implementation project(':common')
|
||||
|
||||
// Test Dependencies (bill service specific)
|
||||
testImplementation 'org.testcontainers:postgresql'
|
||||
testImplementation 'redis.embedded:embedded-redis:0.7.3'
|
||||
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.0'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
finalizedBy jacocoTestReport
|
||||
}
|
||||
|
||||
jacocoTestReport {
|
||||
dependsOn test
|
||||
reports {
|
||||
xml.required = true
|
||||
csv.required = false
|
||||
html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
|
||||
}
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.8"
|
||||
}
|
||||
|
||||
jacocoTestCoverageVerification {
|
||||
violationRules {
|
||||
rule {
|
||||
limit {
|
||||
minimum = 0.80
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
springBoot {
|
||||
buildInfo()
|
||||
}
|
||||
|
||||
// 환경별 실행 프로필 설정
|
||||
task runDev(type: JavaExec, dependsOn: 'classes') {
|
||||
group = 'application'
|
||||
description = 'Run the application with dev profile'
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
mainClass = 'com.phonebill.bill.BillServiceApplication'
|
||||
systemProperty 'spring.profiles.active', 'dev'
|
||||
}
|
||||
|
||||
task runProd(type: JavaExec, dependsOn: 'classes') {
|
||||
group = 'application'
|
||||
description = 'Run the application with prod profile'
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
mainClass = 'com.phonebill.bill.BillServiceApplication'
|
||||
systemProperty 'spring.profiles.active', 'prod'
|
||||
}
|
||||
|
||||
// JAR 파일명 설정
|
||||
jar {
|
||||
enabled = false
|
||||
archiveBaseName = 'bill-service'
|
||||
}
|
||||
|
||||
bootJar {
|
||||
enabled = true
|
||||
archiveBaseName = 'bill-service'
|
||||
archiveClassifier = ''
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.phonebill.bill;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
/**
|
||||
* Bill Service 메인 애플리케이션 클래스
|
||||
*
|
||||
* 통신요금 조회 서비스의 메인 진입점
|
||||
* - 요금조회 메뉴 제공
|
||||
* - KOS 시스템 연동을 통한 요금 데이터 조회
|
||||
* - Redis 캐싱을 통한 성능 최적화
|
||||
* - Circuit Breaker를 통한 외부 시스템 장애 격리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
@EnableAsync
|
||||
@EnableTransactionManagement
|
||||
public class BillServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BillServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
package com.phonebill.bill.config;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.github.resilience4j.retry.RetryRegistry;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiter;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterRegistry;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Circuit Breaker 패턴 설정
|
||||
*
|
||||
* Resilience4j를 활용한 장애 격리 및 복구 시스템 구성
|
||||
* - KOS 시스템 연동 시 장애 상황에 대한 자동 회복
|
||||
* - 실패율 기반 Circuit Breaker
|
||||
* - 응답 시간 기반 Time Limiter
|
||||
* - 재시도 정책 구성
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class CircuitBreakerConfig {
|
||||
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동용 Circuit Breaker 구성
|
||||
*
|
||||
* @return Circuit Breaker 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public CircuitBreakerRegistry circuitBreakerRegistry() {
|
||||
log.info("Circuit Breaker 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 Circuit Breaker 설정
|
||||
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig kosCircuitBreakerConfig =
|
||||
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
|
||||
// 실패율 임계값 (50%)
|
||||
.failureRateThreshold(kosProperties.getCircuitBreaker().getFailureRateThreshold() * 100)
|
||||
// 느린 호출 임계값 (10초)
|
||||
.slowCallDurationThreshold(Duration.ofMillis(
|
||||
kosProperties.getCircuitBreaker().getSlowCallDurationThreshold()))
|
||||
// 느린 호출 비율 임계값 (50%)
|
||||
.slowCallRateThreshold(kosProperties.getCircuitBreaker().getSlowCallRateThreshold() * 100)
|
||||
// 슬라이딩 윈도우 크기 (10회)
|
||||
.slidingWindowSize(kosProperties.getCircuitBreaker().getSlidingWindowSize())
|
||||
// 슬라이딩 윈도우 타입 (횟수 기반)
|
||||
.slidingWindowType(SlidingWindowType.COUNT_BASED)
|
||||
// 최소 호출 수 (5회)
|
||||
.minimumNumberOfCalls(kosProperties.getCircuitBreaker().getMinimumNumberOfCalls())
|
||||
// Half-Open 상태에서 허용되는 호출 수 (3회)
|
||||
.permittedNumberOfCallsInHalfOpenState(
|
||||
kosProperties.getCircuitBreaker().getPermittedNumberOfCallsInHalfOpenState())
|
||||
// Open 상태 유지 시간 (60초)
|
||||
.waitDurationInOpenState(Duration.ofMillis(
|
||||
kosProperties.getCircuitBreaker().getWaitDurationInOpenState()))
|
||||
// Circuit Breaker 상태 변경 이벤트 리스너
|
||||
.recordExceptions(Exception.class)
|
||||
.ignoreExceptions()
|
||||
.build();
|
||||
|
||||
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(kosCircuitBreakerConfig);
|
||||
|
||||
// KOS Circuit Breaker 등록
|
||||
CircuitBreaker kosCircuitBreaker = registry.circuitBreaker("kos-system", kosCircuitBreakerConfig);
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
kosCircuitBreaker.getEventPublisher()
|
||||
.onStateTransition(event -> {
|
||||
log.warn("Circuit Breaker 상태 변경 - From: {}, To: {}",
|
||||
event.getStateTransition().getFromState(),
|
||||
event.getStateTransition().getToState());
|
||||
})
|
||||
.onCallNotPermitted(event -> {
|
||||
log.error("Circuit Breaker OPEN 상태 - 호출 차단됨: {}", event.getCircuitBreakerName());
|
||||
})
|
||||
.onFailureRateExceeded(event -> {
|
||||
log.error("Circuit Breaker 실패율 초과");
|
||||
});
|
||||
|
||||
log.info("Circuit Breaker 레지스트리 구성 완료 - KOS Circuit Breaker 등록됨");
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재시도 정책 레지스트리 구성
|
||||
*
|
||||
* @return 재시도 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public RetryRegistry retryRegistry() {
|
||||
log.info("Retry 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 재시도 설정
|
||||
io.github.resilience4j.retry.RetryConfig kosRetryConfig =
|
||||
io.github.resilience4j.retry.RetryConfig.custom()
|
||||
// 최대 재시도 횟수
|
||||
.maxAttempts(kosProperties.getMaxRetries())
|
||||
// 재시도 간격
|
||||
.waitDuration(Duration.ofMillis(kosProperties.getRetryDelay()))
|
||||
// 지수 백오프 비활성화 (고정 간격 사용)
|
||||
// .intervalFunction() 대신 waitDuration 사용
|
||||
// 재시도 대상 예외
|
||||
.retryExceptions(Exception.class)
|
||||
// 재시도 제외 예외
|
||||
.ignoreExceptions(IllegalArgumentException.class, SecurityException.class)
|
||||
.build();
|
||||
|
||||
RetryRegistry registry = RetryRegistry.of(kosRetryConfig);
|
||||
|
||||
// KOS Retry 등록
|
||||
Retry kosRetry = registry.retry("kos-system", kosRetryConfig);
|
||||
|
||||
// 재시도 이벤트 리스너
|
||||
kosRetry.getEventPublisher()
|
||||
.onRetry(event -> {
|
||||
log.warn("재시도 실행 - 시도 횟수: {}/{}, 마지막 오류: {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
kosRetryConfig.getMaxAttempts(),
|
||||
event.getLastThrowable().getMessage());
|
||||
})
|
||||
.onError(event -> {
|
||||
log.error("재시도 최종 실패 - 총 시도 횟수: {}, 최종 오류: {}",
|
||||
event.getNumberOfRetryAttempts(),
|
||||
event.getLastThrowable().getMessage());
|
||||
});
|
||||
|
||||
log.info("Retry 레지스트리 구성 완료 - 최대 재시도: {}회, 간격: {}ms",
|
||||
kosProperties.getMaxRetries(), kosProperties.getRetryDelay());
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time Limiter 레지스트리 구성
|
||||
*
|
||||
* @return Time Limiter 레지스트리
|
||||
*/
|
||||
@Bean
|
||||
public TimeLimiterRegistry timeLimiterRegistry() {
|
||||
log.info("Time Limiter 레지스트리 구성 시작");
|
||||
|
||||
// KOS 시스템용 타임아웃 설정
|
||||
io.github.resilience4j.timelimiter.TimeLimiterConfig kosTimeLimiterConfig =
|
||||
io.github.resilience4j.timelimiter.TimeLimiterConfig.custom()
|
||||
// 타임아웃 (연결 타임아웃 + 읽기 타임아웃)
|
||||
.timeoutDuration(Duration.ofMillis(kosProperties.getTotalTimeout()))
|
||||
// 타임아웃 시 작업 취소 여부
|
||||
.cancelRunningFuture(true)
|
||||
.build();
|
||||
|
||||
TimeLimiterRegistry registry = TimeLimiterRegistry.of(kosTimeLimiterConfig);
|
||||
|
||||
// KOS Time Limiter 등록
|
||||
TimeLimiter kosTimeLimiter = registry.timeLimiter("kos-system", kosTimeLimiterConfig);
|
||||
|
||||
// 타임아웃 이벤트 리스너
|
||||
kosTimeLimiter.getEventPublisher()
|
||||
.onTimeout(event -> {
|
||||
log.error("Time Limiter 타임아웃 발생 - 설정 시간: {}ms",
|
||||
kosTimeLimiterConfig.getTimeoutDuration().toMillis());
|
||||
});
|
||||
|
||||
log.info("Time Limiter 레지스트리 구성 완료 - 타임아웃: {}ms",
|
||||
kosProperties.getTotalTimeout());
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Circuit Breaker 조회
|
||||
*
|
||||
* @param circuitBreakerRegistry Circuit Breaker 레지스트리
|
||||
* @return KOS Circuit Breaker
|
||||
*/
|
||||
@Bean
|
||||
public CircuitBreaker kosCircuitBreaker(CircuitBreakerRegistry circuitBreakerRegistry) {
|
||||
return circuitBreakerRegistry.circuitBreaker("kos-system");
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Retry 조회
|
||||
*
|
||||
* @param retryRegistry Retry 레지스트리
|
||||
* @return KOS Retry
|
||||
*/
|
||||
@Bean
|
||||
public Retry kosRetry(RetryRegistry retryRegistry) {
|
||||
return retryRegistry.retry("kos-system");
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS Time Limiter 조회
|
||||
*
|
||||
* @param timeLimiterRegistry Time Limiter 레지스트리
|
||||
* @return KOS Time Limiter
|
||||
*/
|
||||
@Bean
|
||||
public TimeLimiter kosTimeLimiter(TimeLimiterRegistry timeLimiterRegistry) {
|
||||
return timeLimiterRegistry.timeLimiter("kos-system");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,303 @@
|
||||
package com.phonebill.bill.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동 설정 프로퍼티
|
||||
*
|
||||
* application.yml 파일의 kos 설정을 바인딩하는 설정 클래스
|
||||
* - 연결 정보 (URL, 타임아웃 등)
|
||||
* - 재시도 정책
|
||||
* - Circuit Breaker 설정
|
||||
* - 인증 관련 설정
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "kos")
|
||||
@Getter
|
||||
@Setter
|
||||
@Validated
|
||||
public class KosProperties {
|
||||
|
||||
/**
|
||||
* KOS 시스템 기본 URL
|
||||
*/
|
||||
@NotBlank(message = "KOS 기본 URL은 필수입니다")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 연결 타임아웃 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer connectTimeout = 5000;
|
||||
|
||||
/**
|
||||
* 읽기 타임아웃 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer readTimeout = 30000;
|
||||
|
||||
/**
|
||||
* 최대 재시도 횟수
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Integer maxRetries = 3;
|
||||
|
||||
/**
|
||||
* 재시도 간격 (밀리초)
|
||||
*/
|
||||
@NotNull
|
||||
@Positive
|
||||
private Long retryDelay = 1000L;
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정
|
||||
*/
|
||||
private CircuitBreaker circuitBreaker = new CircuitBreaker();
|
||||
|
||||
/**
|
||||
* 인증 설정
|
||||
*/
|
||||
private Authentication authentication = new Authentication();
|
||||
|
||||
/**
|
||||
* 모니터링 설정
|
||||
*/
|
||||
private Monitoring monitoring = new Monitoring();
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CircuitBreaker {
|
||||
|
||||
/**
|
||||
* 실패율 임계값 (0.0 ~ 1.0)
|
||||
*/
|
||||
private Float failureRateThreshold = 0.5f;
|
||||
|
||||
/**
|
||||
* 느린 호출 임계값 (밀리초)
|
||||
*/
|
||||
private Long slowCallDurationThreshold = 10000L;
|
||||
|
||||
/**
|
||||
* 느린 호출 비율 임계값 (0.0 ~ 1.0)
|
||||
*/
|
||||
private Float slowCallRateThreshold = 0.5f;
|
||||
|
||||
/**
|
||||
* 슬라이딩 윈도우 크기
|
||||
*/
|
||||
private Integer slidingWindowSize = 10;
|
||||
|
||||
/**
|
||||
* 최소 호출 수
|
||||
*/
|
||||
private Integer minimumNumberOfCalls = 5;
|
||||
|
||||
/**
|
||||
* Half-Open 상태에서 허용되는 호출 수
|
||||
*/
|
||||
private Integer permittedNumberOfCallsInHalfOpenState = 3;
|
||||
|
||||
/**
|
||||
* Open 상태 유지 시간 (밀리초)
|
||||
*/
|
||||
private Long waitDurationInOpenState = 60000L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Authentication {
|
||||
|
||||
/**
|
||||
* 인증 토큰 사용 여부
|
||||
*/
|
||||
private Boolean enabled = true;
|
||||
|
||||
/**
|
||||
* API 키
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 시크릿 키
|
||||
*/
|
||||
private String secretKey;
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간 (초)
|
||||
*/
|
||||
private Long tokenExpirationSeconds = 3600L;
|
||||
|
||||
/**
|
||||
* 토큰 갱신 임계 시간 (초)
|
||||
*/
|
||||
private Long tokenRefreshThresholdSeconds = 300L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모니터링 설정 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Monitoring {
|
||||
|
||||
/**
|
||||
* 성능 로깅 사용 여부
|
||||
*/
|
||||
private Boolean performanceLoggingEnabled = true;
|
||||
|
||||
/**
|
||||
* 느린 요청 임계값 (밀리초)
|
||||
*/
|
||||
private Long slowRequestThreshold = 3000L;
|
||||
|
||||
/**
|
||||
* 메트릭 수집 사용 여부
|
||||
*/
|
||||
private Boolean metricsEnabled = true;
|
||||
|
||||
/**
|
||||
* 상태 체크 주기 (밀리초)
|
||||
*/
|
||||
private Long healthCheckInterval = 30000L;
|
||||
}
|
||||
|
||||
// === Computed Properties ===
|
||||
|
||||
/**
|
||||
* 요금조회 API URL 조회
|
||||
*
|
||||
* @return 요금조회 API 전체 URL
|
||||
*/
|
||||
public String getBillInquiryUrl() {
|
||||
return baseUrl + "/api/bill/inquiry";
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 확인 API URL 조회
|
||||
*
|
||||
* @return 상태 확인 API 전체 URL
|
||||
*/
|
||||
public String getStatusCheckUrl() {
|
||||
return baseUrl + "/api/bill/status";
|
||||
}
|
||||
|
||||
/**
|
||||
* 헬스체크 API URL 조회
|
||||
*
|
||||
* @return 헬스체크 API 전체 URL
|
||||
*/
|
||||
public String getHealthCheckUrl() {
|
||||
return baseUrl + "/health";
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 타임아웃 계산 (연결 + 읽기)
|
||||
*
|
||||
* @return 전체 타임아웃 (밀리초)
|
||||
*/
|
||||
public Integer getTotalTimeout() {
|
||||
return connectTimeout + readTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대 재시도 시간 계산
|
||||
*
|
||||
* @return 최대 재시도 시간 (밀리초)
|
||||
*/
|
||||
public Long getMaxRetryDuration() {
|
||||
return retryDelay * maxRetries;
|
||||
}
|
||||
|
||||
// === Validation Methods ===
|
||||
|
||||
/**
|
||||
* 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return baseUrl != null && !baseUrl.trim().isEmpty() &&
|
||||
connectTimeout > 0 && readTimeout > 0 &&
|
||||
maxRetries > 0 && retryDelay > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isCircuitBreakerConfigValid() {
|
||||
return circuitBreaker.failureRateThreshold >= 0.0f && circuitBreaker.failureRateThreshold <= 1.0f &&
|
||||
circuitBreaker.slowCallRateThreshold >= 0.0f && circuitBreaker.slowCallRateThreshold <= 1.0f &&
|
||||
circuitBreaker.slidingWindowSize > 0 &&
|
||||
circuitBreaker.minimumNumberOfCalls > 0 &&
|
||||
circuitBreaker.permittedNumberOfCallsInHalfOpenState > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 설정 유효성 검증
|
||||
*
|
||||
* @return 유효한 설정인지 여부
|
||||
*/
|
||||
public boolean isAuthenticationConfigValid() {
|
||||
if (!authentication.enabled) {
|
||||
return true;
|
||||
}
|
||||
return authentication.apiKey != null && !authentication.apiKey.trim().isEmpty() &&
|
||||
authentication.secretKey != null && !authentication.secretKey.trim().isEmpty();
|
||||
}
|
||||
|
||||
// === Utility Methods ===
|
||||
|
||||
/**
|
||||
* 설정 정보 요약
|
||||
*
|
||||
* @return 설정 요약 문자열
|
||||
*/
|
||||
public String getConfigSummary() {
|
||||
return String.format(
|
||||
"KOS 설정 - URL: %s, 연결타임아웃: %dms, 읽기타임아웃: %dms, 재시도: %d회",
|
||||
baseUrl, connectTimeout, readTimeout, maxRetries
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스킹된 인증 정보 조회 (로깅용)
|
||||
*
|
||||
* @return 마스킹된 인증 정보
|
||||
*/
|
||||
public String getMaskedAuthInfo() {
|
||||
if (!authentication.enabled || authentication.apiKey == null) {
|
||||
return "인증 비활성화";
|
||||
}
|
||||
|
||||
String maskedApiKey = authentication.apiKey.length() > 8 ?
|
||||
authentication.apiKey.substring(0, 4) + "****" +
|
||||
authentication.apiKey.substring(authentication.apiKey.length() - 4) :
|
||||
"****";
|
||||
|
||||
return String.format("API키: %s, 토큰만료: %d초", maskedApiKey, authentication.tokenExpirationSeconds);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
package com.phonebill.bill.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Redis 캐시 설정
|
||||
*
|
||||
* Redis를 활용한 캐싱 시스템 설정
|
||||
* - Redis 연결 설정
|
||||
* - 직렬화/역직렬화 설정
|
||||
* - 캐시별 TTL 설정
|
||||
* - Cache Manager 구성
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class RedisConfig {
|
||||
|
||||
@Value("${spring.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.redis.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.redis.database:0}")
|
||||
private int redisDatabase;
|
||||
|
||||
@Value("${spring.redis.timeout:5000}")
|
||||
private int redisTimeout;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리 구성
|
||||
*
|
||||
* @return Redis 연결 팩토리
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
log.info("Redis 연결 설정 - 호스트: {}, 포트: {}, DB: {}", redisHost, redisPort, redisDatabase);
|
||||
|
||||
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
|
||||
config.setHostName(redisHost);
|
||||
config.setPort(redisPort);
|
||||
config.setDatabase(redisDatabase);
|
||||
|
||||
if (redisPassword != null && !redisPassword.trim().isEmpty()) {
|
||||
config.setPassword(redisPassword);
|
||||
}
|
||||
|
||||
JedisConnectionFactory factory = new JedisConnectionFactory(config);
|
||||
factory.setTimeout(redisTimeout);
|
||||
|
||||
log.info("Redis 연결 팩토리 구성 완료");
|
||||
return factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis Template 구성
|
||||
*
|
||||
* @param connectionFactory Redis 연결 팩토리
|
||||
* @return Redis Template
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
log.debug("Redis Template 구성 시작");
|
||||
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// Key 직렬화: String 사용
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// Value 직렬화: JSON 사용
|
||||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper());
|
||||
template.setValueSerializer(jsonSerializer);
|
||||
template.setHashValueSerializer(jsonSerializer);
|
||||
|
||||
// 기본 직렬화 설정
|
||||
template.setDefaultSerializer(jsonSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
|
||||
log.info("Redis Template 구성 완료");
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache Manager 구성
|
||||
*
|
||||
* @param connectionFactory Redis 연결 팩토리
|
||||
* @return Cache Manager
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
|
||||
log.debug("Cache Manager 구성 시작");
|
||||
|
||||
// 기본 캐시 설정
|
||||
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ofHours(1)) // 기본 TTL: 1시간
|
||||
.serializeKeysWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair
|
||||
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())))
|
||||
.disableCachingNullValues(); // null 값 캐싱 비활성화
|
||||
|
||||
// 캐시별 개별 설정
|
||||
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
|
||||
|
||||
// 요금 데이터 캐시 (1시간)
|
||||
cacheConfigurations.put("billData", defaultConfig.entryTtl(Duration.ofHours(1)));
|
||||
|
||||
// 고객 정보 캐시 (4시간)
|
||||
cacheConfigurations.put("customerInfo", defaultConfig.entryTtl(Duration.ofHours(4)));
|
||||
|
||||
// 조회 가능 월 캐시 (24시간)
|
||||
cacheConfigurations.put("availableMonths", defaultConfig.entryTtl(Duration.ofHours(24)));
|
||||
|
||||
// 상품 정보 캐시 (2시간)
|
||||
cacheConfigurations.put("productInfo", defaultConfig.entryTtl(Duration.ofHours(2)));
|
||||
|
||||
// 회선 상태 캐시 (30분)
|
||||
cacheConfigurations.put("lineStatus", defaultConfig.entryTtl(Duration.ofMinutes(30)));
|
||||
|
||||
// 시스템 설정 캐시 (12시간)
|
||||
cacheConfigurations.put("systemConfig", defaultConfig.entryTtl(Duration.ofHours(12)));
|
||||
|
||||
RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(defaultConfig)
|
||||
.withInitialCacheConfigurations(cacheConfigurations)
|
||||
.transactionAware() // 트랜잭션 인식
|
||||
.build();
|
||||
|
||||
log.info("Cache Manager 구성 완료 - 캐시 종류: {}개", cacheConfigurations.size());
|
||||
return cacheManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* ObjectMapper 구성
|
||||
*
|
||||
* @return JSON 직렬화용 ObjectMapper
|
||||
*/
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
// Java Time 모듈 등록 (LocalDateTime 등 지원)
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// 타입 정보 포함 (다형성 지원)
|
||||
mapper.activateDefaultTyping(
|
||||
LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
|
||||
log.debug("ObjectMapper 구성 완료");
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 캐시 키 생성기 구성
|
||||
*
|
||||
* @return 캐시 키 생성기
|
||||
*/
|
||||
@Bean
|
||||
public org.springframework.cache.interceptor.KeyGenerator customKeyGenerator() {
|
||||
return (target, method, params) -> {
|
||||
StringBuilder keyBuilder = new StringBuilder();
|
||||
keyBuilder.append(target.getClass().getSimpleName()).append(":");
|
||||
keyBuilder.append(method.getName()).append(":");
|
||||
|
||||
for (Object param : params) {
|
||||
if (param != null) {
|
||||
keyBuilder.append(param.toString()).append(":");
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 콜론 제거
|
||||
String key = keyBuilder.toString();
|
||||
if (key.endsWith(":")) {
|
||||
key = key.substring(0, key.length() - 1);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 연결 상태 확인
|
||||
*
|
||||
* @param redisTemplate Redis Template
|
||||
* @return 연결 상태
|
||||
*/
|
||||
@Bean
|
||||
public RedisHealthIndicator redisHealthIndicator(RedisTemplate<String, Object> redisTemplate) {
|
||||
return new RedisHealthIndicator(redisTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 상태 확인을 위한 헬스 인디케이터
|
||||
*/
|
||||
public static class RedisHealthIndicator {
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
public RedisHealthIndicator(RedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 연결 상태 확인
|
||||
*
|
||||
* @return 연결 가능 여부
|
||||
*/
|
||||
public boolean isRedisAvailable() {
|
||||
try {
|
||||
String response = redisTemplate.getConnectionFactory().getConnection().ping();
|
||||
return "PONG".equals(response);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis 연결 상태 확인 실패: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 정보 조회
|
||||
*
|
||||
* @return Redis 서버 정보
|
||||
*/
|
||||
public String getRedisInfo() {
|
||||
try {
|
||||
return redisTemplate.getConnectionFactory().getConnection().info().toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis 정보 조회 실패: {}", e.getMessage());
|
||||
return "정보 조회 실패: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
package com.phonebill.bill.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* RestTemplate 설정
|
||||
*
|
||||
* KOS 시스템 및 외부 API 연동을 위한 HTTP 클라이언트 설정
|
||||
* - 연결 타임아웃 설정
|
||||
* - 읽기 타임아웃 설정
|
||||
* - 요청/응답 로깅 인터셉터
|
||||
* - 에러 핸들러 설정
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class RestTemplateConfig {
|
||||
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동용 RestTemplate 구성
|
||||
*
|
||||
* @param restTemplateBuilder RestTemplate 빌더
|
||||
* @return KOS용 RestTemplate
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate kosRestTemplate(RestTemplateBuilder restTemplateBuilder) {
|
||||
log.info("KOS RestTemplate 구성 시작");
|
||||
|
||||
RestTemplate restTemplate = restTemplateBuilder
|
||||
// 타임아웃 설정
|
||||
.setConnectTimeout(Duration.ofMillis(kosProperties.getConnectTimeout()))
|
||||
.setReadTimeout(Duration.ofMillis(kosProperties.getReadTimeout()))
|
||||
|
||||
// 요청 팩토리 설정
|
||||
.requestFactory(() -> createRequestFactory())
|
||||
|
||||
// 기본 에러 핸들러 설정
|
||||
.errorHandler(new RestTemplateErrorHandler())
|
||||
|
||||
// 요청/응답 로깅 인터셉터 추가
|
||||
.additionalInterceptors(new RestTemplateLoggingInterceptor())
|
||||
|
||||
.build();
|
||||
|
||||
log.info("KOS RestTemplate 구성 완료 - 연결타임아웃: {}ms, 읽기타임아웃: {}ms",
|
||||
kosProperties.getConnectTimeout(), kosProperties.getReadTimeout());
|
||||
|
||||
return restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반용 RestTemplate 구성
|
||||
*
|
||||
* @param restTemplateBuilder RestTemplate 빌더
|
||||
* @return 일반용 RestTemplate
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
|
||||
log.info("일반 RestTemplate 구성 시작");
|
||||
|
||||
RestTemplate restTemplate = restTemplateBuilder
|
||||
// 기본 타임아웃 설정 (더 관대한 설정)
|
||||
.setConnectTimeout(Duration.ofSeconds(10))
|
||||
.setReadTimeout(Duration.ofSeconds(30))
|
||||
|
||||
// 요청 팩토리 설정
|
||||
.requestFactory(() -> createRequestFactory())
|
||||
|
||||
// 기본 에러 핸들러 설정
|
||||
.errorHandler(new RestTemplateErrorHandler())
|
||||
|
||||
.build();
|
||||
|
||||
log.info("일반 RestTemplate 구성 완료");
|
||||
return restTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 팩토리 생성
|
||||
*
|
||||
* @return 클라이언트 HTTP 요청 팩토리
|
||||
*/
|
||||
private ClientHttpRequestFactory createRequestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
|
||||
// 연결 타임아웃 설정
|
||||
factory.setConnectTimeout(kosProperties.getConnectTimeout());
|
||||
|
||||
// 읽기 타임아웃 설정
|
||||
factory.setReadTimeout(kosProperties.getReadTimeout());
|
||||
|
||||
// 요청/응답 본문을 여러 번 읽을 수 있도록 버퍼링 활성화
|
||||
return new BufferingClientHttpRequestFactory(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* RestTemplate 로깅 인터셉터
|
||||
*
|
||||
* 요청 및 응답 로그를 기록하는 인터셉터
|
||||
*/
|
||||
private class RestTemplateLoggingInterceptor implements
|
||||
org.springframework.http.client.ClientHttpRequestInterceptor {
|
||||
|
||||
@Override
|
||||
public org.springframework.http.client.ClientHttpResponse intercept(
|
||||
org.springframework.http.HttpRequest request,
|
||||
byte[] body,
|
||||
org.springframework.http.client.ClientHttpRequestExecution execution) throws java.io.IOException {
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 요청 로깅
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("HTTP 요청 - 메소드: {}, URI: {}, 헤더: {}",
|
||||
request.getMethod(), request.getURI(), request.getHeaders());
|
||||
|
||||
if (body.length > 0) {
|
||||
log.debug("HTTP 요청 본문: {}", new String(body, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 실행
|
||||
org.springframework.http.client.ClientHttpResponse response = execution.execute(request, body);
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
long duration = endTime - startTime;
|
||||
|
||||
// 응답 로깅
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("HTTP 응답 - 상태: {}, 소요시간: {}ms, 헤더: {}",
|
||||
response.getStatusCode(), duration, response.getHeaders());
|
||||
|
||||
try {
|
||||
String responseBody = new String(
|
||||
response.getBody().readAllBytes(),
|
||||
java.nio.charset.StandardCharsets.UTF_8
|
||||
);
|
||||
log.debug("HTTP 응답 본문: {}", responseBody);
|
||||
} catch (Exception e) {
|
||||
log.debug("HTTP 응답 본문 읽기 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 성능 모니터링 로그
|
||||
if (duration > kosProperties.getMonitoring().getSlowRequestThreshold()) {
|
||||
log.warn("느린 HTTP 요청 감지 - URI: {}, 소요시간: {}ms, 임계값: {}ms",
|
||||
request.getURI(), duration, kosProperties.getMonitoring().getSlowRequestThreshold());
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RestTemplate 에러 핸들러
|
||||
*
|
||||
* HTTP 에러 응답을 커스텀 예외로 변환하는 핸들러
|
||||
*/
|
||||
private static class RestTemplateErrorHandler implements org.springframework.web.client.ResponseErrorHandler {
|
||||
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws java.io.IOException {
|
||||
return response.getStatusCode().is4xxClientError() || response.getStatusCode().is5xxServerError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) throws java.io.IOException {
|
||||
String statusCode = response.getStatusCode().toString();
|
||||
String statusText = response.getStatusText();
|
||||
|
||||
String responseBody = "";
|
||||
try {
|
||||
responseBody = new String(
|
||||
response.getBody().readAllBytes(),
|
||||
java.nio.charset.StandardCharsets.UTF_8
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.debug("HTTP 에러 응답 본문 읽기 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.error("HTTP 에러 응답 - 상태: {} {}, 응답 본문: {}",
|
||||
statusCode, statusText, responseBody);
|
||||
|
||||
// 상태 코드별 예외 처리
|
||||
if (response.getStatusCode().is4xxClientError()) {
|
||||
throw new RuntimeException(
|
||||
String.format("클라이언트 오류 - %s %s: %s", statusCode, statusText, responseBody)
|
||||
);
|
||||
} else if (response.getStatusCode().is5xxServerError()) {
|
||||
throw new RuntimeException(
|
||||
String.format("서버 오류 - %s %s: %s", statusCode, statusText, responseBody)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
package com.phonebill.bill.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.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 기반 인증/인가 시스템 구성
|
||||
* - Stateless 인증 방식
|
||||
* - API 엔드포인트별 접근 권한 설정
|
||||
* - CORS 설정
|
||||
* - 예외 처리 설정
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(prePostEnabled = true)
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* 보안 필터 체인 구성
|
||||
*
|
||||
* @param http HTTP 보안 설정
|
||||
* @return 보안 필터 체인
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
log.info("Security Filter Chain 구성 시작");
|
||||
|
||||
http
|
||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// CORS 설정 활성화
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
||||
// 세션 관리 - Stateless (JWT 사용)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 요청별 인증/인가 설정
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// 공개 엔드포인트 - 인증 불필요
|
||||
.requestMatchers(
|
||||
// Health Check
|
||||
"/actuator/**",
|
||||
// Swagger UI
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-resources/**",
|
||||
"/webjars/**",
|
||||
// 정적 리소스
|
||||
"/favicon.ico",
|
||||
"/error"
|
||||
).permitAll()
|
||||
|
||||
// OPTIONS 요청은 모두 허용 (CORS Preflight)
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
|
||||
// 요금 조회 API - 인증 필요
|
||||
.requestMatchers("/api/bills/**").authenticated()
|
||||
|
||||
// 나머지 모든 요청 - 인증 필요
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
// JWT 인증 필터 추가
|
||||
// TODO: JWT 필터 구현 후 활성화
|
||||
// .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
|
||||
// 예외 처리
|
||||
.exceptionHandling(exception -> exception
|
||||
// 인증 실패 시 처리
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
log.warn("인증 실패 - URI: {}, 오류: {}",
|
||||
request.getRequestURI(), authException.getMessage());
|
||||
response.setStatus(401);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"message": "인증이 필요합니다",
|
||||
"timestamp": "%s"
|
||||
}
|
||||
""".formatted(java.time.LocalDateTime.now()));
|
||||
})
|
||||
|
||||
// 권한 부족 시 처리
|
||||
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||
log.warn("접근 거부 - URI: {}, 오류: {}",
|
||||
request.getRequestURI(), accessDeniedException.getMessage());
|
||||
response.setStatus(403);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("""
|
||||
{
|
||||
"success": false,
|
||||
"message": "접근 권한이 없습니다",
|
||||
"timestamp": "%s"
|
||||
}
|
||||
""".formatted(java.time.LocalDateTime.now()));
|
||||
})
|
||||
);
|
||||
|
||||
log.info("Security Filter Chain 구성 완료");
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 설정
|
||||
*
|
||||
* @return CORS 설정 소스
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
log.debug("CORS 설정 구성 시작");
|
||||
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// 허용할 Origin 설정 (개발환경)
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(
|
||||
"http://localhost:*",
|
||||
"https://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"https://127.0.0.1:*"
|
||||
// TODO: 운영환경 도메인 추가
|
||||
));
|
||||
|
||||
// 허용할 HTTP 메소드
|
||||
configuration.setAllowedMethods(Arrays.asList(
|
||||
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
|
||||
));
|
||||
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"Access-Control-Request-Method",
|
||||
"Access-Control-Request-Headers"
|
||||
));
|
||||
|
||||
// 자격 증명 허용 (쿠키, Authorization 헤더 등)
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Preflight 요청 캐시 시간 (초)
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
|
||||
log.debug("CORS 설정 구성 완료");
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 인코더 구성
|
||||
*
|
||||
* @return BCrypt 패스워드 인코더
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
log.debug("Password Encoder 구성 - BCrypt 사용");
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 매니저 구성
|
||||
*
|
||||
* @param config 인증 설정
|
||||
* @return 인증 매니저
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
log.debug("Authentication Manager 구성");
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 인증 필터 구성
|
||||
*
|
||||
* TODO: JWT 토큰 검증 필터 구현
|
||||
*
|
||||
* @return JWT 인증 필터
|
||||
*/
|
||||
// @Bean
|
||||
// public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
// return new JwtAuthenticationFilter();
|
||||
// }
|
||||
|
||||
/**
|
||||
* JWT 토큰 제공자 구성
|
||||
*
|
||||
* TODO: JWT 토큰 생성/검증 서비스 구현
|
||||
*
|
||||
* @return JWT 토큰 제공자
|
||||
*/
|
||||
// @Bean
|
||||
// public JwtTokenProvider jwtTokenProvider() {
|
||||
// return new JwtTokenProvider();
|
||||
// }
|
||||
}
|
||||
@ -0,0 +1,235 @@
|
||||
package com.phonebill.bill.controller;
|
||||
|
||||
import com.phonebill.bill.dto.*;
|
||||
import com.phonebill.bill.service.BillInquiryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 요금조회 관련 REST API 컨트롤러
|
||||
*
|
||||
* 통신요금 조회 서비스의 주요 기능을 제공:
|
||||
* - UFR-BILL-010: 요금조회 메뉴 접근
|
||||
* - UFR-BILL-020: 요금조회 신청 (동기/비동기 처리)
|
||||
* - UFR-BILL-030: 요금조회 결과 확인
|
||||
* - UFR-BILL-040: 요금조회 이력 관리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/bills")
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
@Tag(name = "Bill Inquiry", description = "요금조회 관련 API")
|
||||
public class BillController {
|
||||
|
||||
private final BillInquiryService billInquiryService;
|
||||
|
||||
/**
|
||||
* 요금조회 메뉴 조회
|
||||
*
|
||||
* UFR-BILL-010: 요금조회 메뉴 접근
|
||||
* - 고객 회선번호 표시
|
||||
* - 조회월 선택 옵션 제공
|
||||
* - 요금 조회 신청 버튼 활성화
|
||||
*/
|
||||
@GetMapping("/menu")
|
||||
@Operation(
|
||||
summary = "요금조회 메뉴 조회",
|
||||
description = "요금조회 메뉴 화면에 필요한 정보(고객정보, 조회가능월)를 제공합니다.",
|
||||
security = @SecurityRequirement(name = "bearerAuth")
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "요금조회 메뉴 정보 조회 성공",
|
||||
content = @Content(schema = @Schema(implementation = ApiResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "인증 실패"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "서버 내부 오류"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<ApiResponse<BillMenuResponse>> getBillMenu() {
|
||||
log.info("요금조회 메뉴 조회 요청");
|
||||
|
||||
BillMenuResponse menuData = billInquiryService.getBillMenu();
|
||||
|
||||
log.info("요금조회 메뉴 조회 완료 - 고객: {}", menuData.getCustomerInfo().getCustomerId());
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(menuData, "요금조회 메뉴를 성공적으로 조회했습니다")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 요청
|
||||
*
|
||||
* UFR-BILL-020: 요금조회 신청
|
||||
* - 시나리오 1: 조회월 미선택 (당월 청구요금 조회)
|
||||
* - 시나리오 2: 조회월 선택 (특정월 청구요금 조회)
|
||||
*
|
||||
* Cache-Aside 패턴과 Circuit Breaker 패턴 적용
|
||||
*/
|
||||
@PostMapping("/inquiry")
|
||||
@Operation(
|
||||
summary = "요금조회 요청",
|
||||
description = "지정된 회선번호와 조회월의 요금 정보를 조회합니다. " +
|
||||
"캐시 확인 후 KOS 시스템 연동을 통해 실시간 데이터를 제공합니다.",
|
||||
security = @SecurityRequirement(name = "bearerAuth")
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "요금조회 완료 (동기 처리)",
|
||||
content = @Content(schema = @Schema(implementation = ApiResponse.class))
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "202",
|
||||
description = "요금조회 요청 접수 (비동기 처리)"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "잘못된 요청 데이터"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "503",
|
||||
description = "KOS 시스템 장애 (Circuit Breaker Open)"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<ApiResponse<BillInquiryResponse>> inquireBill(
|
||||
@Valid @RequestBody BillInquiryRequest request) {
|
||||
log.info("요금조회 요청 - 회선번호: {}, 조회월: {}",
|
||||
request.getLineNumber(), request.getInquiryMonth());
|
||||
|
||||
BillInquiryResponse response = billInquiryService.inquireBill(request);
|
||||
|
||||
if (response.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
||||
log.info("요금조회 완료 - 요청ID: {}, 회선: {}",
|
||||
response.getRequestId(), request.getLineNumber());
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(response, "요금조회가 완료되었습니다")
|
||||
);
|
||||
} else {
|
||||
log.info("요금조회 비동기 처리 - 요청ID: {}, 상태: {}",
|
||||
response.getRequestId(), response.getStatus());
|
||||
return ResponseEntity.accepted().body(
|
||||
ApiResponse.success(response, "요금조회 요청이 접수되었습니다")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 결과 확인
|
||||
*
|
||||
* 비동기로 처리된 요금조회 결과를 확인합니다.
|
||||
* requestId를 통해 조회 상태와 결과를 반환합니다.
|
||||
*/
|
||||
@GetMapping("/inquiry/{requestId}")
|
||||
@Operation(
|
||||
summary = "요금조회 결과 확인",
|
||||
description = "비동기로 처리된 요금조회의 상태와 결과를 확인합니다.",
|
||||
security = @SecurityRequirement(name = "bearerAuth")
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "요금조회 결과 조회 성공"
|
||||
),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "요청 ID를 찾을 수 없음"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<ApiResponse<BillInquiryResponse>> getBillInquiryResult(
|
||||
@Parameter(description = "요금조회 요청 ID", example = "REQ_20240308_001")
|
||||
@PathVariable String requestId) {
|
||||
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
|
||||
|
||||
BillInquiryResponse response = billInquiryService.getBillInquiryResult(requestId);
|
||||
|
||||
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(response, "요금조회 결과를 조회했습니다")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 이력 조회
|
||||
*
|
||||
* UFR-BILL-040: 요금조회 결과 전송 및 이력 관리
|
||||
* - 요금 조회 요청 이력: MVNO → MP
|
||||
* - 요금 조회 처리 이력: MP → KOS
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
@Operation(
|
||||
summary = "요금조회 이력 조회",
|
||||
description = "사용자의 요금조회 요청 및 처리 이력을 페이징으로 제공합니다.",
|
||||
security = @SecurityRequirement(name = "bearerAuth")
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "요금조회 이력 조회 성공"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<ApiResponse<BillHistoryResponse>> getBillHistory(
|
||||
@Parameter(description = "회선번호 (미입력시 인증된 사용자의 모든 회선)")
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "회선번호 형식이 올바르지 않습니다")
|
||||
String lineNumber,
|
||||
|
||||
@Parameter(description = "조회 시작일 (YYYY-MM-DD)")
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)")
|
||||
String startDate,
|
||||
|
||||
@Parameter(description = "조회 종료일 (YYYY-MM-DD)")
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)")
|
||||
String endDate,
|
||||
|
||||
@Parameter(description = "페이지 번호 (1부터 시작)")
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
|
||||
@Parameter(description = "페이지 크기")
|
||||
@RequestParam(defaultValue = "20") Integer size,
|
||||
|
||||
@Parameter(description = "처리 상태 필터")
|
||||
@RequestParam(required = false) BillInquiryResponse.ProcessStatus status) {
|
||||
|
||||
log.info("요금조회 이력 조회 - 회선: {}, 기간: {} ~ {}, 페이지: {}/{}",
|
||||
lineNumber, startDate, endDate, page, size);
|
||||
|
||||
BillHistoryResponse historyData = billInquiryService.getBillHistory(
|
||||
lineNumber, startDate, endDate, page, size, status
|
||||
);
|
||||
|
||||
log.info("요금조회 이력 조회 완료 - 총 {}건, 페이지: {}/{}",
|
||||
historyData.getPagination().getTotalItems(),
|
||||
historyData.getPagination().getCurrentPage(),
|
||||
historyData.getPagination().getTotalPages());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.success(historyData, "요금조회 이력을 조회했습니다")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.phonebill.bill.domain;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 기본 시간 정보를 담는 추상 엔티티 클래스
|
||||
*
|
||||
* 모든 엔티티의 공통 필드인 생성일시와 수정일시를 자동 관리
|
||||
* JPA Auditing을 통해 자동으로 시간 정보가 설정됨
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* API 응답 공통 포맷 클래스
|
||||
*
|
||||
* 모든 API 응답에 대한 공통 구조를 제공
|
||||
* - success: 성공/실패 여부
|
||||
* - data: 실제 응답 데이터 (성공시)
|
||||
* - error: 오류 정보 (실패시)
|
||||
* - message: 응답 메시지
|
||||
* - timestamp: 응답 시간
|
||||
*
|
||||
* @param <T> 응답 데이터 타입
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ApiResponse<T> {
|
||||
|
||||
/**
|
||||
* 성공/실패 여부
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 응답 데이터 (성공시에만 포함)
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 오류 정보 (실패시에만 포함)
|
||||
*/
|
||||
private ErrorDetail error;
|
||||
|
||||
/**
|
||||
* 응답 메시지
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 응답 시간
|
||||
*/
|
||||
@Builder.Default
|
||||
private LocalDateTime timestamp = LocalDateTime.now();
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*
|
||||
* @param data 응답 데이터
|
||||
* @param message 성공 메시지
|
||||
* @param <T> 데이터 타입
|
||||
* @return 성공 응답
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data, String message) {
|
||||
return ApiResponse.<T>builder()
|
||||
.success(true)
|
||||
.data(data)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (기본 메시지)
|
||||
*
|
||||
* @param data 응답 데이터
|
||||
* @param <T> 데이터 타입
|
||||
* @return 성공 응답
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return success(data, "요청이 성공적으로 처리되었습니다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성
|
||||
*
|
||||
* @param error 오류 정보
|
||||
* @param message 오류 메시지
|
||||
* @return 실패 응답
|
||||
*/
|
||||
public static ApiResponse<Void> failure(ErrorDetail error, String message) {
|
||||
return ApiResponse.<Void>builder()
|
||||
.success(false)
|
||||
.error(error)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성 (단순 오류)
|
||||
*
|
||||
* @param code 오류 코드
|
||||
* @param message 오류 메시지
|
||||
* @return 실패 응답
|
||||
*/
|
||||
public static ApiResponse<Void> failure(String code, String message) {
|
||||
ErrorDetail error = ErrorDetail.builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.build();
|
||||
return failure(error, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 상세 정보 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
class ErrorDetail {
|
||||
|
||||
/**
|
||||
* 오류 코드
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 오류 메시지
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 상세 오류 정보
|
||||
*/
|
||||
private String detail;
|
||||
|
||||
/**
|
||||
* 오류 발생 시간
|
||||
*/
|
||||
@Builder.Default
|
||||
private LocalDateTime timestamp = LocalDateTime.now();
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 요금 상세 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BillDetailInfo {
|
||||
private String itemName;
|
||||
private String itemType;
|
||||
private BigDecimal amount;
|
||||
private String description;
|
||||
private String category;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BillHistoryRequest {
|
||||
private String userId;
|
||||
private String startDate;
|
||||
private String endDate;
|
||||
private int page;
|
||||
private int size;
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 응답 DTO
|
||||
*
|
||||
* 요금조회 이력 목록을 담는 응답 객체
|
||||
* - 이력 항목 리스트
|
||||
* - 페이징 정보
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class BillHistoryResponse {
|
||||
|
||||
/**
|
||||
* 요금조회 이력 목록
|
||||
*/
|
||||
private List<BillHistoryItem> items;
|
||||
|
||||
/**
|
||||
* 페이징 정보
|
||||
*/
|
||||
private PaginationInfo pagination;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 항목 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class BillHistoryItem {
|
||||
|
||||
/**
|
||||
* 요금조회 요청 ID
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 회선번호
|
||||
*/
|
||||
private String lineNumber;
|
||||
|
||||
/**
|
||||
* 조회월 (YYYY-MM 형식)
|
||||
*/
|
||||
private String inquiryMonth;
|
||||
|
||||
/**
|
||||
* 요청일시
|
||||
*/
|
||||
private LocalDateTime requestTime;
|
||||
|
||||
/**
|
||||
* 처리일시
|
||||
*/
|
||||
private LocalDateTime processTime;
|
||||
|
||||
/**
|
||||
* 처리 결과
|
||||
*/
|
||||
private BillInquiryResponse.ProcessStatus status;
|
||||
|
||||
/**
|
||||
* 결과 요약 (성공시 요금제명과 금액)
|
||||
*/
|
||||
private String resultSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PaginationInfo {
|
||||
|
||||
/**
|
||||
* 현재 페이지
|
||||
*/
|
||||
private Integer currentPage;
|
||||
|
||||
/**
|
||||
* 전체 페이지 수
|
||||
*/
|
||||
private Integer totalPages;
|
||||
|
||||
/**
|
||||
* 전체 항목 수
|
||||
*/
|
||||
private Long totalItems;
|
||||
|
||||
/**
|
||||
* 페이지 크기
|
||||
*/
|
||||
private Integer pageSize;
|
||||
|
||||
/**
|
||||
* 다음 페이지 존재 여부
|
||||
*/
|
||||
private Boolean hasNext;
|
||||
|
||||
/**
|
||||
* 이전 페이지 존재 여부
|
||||
*/
|
||||
private Boolean hasPrevious;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 요금조회 요청 DTO
|
||||
*
|
||||
* 요금조회 API 요청에 필요한 데이터를 담는 객체
|
||||
* - 회선번호 (필수): 조회할 대상 회선
|
||||
* - 조회월 (선택): 특정월 조회시 사용, 미입력시 당월 조회
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BillInquiryRequest {
|
||||
|
||||
/**
|
||||
* 조회할 회선번호 (필수)
|
||||
* 010-XXXX-XXXX 형식만 허용
|
||||
*/
|
||||
@NotBlank(message = "회선번호는 필수입니다")
|
||||
@Pattern(
|
||||
regexp = "^010-\\d{4}-\\d{4}$",
|
||||
message = "회선번호는 010-XXXX-XXXX 형식이어야 합니다"
|
||||
)
|
||||
private String lineNumber;
|
||||
|
||||
/**
|
||||
* 조회월 (선택)
|
||||
* YYYY-MM 형식, 미입력시 당월 조회
|
||||
*/
|
||||
@Pattern(
|
||||
regexp = "^\\d{4}-\\d{2}$",
|
||||
message = "조회월은 YYYY-MM 형식이어야 합니다"
|
||||
)
|
||||
private String inquiryMonth;
|
||||
}
|
||||
@ -0,0 +1,187 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 요금조회 응답 DTO
|
||||
*
|
||||
* 요금조회 결과를 담는 응답 객체
|
||||
* - 요청 ID: 조회 요청 추적용
|
||||
* - 처리 상태: COMPLETED, PROCESSING, FAILED
|
||||
* - 요금 정보: KOS에서 조회된 실제 요금 데이터
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class BillInquiryResponse {
|
||||
|
||||
/**
|
||||
* 요금조회 요청 ID
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 처리 상태
|
||||
* - COMPLETED: 조회 완료
|
||||
* - PROCESSING: 처리 중
|
||||
* - FAILED: 조회 실패
|
||||
*/
|
||||
private ProcessStatus status;
|
||||
|
||||
/**
|
||||
* 요금 정보 (COMPLETED 상태일 때만 포함)
|
||||
*/
|
||||
private BillInfo billInfo;
|
||||
|
||||
/**
|
||||
* 처리 상태 열거형
|
||||
*/
|
||||
public enum ProcessStatus {
|
||||
COMPLETED, PROCESSING, FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class BillInfo {
|
||||
|
||||
/**
|
||||
* 현재 이용 중인 요금제
|
||||
*/
|
||||
private String productName;
|
||||
|
||||
/**
|
||||
* 계약 약정 조건
|
||||
*/
|
||||
private String contractInfo;
|
||||
|
||||
/**
|
||||
* 요금 청구 월 (YYYY-MM 형식)
|
||||
*/
|
||||
private String billingMonth;
|
||||
|
||||
/**
|
||||
* 청구 요금 금액 (원)
|
||||
*/
|
||||
private Integer totalAmount;
|
||||
|
||||
/**
|
||||
* 적용된 할인 내역
|
||||
*/
|
||||
private List<DiscountInfo> discountInfo;
|
||||
|
||||
/**
|
||||
* 사용량 정보
|
||||
*/
|
||||
private UsageInfo usage;
|
||||
|
||||
/**
|
||||
* 중도 해지 시 비용 (원)
|
||||
*/
|
||||
private Integer terminationFee;
|
||||
|
||||
/**
|
||||
* 단말기 할부 잔액 (원)
|
||||
*/
|
||||
private Integer deviceInstallment;
|
||||
|
||||
/**
|
||||
* 납부 정보
|
||||
*/
|
||||
private PaymentInfo paymentInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 할인 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class DiscountInfo {
|
||||
|
||||
/**
|
||||
* 할인 명칭
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 할인 금액 (원)
|
||||
*/
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UsageInfo {
|
||||
|
||||
/**
|
||||
* 통화 사용량
|
||||
*/
|
||||
private String voice;
|
||||
|
||||
/**
|
||||
* SMS 사용량
|
||||
*/
|
||||
private String sms;
|
||||
|
||||
/**
|
||||
* 데이터 사용량
|
||||
*/
|
||||
private String data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 납부 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PaymentInfo {
|
||||
|
||||
/**
|
||||
* 요금 청구일 (YYYY-MM-DD 형식)
|
||||
*/
|
||||
private String billingDate;
|
||||
|
||||
/**
|
||||
* 납부 상태 (PAID, UNPAID, OVERDUE)
|
||||
*/
|
||||
private PaymentStatus paymentStatus;
|
||||
|
||||
/**
|
||||
* 납부 방법
|
||||
*/
|
||||
private String paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* 납부 상태 열거형
|
||||
*/
|
||||
public enum PaymentStatus {
|
||||
PAID, UNPAID, OVERDUE
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 요금조회 메뉴 응답 DTO
|
||||
*
|
||||
* 요금조회 메뉴 화면에 필요한 정보를 담는 응답 객체
|
||||
* - 고객 정보 (고객ID, 회선번호)
|
||||
* - 조회 가능한 월 목록
|
||||
* - 기본 선택된 현재 월
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BillMenuResponse {
|
||||
|
||||
/**
|
||||
* 고객 정보
|
||||
*/
|
||||
private CustomerInfo customerInfo;
|
||||
|
||||
/**
|
||||
* 조회 가능한 월 목록 (YYYY-MM 형식)
|
||||
*/
|
||||
private List<String> availableMonths;
|
||||
|
||||
/**
|
||||
* 기본 선택된 현재 월 (YYYY-MM 형식)
|
||||
*/
|
||||
private String currentMonth;
|
||||
|
||||
/**
|
||||
* 고객 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class CustomerInfo {
|
||||
|
||||
/**
|
||||
* 고객 ID
|
||||
*/
|
||||
private String customerId;
|
||||
|
||||
/**
|
||||
* 회선번호 (010-XXXX-XXXX 형식)
|
||||
*/
|
||||
private String lineNumber;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 요금조회 상태 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BillStatusResponse {
|
||||
private String requestId;
|
||||
private String status;
|
||||
private String message;
|
||||
private String processedAt;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 할인 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiscountInfo {
|
||||
private String discountName;
|
||||
private String discountType;
|
||||
private BigDecimal discountAmount;
|
||||
private String description;
|
||||
private String validFrom;
|
||||
private String validTo;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.phonebill.bill.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 사용량 정보 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UsageInfo {
|
||||
private String serviceType;
|
||||
private Long usageAmount;
|
||||
private String unit;
|
||||
private BigDecimal unitPrice;
|
||||
private BigDecimal totalAmount;
|
||||
private String description;
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package com.phonebill.bill.exception;
|
||||
|
||||
/**
|
||||
* 요금조회 관련 비즈니스 예외 클래스
|
||||
*
|
||||
* 요금조회 프로세스에서 발생하는 비즈니스 로직 오류를 처리
|
||||
* - 유효하지 않은 회선번호
|
||||
* - 조회 불가능한 월
|
||||
* - 고객 정보 불일치
|
||||
* - 요금 데이터 없음
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
public class BillInquiryException extends BusinessException {
|
||||
|
||||
/**
|
||||
* 기본 생성자
|
||||
*
|
||||
* @param message 오류 메시지
|
||||
*/
|
||||
public BillInquiryException(String message) {
|
||||
super("BILL_INQUIRY_ERROR", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 정보를 포함한 생성자
|
||||
*
|
||||
* @param message 오류 메시지
|
||||
* @param detail 상세 오류 정보
|
||||
*/
|
||||
public BillInquiryException(String message, String detail) {
|
||||
super("BILL_INQUIRY_ERROR", message, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원인 예외를 포함한 생성자
|
||||
*
|
||||
* @param message 오류 메시지
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
public BillInquiryException(String message, Throwable cause) {
|
||||
super("BILL_INQUIRY_ERROR", message, cause);
|
||||
}
|
||||
|
||||
|
||||
// 특정 오류 상황을 위한 정적 팩토리 메소드들
|
||||
|
||||
/**
|
||||
* 사용자 정의 오류 코드를 포함한 생성자
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 오류 메시지
|
||||
* @param detail 상세 정보
|
||||
*/
|
||||
public BillInquiryException(String errorCode, String message, String detail) {
|
||||
super(errorCode, message, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효하지 않은 회선번호 예외
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException invalidLineNumber(String lineNumber) {
|
||||
return new BillInquiryException("INVALID_LINE_NUMBER",
|
||||
String.format("유효하지 않은 회선번호: %s", lineNumber), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 데이터를 찾을 수 없음 예외
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @param type 데이터 타입
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException billDataNotFound(String requestId, String type) {
|
||||
return new BillInquiryException("BILL_DATA_NOT_FOUND",
|
||||
String.format("요금 데이터 없음 - %s: %s", type, requestId), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회 불가능한 월 예외
|
||||
*
|
||||
* @param inquiryMonth 조회 월
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException invalidInquiryMonth(String inquiryMonth) {
|
||||
return new BillInquiryException("INVALID_INQUIRY_MONTH",
|
||||
String.format("조회 불가능한 월: %s", inquiryMonth));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 정보 불일치 예외
|
||||
*
|
||||
* @param customerId 고객 ID
|
||||
* @param lineNumber 회선번호
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException customerMismatch(String customerId, String lineNumber) {
|
||||
return new BillInquiryException("CUSTOMER_MISMATCH",
|
||||
String.format("고객 정보 불일치 - 고객ID: %s, 회선번호: %s", customerId, lineNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 데이터 없음 예외
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회 월
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException noBillData(String lineNumber, String inquiryMonth) {
|
||||
return new BillInquiryException("NO_BILL_DATA",
|
||||
String.format("요금 데이터 없음 - 회선번호: %s, 조회월: %s", lineNumber, inquiryMonth));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 권한 없음 예외
|
||||
*
|
||||
* @param customerId 고객 ID
|
||||
* @return BillInquiryException
|
||||
*/
|
||||
public static BillInquiryException noPermission(String customerId) {
|
||||
return new BillInquiryException("NO_PERMISSION",
|
||||
String.format("요금조회 권한 없음 - 고객ID: %s", customerId));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.phonebill.bill.exception;
|
||||
|
||||
/**
|
||||
* 비즈니스 로직 예외를 위한 기본 예외 클래스
|
||||
*
|
||||
* 애플리케이션의 비즈니스 규칙 위반이나 예상 가능한 오류 상황을 표현
|
||||
* 모든 비즈니스 예외의 부모 클래스로 사용
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
public abstract class BusinessException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* 오류 코드
|
||||
*/
|
||||
private final String errorCode;
|
||||
|
||||
/**
|
||||
* 상세 오류 정보
|
||||
*/
|
||||
private final String detail;
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 오류 메시지
|
||||
*/
|
||||
protected BusinessException(String errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.detail = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 (상세 정보 포함)
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 오류 메시지
|
||||
* @param detail 상세 오류 정보
|
||||
*/
|
||||
protected BusinessException(String errorCode, String message, String detail) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 (원인 예외 포함)
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 오류 메시지
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
protected BusinessException(String errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
this.detail = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 (모든 정보 포함)
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param message 오류 메시지
|
||||
* @param detail 상세 오류 정보
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
protected BusinessException(String errorCode, String message, String detail, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.phonebill.bill.exception;
|
||||
|
||||
/**
|
||||
* Circuit Breaker Open 상태 예외 클래스
|
||||
*
|
||||
* Circuit Breaker가 Open 상태일 때 발생하는 예외
|
||||
* 외부 시스템의 장애나 응답 지연으로 인해 요청이 차단되는 상황을 처리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
public class CircuitBreakerException extends BusinessException {
|
||||
|
||||
/**
|
||||
* 서비스명
|
||||
*/
|
||||
private final String serviceName;
|
||||
|
||||
/**
|
||||
* Circuit Breaker 상태 정보
|
||||
*/
|
||||
private final String stateInfo;
|
||||
|
||||
/**
|
||||
* 기본 생성자
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param message 오류 메시지
|
||||
*/
|
||||
public CircuitBreakerException(String serviceName, String message) {
|
||||
super("CIRCUIT_BREAKER_OPEN", message);
|
||||
this.serviceName = serviceName;
|
||||
this.stateInfo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 정보를 포함한 생성자
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param message 오류 메시지
|
||||
* @param stateInfo 상태 정보
|
||||
*/
|
||||
public CircuitBreakerException(String serviceName, String message, String stateInfo) {
|
||||
super("CIRCUIT_BREAKER_OPEN", message, stateInfo);
|
||||
this.serviceName = serviceName;
|
||||
this.stateInfo = stateInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원인 예외를 포함한 생성자
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param message 오류 메시지
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
public CircuitBreakerException(String serviceName, String message, Throwable cause) {
|
||||
super("CIRCUIT_BREAKER_OPEN", message, cause);
|
||||
this.serviceName = serviceName;
|
||||
this.stateInfo = null;
|
||||
}
|
||||
|
||||
public String getServiceName() {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
public String getStateInfo() {
|
||||
return stateInfo;
|
||||
}
|
||||
|
||||
// 특정 오류 상황을 위한 정적 팩토리 메소드들
|
||||
|
||||
/**
|
||||
* Circuit Breaker Open 상태 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @return 예외 인스턴스
|
||||
*/
|
||||
public static CircuitBreakerException circuitBreakerOpen(String serviceName) {
|
||||
return new CircuitBreakerException(
|
||||
serviceName,
|
||||
"일시적으로 서비스 이용이 어렵습니다",
|
||||
String.format("%s 서비스가 일시적으로 중단되었습니다. 잠시 후 다시 시도해주세요.", serviceName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker Open 상태 예외 (상세 정보 포함)
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param failureRate 실패율
|
||||
* @param slowCallRate 느린 호출 비율
|
||||
* @return 예외 인스턴스
|
||||
*/
|
||||
public static CircuitBreakerException circuitBreakerOpenWithDetails(String serviceName, double failureRate, double slowCallRate) {
|
||||
return new CircuitBreakerException(
|
||||
serviceName,
|
||||
"서비스 품질 저하로 인해 일시적으로 차단되었습니다",
|
||||
String.format("서비스: %s, 실패율: %.2f%%, 느린 호출 비율: %.2f%%", serviceName, failureRate * 100, slowCallRate * 100)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker Half-Open 상태에서 호출 차단 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @return 예외 인스턴스
|
||||
*/
|
||||
public static CircuitBreakerException callNotPermittedInHalfOpenState(String serviceName) {
|
||||
return new CircuitBreakerException(
|
||||
serviceName,
|
||||
"서비스 상태 확인 중입니다",
|
||||
String.format("%s 서비스의 상태를 확인하는 중이므로 잠시 후 다시 시도해주세요.", serviceName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker 설정 오류 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param configError 설정 오류 내용
|
||||
* @return 예외 인스턴스
|
||||
*/
|
||||
public static CircuitBreakerException configurationError(String serviceName, String configError) {
|
||||
return new CircuitBreakerException(
|
||||
serviceName,
|
||||
"Circuit Breaker 설정에 문제가 있습니다",
|
||||
String.format("서비스: %s, 설정 오류: %s", serviceName, configError)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
package com.phonebill.bill.exception;
|
||||
|
||||
import com.phonebill.bill.dto.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 전역 예외 처리 핸들러
|
||||
*
|
||||
* 애플리케이션에서 발생하는 모든 예외를 일관된 형태로 처리
|
||||
* - 비즈니스 예외: 예상 가능한 오류 상황
|
||||
* - 시스템 예외: 예상치 못한 시스템 오류
|
||||
* - 검증 예외: 입력값 검증 실패
|
||||
* - HTTP 예외: HTTP 프로토콜 관련 오류
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 요금조회 관련 비즈니스 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(BillInquiryException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBillInquiryException(
|
||||
BillInquiryException ex, HttpServletRequest request) {
|
||||
log.warn("요금조회 비즈니스 예외 발생: {} - {}", ex.getErrorCode(), ex.getMessage());
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 연동 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(KosConnectionException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleKosConnectionException(
|
||||
KosConnectionException ex, HttpServletRequest request) {
|
||||
log.error("KOS 연동 오류 발생: {} - {}, 서비스: {}",
|
||||
ex.getErrorCode(), ex.getMessage(), ex.getServiceName());
|
||||
|
||||
// KOS 연동 오류는 503 Service Unavailable로 응답
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(CircuitBreakerException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleCircuitBreakerException(
|
||||
CircuitBreakerException ex, HttpServletRequest request) {
|
||||
log.warn("Circuit Breaker 예외 발생: {} - {}, 서비스: {}",
|
||||
ex.getErrorCode(), ex.getMessage(), ex.getServiceName());
|
||||
|
||||
// Circuit Breaker 오류는 503 Service Unavailable로 응답
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 비즈니스 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(
|
||||
BusinessException ex, HttpServletRequest request) {
|
||||
log.warn("비즈니스 예외 발생: {} - {}", ex.getErrorCode(), ex.getMessage());
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.failure(ex.getErrorCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bean Validation 예외 처리 (@Valid 어노테이션)
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
|
||||
MethodArgumentNotValidException ex) {
|
||||
log.warn("입력값 검증 실패: {}", ex.getMessage());
|
||||
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
|
||||
errors.put(error.getField(), error.getDefaultMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.<Map<String, String>>builder()
|
||||
.success(false)
|
||||
.data(errors)
|
||||
.message("입력값이 올바르지 않습니다")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bean Validation 예외 처리 (@ModelAttribute)
|
||||
*/
|
||||
@ExceptionHandler(BindException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleBindException(BindException ex) {
|
||||
log.warn("바인딩 검증 실패: {}", ex.getMessage());
|
||||
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
|
||||
errors.put(error.getField(), error.getDefaultMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.<Map<String, String>>builder()
|
||||
.success(false)
|
||||
.data(errors)
|
||||
.message("입력값이 올바르지 않습니다")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Constraint Validation 예외 처리 (경로 변수, 요청 파라미터)
|
||||
*/
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleConstraintViolationException(
|
||||
ConstraintViolationException ex) {
|
||||
log.warn("제약조건 검증 실패: {}", ex.getMessage());
|
||||
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
|
||||
String fieldName = violation.getPropertyPath().toString();
|
||||
errors.put(fieldName, violation.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.<Map<String, String>>builder()
|
||||
.success(false)
|
||||
.data(errors)
|
||||
.message("입력값이 올바르지 않습니다")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 요청 파라미터 누락 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMissingParameterException(
|
||||
MissingServletRequestParameterException ex) {
|
||||
log.warn("필수 파라미터 누락: {}", ex.getMessage());
|
||||
|
||||
String message = String.format("필수 파라미터가 누락되었습니다: %s", ex.getParameterName());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.failure("MISSING_PARAMETER", message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 타입 불일치 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(
|
||||
MethodArgumentTypeMismatchException ex) {
|
||||
log.warn("파라미터 타입 불일치: {}", ex.getMessage());
|
||||
|
||||
String message = String.format("파라미터 '%s'의 값이 올바르지 않습니다", ex.getName());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.failure("INVALID_PARAMETER_TYPE", message));
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 메소드 지원하지 않음 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(
|
||||
HttpRequestMethodNotSupportedException ex) {
|
||||
log.warn("지원하지 않는 HTTP 메소드: {}", ex.getMessage());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
.body(ApiResponse.failure("METHOD_NOT_ALLOWED",
|
||||
"지원하지 않는 HTTP 메소드입니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 파싱 오류 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException(
|
||||
HttpMessageNotReadableException ex) {
|
||||
log.warn("JSON 파싱 오류: {}", ex.getMessage());
|
||||
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.failure("INVALID_JSON_FORMAT",
|
||||
"요청 데이터 형식이 올바르지 않습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기타 모든 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGeneralException(
|
||||
Exception ex, HttpServletRequest request) {
|
||||
log.error("예상치 못한 시스템 오류 발생: ", ex);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure("INTERNAL_SERVER_ERROR",
|
||||
"서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package com.phonebill.bill.exception;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동 관련 예외 클래스
|
||||
*
|
||||
* KOS(통신사 백엔드 시스템)와의 연동에서 발생하는 오류를 처리
|
||||
* - 네트워크 연결 실패
|
||||
* - 응답 시간 초과
|
||||
* - KOS API 오류 응답
|
||||
* - 데이터 변환 오류
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
public class KosConnectionException extends BusinessException {
|
||||
|
||||
/**
|
||||
* 연동 서비스명
|
||||
*/
|
||||
private final String serviceName;
|
||||
|
||||
/**
|
||||
* 기본 생성자
|
||||
*
|
||||
* @param serviceName 연동 서비스명
|
||||
* @param message 오류 메시지
|
||||
*/
|
||||
public KosConnectionException(String serviceName, String message) {
|
||||
super("KOS_CONNECTION_ERROR", message);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 정보를 포함한 생성자
|
||||
*
|
||||
* @param serviceName 연동 서비스명
|
||||
* @param message 오류 메시지
|
||||
* @param detail 상세 오류 정보
|
||||
*/
|
||||
public KosConnectionException(String serviceName, String message, String detail) {
|
||||
super("KOS_CONNECTION_ERROR", message, detail);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원인 예외를 포함한 생성자
|
||||
*
|
||||
* @param serviceName 연동 서비스명
|
||||
* @param message 오류 메시지
|
||||
* @param cause 원인 예외
|
||||
*/
|
||||
public KosConnectionException(String serviceName, String message, Throwable cause) {
|
||||
super("KOS_CONNECTION_ERROR", message, cause);
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
|
||||
public String getServiceName() {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
// 특정 오류 상황을 위한 정적 팩토리 메소드들
|
||||
|
||||
/**
|
||||
* 연결 시간 초과 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param timeout 시간 초과 값(초)
|
||||
* @return KosConnectionException
|
||||
*/
|
||||
public static KosConnectionException timeout(String serviceName, int timeout) {
|
||||
return new KosConnectionException(serviceName,
|
||||
String.format("KOS 연결 시간 초과 (%d초)", timeout));
|
||||
}
|
||||
|
||||
/**
|
||||
* 네트워크 연결 실패 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param host 호스트명
|
||||
* @param port 포트번호
|
||||
* @return KosConnectionException
|
||||
*/
|
||||
public static KosConnectionException connectionFailed(String serviceName, String host, int port) {
|
||||
return new KosConnectionException(serviceName,
|
||||
String.format("KOS 연결 실패 - %s:%d", host, port));
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS API 오류 응답 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param errorCode KOS 오류 코드
|
||||
* @param errorMessage KOS 오류 메시지
|
||||
* @return KosConnectionException
|
||||
*/
|
||||
public static KosConnectionException apiError(String serviceName, String errorCode, String errorMessage) {
|
||||
return new KosConnectionException(serviceName, errorCode,
|
||||
String.format("KOS API 오류 - 코드: %s, 메시지: %s", errorCode, errorMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 변환 오류 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param dataType 데이터 타입
|
||||
* @param cause 원인 예외
|
||||
* @return KosConnectionException
|
||||
*/
|
||||
public static KosConnectionException dataConversionError(String serviceName, String dataType, Throwable cause) {
|
||||
return new KosConnectionException(serviceName,
|
||||
String.format("KOS 데이터 변환 오류 - 타입: %s", dataType), cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* 네트워크 오류 예외
|
||||
*
|
||||
* @param serviceName 서비스명
|
||||
* @param cause 원인 예외
|
||||
* @return KosConnectionException
|
||||
*/
|
||||
public static KosConnectionException networkError(String serviceName, Throwable cause) {
|
||||
return new KosConnectionException(serviceName,
|
||||
"KOS 네트워크 연결 오류", cause);
|
||||
}
|
||||
}
|
||||
203
bill-service/src/main/java/com/phonebill/bill/external/KosRequest.java
vendored
Normal file
203
bill-service/src/main/java/com/phonebill/bill/external/KosRequest.java
vendored
Normal file
@ -0,0 +1,203 @@
|
||||
package com.phonebill.bill.external;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* KOS 시스템 요청 모델
|
||||
*
|
||||
* 통신사 백엔드 시스템(KOS)으로 전송하는 요청 데이터 구조
|
||||
* - 요금조회 요청에 필요한 정보 포함
|
||||
* - KOS API 스펙에 맞춘 필드명 매핑
|
||||
* - 요청 추적을 위한 메타 정보 포함
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KosRequest {
|
||||
|
||||
/**
|
||||
* 회선번호 (KOS 필드명: lineNum)
|
||||
*/
|
||||
@JsonProperty("lineNum")
|
||||
private String lineNumber;
|
||||
|
||||
/**
|
||||
* 조회월 (KOS 필드명: searchMonth, YYYY-MM 형식)
|
||||
*/
|
||||
@JsonProperty("searchMonth")
|
||||
private String inquiryMonth;
|
||||
|
||||
/**
|
||||
* 서비스 구분 코드 (KOS 필드명: svcDiv)
|
||||
* - BILL_INQ: 요금조회
|
||||
* - BILL_DETAIL: 상세조회
|
||||
*/
|
||||
@JsonProperty("svcDiv")
|
||||
@Builder.Default
|
||||
private String serviceCode = "BILL_INQ";
|
||||
|
||||
/**
|
||||
* 요청 시스템 코드 (KOS 필드명: reqSysCode)
|
||||
*/
|
||||
@JsonProperty("reqSysCode")
|
||||
@Builder.Default
|
||||
private String requestSystemCode = "MVNO";
|
||||
|
||||
/**
|
||||
* 요청 채널 코드 (KOS 필드명: reqChnlCode)
|
||||
* - WEB: 웹
|
||||
* - APP: 모바일앱
|
||||
* - API: API
|
||||
*/
|
||||
@JsonProperty("reqChnlCode")
|
||||
@Builder.Default
|
||||
private String requestChannelCode = "API";
|
||||
|
||||
/**
|
||||
* 요청자 ID (KOS 필드명: reqUserId)
|
||||
*/
|
||||
@JsonProperty("reqUserId")
|
||||
private String requestUserId;
|
||||
|
||||
/**
|
||||
* 요청일시 (KOS 필드명: reqDttm, YYYY-MM-DD HH:MM:SS 형식)
|
||||
*/
|
||||
@JsonProperty("reqDttm")
|
||||
private LocalDateTime requestTime;
|
||||
|
||||
/**
|
||||
* 요청 고유번호 (KOS 필드명: reqSeqNo)
|
||||
*/
|
||||
@JsonProperty("reqSeqNo")
|
||||
private String requestSequenceNumber;
|
||||
|
||||
/**
|
||||
* 고객 구분 코드 (KOS 필드명: custDiv)
|
||||
* - PERS: 개인
|
||||
* - CORP: 법인
|
||||
*/
|
||||
@JsonProperty("custDiv")
|
||||
@Builder.Default
|
||||
private String customerType = "PERS";
|
||||
|
||||
/**
|
||||
* 인증 토큰 (KOS 필드명: authToken)
|
||||
*/
|
||||
@JsonProperty("authToken")
|
||||
private String authToken;
|
||||
|
||||
/**
|
||||
* 응답 형식 (KOS 필드명: respFormat)
|
||||
*/
|
||||
@JsonProperty("respFormat")
|
||||
@Builder.Default
|
||||
private String responseFormat = "JSON";
|
||||
|
||||
/**
|
||||
* 타임아웃 설정 (초, KOS 필드명: timeout)
|
||||
*/
|
||||
@JsonProperty("timeout")
|
||||
@Builder.Default
|
||||
private Integer timeout = 30;
|
||||
|
||||
// === Static Factory Methods ===
|
||||
|
||||
/**
|
||||
* 요금조회 요청 생성
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @param requestUserId 요청자 ID
|
||||
* @return KOS 요청 객체
|
||||
*/
|
||||
public static KosRequest createBillInquiryRequest(String lineNumber, String inquiryMonth, String requestUserId) {
|
||||
return KosRequest.builder()
|
||||
.lineNumber(lineNumber)
|
||||
.inquiryMonth(inquiryMonth)
|
||||
.requestUserId(requestUserId)
|
||||
.requestTime(LocalDateTime.now())
|
||||
.requestSequenceNumber(generateSequenceNumber())
|
||||
.serviceCode("BILL_INQ")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세조회 요청 생성
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @param requestUserId 요청자 ID
|
||||
* @return KOS 요청 객체
|
||||
*/
|
||||
public static KosRequest createBillDetailRequest(String lineNumber, String inquiryMonth, String requestUserId) {
|
||||
return KosRequest.builder()
|
||||
.lineNumber(lineNumber)
|
||||
.inquiryMonth(inquiryMonth)
|
||||
.requestUserId(requestUserId)
|
||||
.requestTime(LocalDateTime.now())
|
||||
.requestSequenceNumber(generateSequenceNumber())
|
||||
.serviceCode("BILL_DETAIL")
|
||||
.build();
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
/**
|
||||
* 요청 순번 생성
|
||||
*
|
||||
* @return 요청 순번
|
||||
*/
|
||||
private static String generateSequenceNumber() {
|
||||
return String.valueOf(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 토큰 설정
|
||||
*
|
||||
* @param authToken 인증 토큰
|
||||
*/
|
||||
public void setAuthToken(String authToken) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청자 ID 설정
|
||||
*
|
||||
* @param requestUserId 요청자 ID
|
||||
*/
|
||||
public void setRequestUserId(String requestUserId) {
|
||||
this.requestUserId = requestUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 유효성 검증
|
||||
*
|
||||
* @return 유효한 요청인지 여부
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return lineNumber != null && !lineNumber.trim().isEmpty() &&
|
||||
inquiryMonth != null && !inquiryMonth.trim().isEmpty() &&
|
||||
requestUserId != null && !requestUserId.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 정보 요약
|
||||
*
|
||||
* @return 요청 요약 정보
|
||||
*/
|
||||
public String getSummary() {
|
||||
return String.format("KOS 요청 - 회선: %s, 조회월: %s, 서비스: %s",
|
||||
lineNumber, inquiryMonth, serviceCode);
|
||||
}
|
||||
}
|
||||
344
bill-service/src/main/java/com/phonebill/bill/external/KosResponse.java
vendored
Normal file
344
bill-service/src/main/java/com/phonebill/bill/external/KosResponse.java
vendored
Normal file
@ -0,0 +1,344 @@
|
||||
package com.phonebill.bill.external;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* KOS 시스템 응답 모델
|
||||
*
|
||||
* 통신사 백엔드 시스템(KOS)에서 수신하는 응답 데이터 구조
|
||||
* - 요금조회 결과 데이터 포함
|
||||
* - KOS API 스펙에 맞춘 필드명 매핑
|
||||
* - 내부 모델로 변환하기 위한 구조 제공
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KosResponse {
|
||||
|
||||
/**
|
||||
* 요청 ID (KOS 필드명: reqId)
|
||||
*/
|
||||
@JsonProperty("reqId")
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 처리 상태 (KOS 필드명: procStatus)
|
||||
* - SUCCESS: 성공
|
||||
* - PROCESSING: 처리 중
|
||||
* - FAILED: 실패
|
||||
*/
|
||||
@JsonProperty("procStatus")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 결과 코드 (KOS 필드명: resultCode)
|
||||
* - 0000: 성공
|
||||
* - 기타: 오류 코드
|
||||
*/
|
||||
@JsonProperty("resultCode")
|
||||
private String resultCode;
|
||||
|
||||
/**
|
||||
* 결과 메시지 (KOS 필드명: resultMsg)
|
||||
*/
|
||||
@JsonProperty("resultMsg")
|
||||
private String resultMessage;
|
||||
|
||||
/**
|
||||
* 응답일시 (KOS 필드명: respDttm)
|
||||
*/
|
||||
@JsonProperty("respDttm")
|
||||
private LocalDateTime responseTime;
|
||||
|
||||
/**
|
||||
* 처리 시간 (밀리초, KOS 필드명: procTimeMs)
|
||||
*/
|
||||
@JsonProperty("procTimeMs")
|
||||
private Long processingTimeMs;
|
||||
|
||||
/**
|
||||
* 요금 데이터 (KOS 필드명: billData)
|
||||
*/
|
||||
@JsonProperty("billData")
|
||||
private BillData billData;
|
||||
|
||||
/**
|
||||
* 추가 정보 (KOS 필드명: addInfo)
|
||||
*/
|
||||
@JsonProperty("addInfo")
|
||||
private String additionalInfo;
|
||||
|
||||
/**
|
||||
* 요금 데이터 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class BillData {
|
||||
|
||||
/**
|
||||
* 요금제명 (KOS 필드명: prodNm)
|
||||
*/
|
||||
@JsonProperty("prodNm")
|
||||
private String productName;
|
||||
|
||||
/**
|
||||
* 계약 정보 (KOS 필드명: contractInfo)
|
||||
*/
|
||||
@JsonProperty("contractInfo")
|
||||
private String contractInfo;
|
||||
|
||||
/**
|
||||
* 청구월 (KOS 필드명: billMonth)
|
||||
*/
|
||||
@JsonProperty("billMonth")
|
||||
private String billingMonth;
|
||||
|
||||
/**
|
||||
* 청구 금액 (KOS 필드명: billAmt)
|
||||
*/
|
||||
@JsonProperty("billAmt")
|
||||
private Integer totalAmount;
|
||||
|
||||
/**
|
||||
* 할인 정보 목록 (KOS 필드명: discList)
|
||||
*/
|
||||
@JsonProperty("discList")
|
||||
private List<DiscountData> discounts;
|
||||
|
||||
/**
|
||||
* 사용량 정보 (KOS 필드명: usageInfo)
|
||||
*/
|
||||
@JsonProperty("usageInfo")
|
||||
private UsageData usage;
|
||||
|
||||
/**
|
||||
* 위약금 (KOS 필드명: penaltyAmt)
|
||||
*/
|
||||
@JsonProperty("penaltyAmt")
|
||||
private Integer terminationFee;
|
||||
|
||||
/**
|
||||
* 할부금 잔액 (KOS 필드명: installAmt)
|
||||
*/
|
||||
@JsonProperty("installAmt")
|
||||
private Integer deviceInstallment;
|
||||
|
||||
/**
|
||||
* 결제 정보 (KOS 필드명: payInfo)
|
||||
*/
|
||||
@JsonProperty("payInfo")
|
||||
private PaymentData payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 할인 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class DiscountData {
|
||||
|
||||
/**
|
||||
* 할인명 (KOS 필드명: discNm)
|
||||
*/
|
||||
@JsonProperty("discNm")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 할인 금액 (KOS 필드명: discAmt)
|
||||
*/
|
||||
@JsonProperty("discAmt")
|
||||
private Integer amount;
|
||||
|
||||
/**
|
||||
* 할인 유형 (KOS 필드명: discType)
|
||||
*/
|
||||
@JsonProperty("discType")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 할인 기간 (KOS 필드명: discPeriod)
|
||||
*/
|
||||
@JsonProperty("discPeriod")
|
||||
private String period;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UsageData {
|
||||
|
||||
/**
|
||||
* 통화 사용량 (KOS 필드명: voiceUsage)
|
||||
*/
|
||||
@JsonProperty("voiceUsage")
|
||||
private String voice;
|
||||
|
||||
/**
|
||||
* SMS 사용량 (KOS 필드명: smsUsage)
|
||||
*/
|
||||
@JsonProperty("smsUsage")
|
||||
private String sms;
|
||||
|
||||
/**
|
||||
* 데이터 사용량 (KOS 필드명: dataUsage)
|
||||
*/
|
||||
@JsonProperty("dataUsage")
|
||||
private String data;
|
||||
|
||||
/**
|
||||
* 기본료 (KOS 필드명: basicFee)
|
||||
*/
|
||||
@JsonProperty("basicFee")
|
||||
private Integer basicFee;
|
||||
|
||||
/**
|
||||
* 초과료 (KOS 필드명: overageFee)
|
||||
*/
|
||||
@JsonProperty("overageFee")
|
||||
private Integer overageFee;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PaymentData {
|
||||
|
||||
/**
|
||||
* 청구일 (KOS 필드명: billDate)
|
||||
*/
|
||||
@JsonProperty("billDate")
|
||||
private String billingDate;
|
||||
|
||||
/**
|
||||
* 결제 상태 (KOS 필드명: payStatus)
|
||||
* - PAID: 결제 완료
|
||||
* - UNPAID: 미결제
|
||||
* - OVERDUE: 연체
|
||||
*/
|
||||
@JsonProperty("payStatus")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 결제 방법 (KOS 필드명: payMethod)
|
||||
*/
|
||||
@JsonProperty("payMethod")
|
||||
private String method;
|
||||
|
||||
/**
|
||||
* 결제일 (KOS 필드명: payDate)
|
||||
*/
|
||||
@JsonProperty("payDate")
|
||||
private String paymentDate;
|
||||
|
||||
/**
|
||||
* 결제 은행 (KOS 필드명: payBank)
|
||||
*/
|
||||
@JsonProperty("payBank")
|
||||
private String paymentBank;
|
||||
|
||||
/**
|
||||
* 계좌번호 (마스킹, KOS 필드명: acctNum)
|
||||
*/
|
||||
@JsonProperty("acctNum")
|
||||
private String accountNumber;
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
/**
|
||||
* 성공 응답인지 확인
|
||||
*
|
||||
* @return 성공 여부
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return "SUCCESS".equalsIgnoreCase(status) && "0000".equals(resultCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중 상태인지 확인
|
||||
*
|
||||
* @return 처리 중 여부
|
||||
*/
|
||||
public boolean isProcessing() {
|
||||
return "PROCESSING".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답인지 확인
|
||||
*
|
||||
* @return 실패 여부
|
||||
*/
|
||||
public boolean isFailed() {
|
||||
return "FAILED".equalsIgnoreCase(status) || (!"0000".equals(resultCode) && resultCode != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 데이터 존재 여부 확인
|
||||
*
|
||||
* @return 요금 데이터 존재 여부
|
||||
*/
|
||||
public boolean hasBillData() {
|
||||
return billData != null && billData.getTotalAmount() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 정보 조회
|
||||
*
|
||||
* @return 오류 정보 (결과코드: 결과메시지)
|
||||
*/
|
||||
public String getErrorInfo() {
|
||||
if (isFailed()) {
|
||||
return String.format("%s: %s", resultCode, resultMessage);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 요약 정보
|
||||
*
|
||||
* @return 응답 요약
|
||||
*/
|
||||
public String getSummary() {
|
||||
if (isSuccess() && hasBillData()) {
|
||||
return String.format("KOS 응답 성공 - 요금제: %s, 금액: %,d원",
|
||||
billData.getProductName(), billData.getTotalAmount());
|
||||
} else if (isProcessing()) {
|
||||
return "KOS 응답 - 처리 중";
|
||||
} else {
|
||||
return String.format("KOS 응답 실패 - %s", getErrorInfo());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 시간이 느린지 확인 (임계값: 3초)
|
||||
*
|
||||
* @return 느린 응답 여부
|
||||
*/
|
||||
public boolean isSlowResponse() {
|
||||
return processingTimeMs != null && processingTimeMs > 3000;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
package com.phonebill.bill.repository;
|
||||
|
||||
import com.phonebill.bill.repository.entity.BillInquiryHistoryEntity;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 Repository 인터페이스
|
||||
*
|
||||
* 요금조회 이력 데이터에 대한 접근을 담당하는 Repository
|
||||
* - JPA를 통한 기본 CRUD 작업
|
||||
* - 복합 조건 검색을 위한 커스텀 쿼리
|
||||
* - 페이징 처리를 통한 대용량 데이터 조회
|
||||
* - 성능 최적화를 위한 인덱스 활용 쿼리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Repository
|
||||
public interface BillInquiryHistoryRepository extends JpaRepository<BillInquiryHistoryEntity, Long> {
|
||||
|
||||
/**
|
||||
* 요청 ID로 이력 조회
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @return 이력 엔티티 (Optional)
|
||||
*/
|
||||
Optional<BillInquiryHistoryEntity> findByRequestId(String requestId);
|
||||
|
||||
/**
|
||||
* 회선번호로 이력 목록 조회 (최신순)
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이력 페이지
|
||||
*/
|
||||
Page<BillInquiryHistoryEntity> findByLineNumberOrderByRequestTimeDesc(
|
||||
String lineNumber, Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 회선번호와 상태로 이력 목록 조회
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param status 처리 상태
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이력 페이지
|
||||
*/
|
||||
Page<BillInquiryHistoryEntity> findByLineNumberAndStatusOrderByRequestTimeDesc(
|
||||
String lineNumber, String status, Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 회선번호 목록으로 이력 조회 (사용자 권한 기반)
|
||||
*
|
||||
* @param lineNumbers 회선번호 목록
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이력 페이지
|
||||
*/
|
||||
Page<BillInquiryHistoryEntity> findByLineNumberInOrderByRequestTimeDesc(
|
||||
List<String> lineNumbers, Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 기간별 이력 조회
|
||||
*
|
||||
* @param startTime 조회 시작 시간
|
||||
* @param endTime 조회 종료 시간
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이력 페이지
|
||||
*/
|
||||
Page<BillInquiryHistoryEntity> findByRequestTimeBetweenOrderByRequestTimeDesc(
|
||||
LocalDateTime startTime, LocalDateTime endTime, Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 복합 조건을 통한 이력 조회 (동적 쿼리)
|
||||
*
|
||||
* @param lineNumbers 사용자 권한이 있는 회선번호 목록
|
||||
* @param lineNumber 특정 회선번호 필터 (선택)
|
||||
* @param startTime 조회 시작 시간 (선택)
|
||||
* @param endTime 조회 종료 시간 (선택)
|
||||
* @param status 처리 상태 필터 (선택)
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이력 페이지
|
||||
*/
|
||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.lineNumber IN :lineNumbers " +
|
||||
"AND (:lineNumber IS NULL OR h.lineNumber = :lineNumber) " +
|
||||
"AND (:startTime IS NULL OR h.requestTime >= :startTime) " +
|
||||
"AND (:endTime IS NULL OR h.requestTime <= :endTime) " +
|
||||
"AND (:status IS NULL OR h.status = :status) " +
|
||||
"ORDER BY h.requestTime DESC")
|
||||
Page<BillInquiryHistoryEntity> findBillHistoryWithFilters(
|
||||
@Param("lineNumbers") List<String> lineNumbers,
|
||||
@Param("lineNumber") String lineNumber,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
@Param("status") String status,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 특정 회선의 최근 이력 조회
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param limit 조회 건수
|
||||
* @return 최근 이력 목록
|
||||
*/
|
||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE h.lineNumber = :lineNumber " +
|
||||
"ORDER BY h.requestTime DESC")
|
||||
List<BillInquiryHistoryEntity> findRecentHistoryByLineNumber(
|
||||
@Param("lineNumber") String lineNumber, Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 처리 상태별 통계 조회
|
||||
*
|
||||
* @param lineNumbers 회선번호 목록
|
||||
* @param startTime 조회 시작 시간
|
||||
* @param endTime 조회 종료 시간
|
||||
* @return 상태별 개수 목록
|
||||
*/
|
||||
@Query("SELECT h.status, COUNT(h) FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.lineNumber IN :lineNumbers " +
|
||||
"AND h.requestTime BETWEEN :startTime AND :endTime " +
|
||||
"GROUP BY h.status")
|
||||
List<Object[]> getStatusStatistics(
|
||||
@Param("lineNumbers") List<String> lineNumbers,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime
|
||||
);
|
||||
|
||||
/**
|
||||
* 처리 시간이 긴 요청 조회 (성능 모니터링용)
|
||||
*
|
||||
* @param thresholdMs 임계값 (밀리초)
|
||||
* @param startTime 조회 시작 시간
|
||||
* @param endTime 조회 종료 시간
|
||||
* @param pageable 페이징 정보
|
||||
* @return 느린 요청 목록
|
||||
*/
|
||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.kosResponseTimeMs > :thresholdMs " +
|
||||
"AND h.requestTime BETWEEN :startTime AND :endTime " +
|
||||
"ORDER BY h.kosResponseTimeMs DESC")
|
||||
Page<BillInquiryHistoryEntity> findSlowRequests(
|
||||
@Param("thresholdMs") Long thresholdMs,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 캐시 히트율 통계 조회
|
||||
*
|
||||
* @param startTime 조회 시작 시간
|
||||
* @param endTime 조회 종료 시간
|
||||
* @return [총 요청 수, 캐시 히트 수]
|
||||
*/
|
||||
@Query("SELECT COUNT(h), SUM(CASE WHEN h.cacheHit = true THEN 1 ELSE 0 END) " +
|
||||
"FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.requestTime BETWEEN :startTime AND :endTime")
|
||||
Object[] getCacheHitRateStatistics(
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime
|
||||
);
|
||||
|
||||
/**
|
||||
* 실패한 요청 조회 (디버깅용)
|
||||
*
|
||||
* @param lineNumbers 회선번호 목록
|
||||
* @param startTime 조회 시작 시간
|
||||
* @param endTime 조회 종료 시간
|
||||
* @param pageable 페이징 정보
|
||||
* @return 실패한 요청 목록
|
||||
*/
|
||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.lineNumber IN :lineNumbers " +
|
||||
"AND h.status = 'FAILED' " +
|
||||
"AND h.requestTime BETWEEN :startTime AND :endTime " +
|
||||
"ORDER BY h.requestTime DESC")
|
||||
Page<BillInquiryHistoryEntity> findFailedRequests(
|
||||
@Param("lineNumbers") List<String> lineNumbers,
|
||||
@Param("startTime") LocalDateTime startTime,
|
||||
@Param("endTime") LocalDateTime endTime,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 오래된 처리 중 상태 요청 조회 (데이터 정리용)
|
||||
*
|
||||
* @param thresholdTime 임계 시간 (이 시간 이전의 PROCESSING 상태 요청)
|
||||
* @return 오래된 처리 중 요청 목록
|
||||
*/
|
||||
@Query("SELECT h FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.status = 'PROCESSING' AND h.requestTime < :thresholdTime " +
|
||||
"ORDER BY h.requestTime")
|
||||
List<BillInquiryHistoryEntity> findOldProcessingRequests(
|
||||
@Param("thresholdTime") LocalDateTime thresholdTime
|
||||
);
|
||||
|
||||
/**
|
||||
* 특정 조회월의 이력 개수 조회
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @return 이력 개수
|
||||
*/
|
||||
long countByLineNumberAndInquiryMonth(String lineNumber, String inquiryMonth);
|
||||
|
||||
/**
|
||||
* 회선번호별 이력 개수 조회
|
||||
*
|
||||
* @param lineNumbers 회선번호 목록
|
||||
* @return 회선번호별 이력 개수
|
||||
*/
|
||||
@Query("SELECT h.lineNumber, COUNT(h) FROM BillInquiryHistoryEntity h WHERE " +
|
||||
"h.lineNumber IN :lineNumbers GROUP BY h.lineNumber")
|
||||
List<Object[]> getHistoryCountByLineNumber(@Param("lineNumbers") List<String> lineNumbers);
|
||||
|
||||
/**
|
||||
* 데이터 정리를 위한 오래된 이력 삭제
|
||||
*
|
||||
* @param beforeTime 이 시간 이전의 데이터 삭제
|
||||
* @return 삭제된 레코드 수
|
||||
*/
|
||||
@Query("DELETE FROM BillInquiryHistoryEntity h WHERE h.requestTime < :beforeTime")
|
||||
int deleteByRequestTimeBefore(@Param("beforeTime") LocalDateTime beforeTime);
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
package com.phonebill.bill.repository.entity;
|
||||
|
||||
import com.phonebill.bill.domain.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 엔티티
|
||||
*
|
||||
* 요금조회 요청 및 처리 이력을 저장하는 엔티티
|
||||
* - 요청 ID를 통한 추적 가능
|
||||
* - 처리 상태별 이력 관리
|
||||
* - 성능을 위한 인덱스 최적화
|
||||
* - 페이징 처리를 위한 정렬 기준 제공
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "bill_inquiry_history",
|
||||
indexes = {
|
||||
@Index(name = "idx_request_id", columnList = "request_id"),
|
||||
@Index(name = "idx_line_number", columnList = "line_number"),
|
||||
@Index(name = "idx_request_time", columnList = "request_time"),
|
||||
@Index(name = "idx_status", columnList = "status"),
|
||||
@Index(name = "idx_line_request_time", columnList = "line_number, request_time")
|
||||
}
|
||||
)
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class BillInquiryHistoryEntity extends BaseTimeEntity {
|
||||
|
||||
/**
|
||||
* 기본 키 (자동 증가)
|
||||
*/
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 요금조회 요청 ID (고유 식별자)
|
||||
*/
|
||||
@Column(name = "request_id", nullable = false, unique = true, length = 50)
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 회선번호
|
||||
*/
|
||||
@Column(name = "line_number", nullable = false, length = 15)
|
||||
private String lineNumber;
|
||||
|
||||
/**
|
||||
* 조회월 (YYYY-MM 형식)
|
||||
*/
|
||||
@Column(name = "inquiry_month", nullable = false, length = 7)
|
||||
private String inquiryMonth;
|
||||
|
||||
/**
|
||||
* 요청일시
|
||||
*/
|
||||
@Column(name = "request_time", nullable = false)
|
||||
private LocalDateTime requestTime;
|
||||
|
||||
/**
|
||||
* 처리일시
|
||||
*/
|
||||
@Column(name = "process_time")
|
||||
private LocalDateTime processTime;
|
||||
|
||||
/**
|
||||
* 처리 상태 (COMPLETED, PROCESSING, FAILED)
|
||||
*/
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 결과 요약 (성공시 요금제명과 금액, 실패시 오류 메시지)
|
||||
*/
|
||||
@Column(name = "result_summary", length = 500)
|
||||
private String resultSummary;
|
||||
|
||||
/**
|
||||
* KOS 응답 시간 (성능 모니터링용)
|
||||
*/
|
||||
@Column(name = "kos_response_time_ms")
|
||||
private Long kosResponseTimeMs;
|
||||
|
||||
/**
|
||||
* 캐시 히트 여부 (성능 모니터링용)
|
||||
*/
|
||||
@Column(name = "cache_hit")
|
||||
private Boolean cacheHit;
|
||||
|
||||
/**
|
||||
* 오류 코드 (실패시)
|
||||
*/
|
||||
@Column(name = "error_code", length = 50)
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 오류 메시지 (실패시)
|
||||
*/
|
||||
@Column(name = "error_message", length = 1000)
|
||||
private String errorMessage;
|
||||
|
||||
// === Business Methods ===
|
||||
|
||||
/**
|
||||
* 상태 업데이트
|
||||
*
|
||||
* @param newStatus 새로운 상태
|
||||
*/
|
||||
public void updateStatus(String newStatus) {
|
||||
this.status = newStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 시간 업데이트
|
||||
*
|
||||
* @param processTime 처리 완료 시간
|
||||
*/
|
||||
public void updateProcessTime(LocalDateTime processTime) {
|
||||
this.processTime = processTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 요약 업데이트
|
||||
*
|
||||
* @param resultSummary 결과 요약
|
||||
*/
|
||||
public void updateResultSummary(String resultSummary) {
|
||||
this.resultSummary = resultSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 응답 시간 설정
|
||||
*
|
||||
* @param kosResponseTimeMs KOS 응답 시간 (밀리초)
|
||||
*/
|
||||
public void setKosResponseTime(Long kosResponseTimeMs) {
|
||||
this.kosResponseTimeMs = kosResponseTimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 히트 여부 설정
|
||||
*
|
||||
* @param cacheHit 캐시 히트 여부
|
||||
*/
|
||||
public void setCacheHit(Boolean cacheHit) {
|
||||
this.cacheHit = cacheHit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 정보 설정
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param errorMessage 오류 메시지
|
||||
*/
|
||||
public void setErrorInfo(String errorCode, String errorMessage) {
|
||||
this.errorCode = errorCode;
|
||||
this.errorMessage = errorMessage;
|
||||
this.status = "FAILED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 완료로 상태 변경
|
||||
*
|
||||
* @param resultSummary 결과 요약
|
||||
*/
|
||||
public void markAsCompleted(String resultSummary) {
|
||||
this.status = "COMPLETED";
|
||||
this.processTime = LocalDateTime.now();
|
||||
this.resultSummary = resultSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 실패로 상태 변경
|
||||
*
|
||||
* @param errorCode 오류 코드
|
||||
* @param errorMessage 오류 메시지
|
||||
*/
|
||||
public void markAsFailed(String errorCode, String errorMessage) {
|
||||
this.status = "FAILED";
|
||||
this.processTime = LocalDateTime.now();
|
||||
this.errorCode = errorCode;
|
||||
this.errorMessage = errorMessage;
|
||||
this.resultSummary = "조회 실패: " + errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 중 상태인지 확인
|
||||
*
|
||||
* @return 처리 중 상태 여부
|
||||
*/
|
||||
public boolean isProcessing() {
|
||||
return "PROCESSING".equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 완료 상태인지 확인
|
||||
*
|
||||
* @return 처리 완료 상태 여부
|
||||
*/
|
||||
public boolean isCompleted() {
|
||||
return "COMPLETED".equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 실패 상태인지 확인
|
||||
*
|
||||
* @return 처리 실패 상태 여부
|
||||
*/
|
||||
public boolean isFailed() {
|
||||
return "FAILED".equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에서 조회된 요청인지 확인
|
||||
*
|
||||
* @return 캐시 히트 여부
|
||||
*/
|
||||
public boolean isCacheHit() {
|
||||
return Boolean.TRUE.equals(this.cacheHit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 소요 시간 계산 (밀리초)
|
||||
*
|
||||
* @return 처리 소요 시간 (밀리초), 처리 중이거나 처리시간이 없으면 null
|
||||
*/
|
||||
public Long getProcessingTimeMs() {
|
||||
if (requestTime != null && processTime != null) {
|
||||
return java.time.Duration.between(requestTime, processTime).toMillis();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,242 @@
|
||||
package com.phonebill.bill.service;
|
||||
|
||||
import com.phonebill.bill.dto.BillInquiryResponse;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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 java.time.Duration;
|
||||
|
||||
/**
|
||||
* 요금조회 캐시 서비스
|
||||
*
|
||||
* Redis를 활용한 요금 정보 캐싱으로 성능 최적화 구현
|
||||
* Cache-Aside 패턴을 적용하여 데이터 일관성과 성능을 균형있게 관리
|
||||
*
|
||||
* 캐시 전략:
|
||||
* - 요금 정보: 1시간 TTL (외부 시스템 연동 부하 감소)
|
||||
* - 고객 정보: 4시간 TTL (변경 빈도가 낮음)
|
||||
* - 조회 가능 월: 24시간 TTL (일별 업데이트)
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BillCacheService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// 캐시 TTL 상수
|
||||
private static final Duration BILL_DATA_TTL = Duration.ofHours(1);
|
||||
private static final Duration CUSTOMER_INFO_TTL = Duration.ofHours(4);
|
||||
private static final Duration AVAILABLE_MONTHS_TTL = Duration.ofHours(24);
|
||||
|
||||
// 캐시 키 접두사
|
||||
private static final String BILL_DATA_PREFIX = "bill:data:";
|
||||
private static final String CUSTOMER_INFO_PREFIX = "bill:customer:";
|
||||
private static final String AVAILABLE_MONTHS_PREFIX = "bill:months:";
|
||||
|
||||
/**
|
||||
* 캐시에서 요금 데이터 조회
|
||||
*
|
||||
* 캐시 키: bill:data:{lineNumber}:{inquiryMonth}
|
||||
* TTL: 1시간
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @return 캐시된 요금 데이터 (없으면 null)
|
||||
*/
|
||||
@Cacheable(value = "billData", key = "#lineNumber + ':' + #inquiryMonth")
|
||||
public BillInquiryResponse getCachedBillData(String lineNumber, String inquiryMonth) {
|
||||
log.debug("요금 데이터 캐시 조회 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||
|
||||
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
||||
|
||||
try {
|
||||
Object cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cachedData != null) {
|
||||
BillInquiryResponse response = objectMapper.convertValue(cachedData, BillInquiryResponse.class);
|
||||
log.info("요금 데이터 캐시 히트 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||
return response;
|
||||
}
|
||||
|
||||
log.debug("요금 데이터 캐시 미스 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금 데이터 캐시 조회 오류 - 회선: {}, 조회월: {}, 오류: {}",
|
||||
lineNumber, inquiryMonth, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금 데이터를 캐시에 저장
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @param billData 요금 데이터
|
||||
*/
|
||||
public void cacheBillData(String lineNumber, String inquiryMonth, BillInquiryResponse billData) {
|
||||
log.debug("요금 데이터 캐시 저장 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||
|
||||
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
||||
|
||||
try {
|
||||
redisTemplate.opsForValue().set(cacheKey, billData, BILL_DATA_TTL);
|
||||
log.info("요금 데이터 캐시 저장 완료 - 회선: {}, 조회월: {}, TTL: {}시간",
|
||||
lineNumber, inquiryMonth, BILL_DATA_TTL.toHours());
|
||||
} catch (Exception e) {
|
||||
log.error("요금 데이터 캐시 저장 오류 - 회선: {}, 조회월: {}, 오류: {}",
|
||||
lineNumber, inquiryMonth, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 정보 캐시 조회
|
||||
*
|
||||
* 캐시 키: bill:customer:{lineNumber}
|
||||
* TTL: 4시간
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @return 캐시된 고객 정보 (없으면 null)
|
||||
*/
|
||||
public Object getCachedCustomerInfo(String lineNumber) {
|
||||
log.debug("고객 정보 캐시 조회 - 회선: {}", lineNumber);
|
||||
|
||||
String cacheKey = CUSTOMER_INFO_PREFIX + lineNumber;
|
||||
|
||||
try {
|
||||
Object cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (cachedData != null) {
|
||||
log.info("고객 정보 캐시 히트 - 회선: {}", lineNumber);
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
log.debug("고객 정보 캐시 미스 - 회선: {}", lineNumber);
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("고객 정보 캐시 조회 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 정보를 캐시에 저장
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param customerInfo 고객 정보
|
||||
*/
|
||||
public void cacheCustomerInfo(String lineNumber, Object customerInfo) {
|
||||
log.debug("고객 정보 캐시 저장 - 회선: {}", lineNumber);
|
||||
|
||||
String cacheKey = CUSTOMER_INFO_PREFIX + lineNumber;
|
||||
|
||||
try {
|
||||
redisTemplate.opsForValue().set(cacheKey, customerInfo, CUSTOMER_INFO_TTL);
|
||||
log.info("고객 정보 캐시 저장 완료 - 회선: {}, TTL: {}시간",
|
||||
lineNumber, CUSTOMER_INFO_TTL.toHours());
|
||||
} catch (Exception e) {
|
||||
log.error("고객 정보 캐시 저장 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 회선의 요금 데이터 캐시 무효화
|
||||
*
|
||||
* 상품 변경 등으로 요금 정보가 변경된 경우 호출
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
*/
|
||||
@CacheEvict(value = "billData", key = "#lineNumber + '*'")
|
||||
public void evictBillDataCache(String lineNumber) {
|
||||
log.info("요금 데이터 캐시 무효화 - 회선: {}", lineNumber);
|
||||
|
||||
try {
|
||||
// 패턴을 사용한 키 삭제
|
||||
String pattern = BILL_DATA_PREFIX + lineNumber + ":*";
|
||||
redisTemplate.delete(redisTemplate.keys(pattern));
|
||||
|
||||
log.info("요금 데이터 캐시 무효화 완료 - 회선: {}", lineNumber);
|
||||
} catch (Exception e) {
|
||||
log.error("요금 데이터 캐시 무효화 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월의 모든 요금 데이터 캐시 무효화
|
||||
*
|
||||
* 시스템 점검이나 대량 데이터 업데이트 시 사용
|
||||
*
|
||||
* @param inquiryMonth 조회월
|
||||
*/
|
||||
public void evictBillDataCacheByMonth(String inquiryMonth) {
|
||||
log.info("월별 요금 데이터 캐시 무효화 - 조회월: {}", inquiryMonth);
|
||||
|
||||
try {
|
||||
// 패턴을 사용한 키 삭제
|
||||
String pattern = BILL_DATA_PREFIX + "*:" + inquiryMonth;
|
||||
redisTemplate.delete(redisTemplate.keys(pattern));
|
||||
|
||||
log.info("월별 요금 데이터 캐시 무효화 완료 - 조회월: {}", inquiryMonth);
|
||||
} catch (Exception e) {
|
||||
log.error("월별 요금 데이터 캐시 무효화 오류 - 조회월: {}, 오류: {}", inquiryMonth, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 요금 데이터 캐시 무효화
|
||||
*
|
||||
* 시스템 점검이나 긴급 상황에서 사용
|
||||
*/
|
||||
@CacheEvict(value = "billData", allEntries = true)
|
||||
public void evictAllBillDataCache() {
|
||||
log.warn("전체 요금 데이터 캐시 무효화 실행");
|
||||
|
||||
try {
|
||||
// 모든 요금 데이터 캐시 삭제
|
||||
String pattern = BILL_DATA_PREFIX + "*";
|
||||
redisTemplate.delete(redisTemplate.keys(pattern));
|
||||
|
||||
log.warn("전체 요금 데이터 캐시 무효화 완료");
|
||||
} catch (Exception e) {
|
||||
log.error("전체 요금 데이터 캐시 무효화 오류: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 상태 확인
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @return 캐시 존재 여부
|
||||
*/
|
||||
public boolean isCacheExists(String lineNumber, String inquiryMonth) {
|
||||
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(cacheKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 만료 시간 조회
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @return 캐시 만료까지 남은 시간 (초)
|
||||
*/
|
||||
public Long getCacheExpiry(String lineNumber, String inquiryMonth) {
|
||||
String cacheKey = BILL_DATA_PREFIX + lineNumber + ":" + inquiryMonth;
|
||||
return redisTemplate.getExpire(cacheKey);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,279 @@
|
||||
package com.phonebill.bill.service;
|
||||
|
||||
import com.phonebill.bill.dto.*;
|
||||
import com.phonebill.bill.exception.BillInquiryException;
|
||||
import com.phonebill.bill.repository.BillInquiryHistoryRepository;
|
||||
import com.phonebill.bill.repository.entity.BillInquiryHistoryEntity;
|
||||
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.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 관리 서비스
|
||||
*
|
||||
* 요금조회 요청 및 처리 이력을 관리하는 서비스
|
||||
* - 비동기 이력 저장으로 응답 성능에 영향 없음
|
||||
* - 페이징 처리로 대용량 이력 데이터 효율적 조회
|
||||
* - 다양한 필터 조건 지원
|
||||
* - 사용자별 권한 기반 이력 접근 제어
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class BillHistoryService {
|
||||
|
||||
private final BillInquiryHistoryRepository historyRepository;
|
||||
|
||||
/**
|
||||
* 요금조회 이력 비동기 저장
|
||||
*
|
||||
* 응답 성능에 영향을 주지 않도록 비동기로 처리
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @param request 요금조회 요청 데이터
|
||||
* @param response 요금조회 응답 데이터
|
||||
*/
|
||||
@Async
|
||||
@Transactional
|
||||
public void saveInquiryHistoryAsync(String requestId, BillInquiryRequest request, BillInquiryResponse response) {
|
||||
log.debug("요금조회 이력 비동기 저장 시작 - 요청ID: {}", requestId);
|
||||
|
||||
try {
|
||||
// 조회월 기본값 설정
|
||||
String inquiryMonth = request.getInquiryMonth();
|
||||
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
|
||||
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
}
|
||||
|
||||
// 결과 요약 생성
|
||||
String resultSummary = generateResultSummary(response);
|
||||
|
||||
// 이력 엔티티 생성
|
||||
BillInquiryHistoryEntity historyEntity = BillInquiryHistoryEntity.builder()
|
||||
.requestId(requestId)
|
||||
.lineNumber(request.getLineNumber())
|
||||
.inquiryMonth(inquiryMonth)
|
||||
.requestTime(LocalDateTime.now())
|
||||
.processTime(LocalDateTime.now())
|
||||
.status(response.getStatus().name())
|
||||
.resultSummary(resultSummary)
|
||||
.build();
|
||||
|
||||
historyRepository.save(historyEntity);
|
||||
|
||||
log.info("요금조회 이력 저장 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금조회 이력 저장 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
||||
// 이력 저장 실패는 전체 프로세스에 영향을 주지 않도록 예외를 던지지 않음
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 상태 업데이트
|
||||
*
|
||||
* 비동기 처리된 요청의 상태가 변경되었을 때 호출
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @param response 업데이트된 응답 데이터
|
||||
*/
|
||||
@Transactional
|
||||
public void updateInquiryStatus(String requestId, BillInquiryResponse response) {
|
||||
log.debug("요금조회 상태 업데이트 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
|
||||
try {
|
||||
BillInquiryHistoryEntity historyEntity = historyRepository.findByRequestId(requestId)
|
||||
.orElseThrow(() -> BillInquiryException.billDataNotFound(requestId, "요청 ID"));
|
||||
|
||||
// 상태 업데이트
|
||||
historyEntity.updateStatus(response.getStatus().name());
|
||||
historyEntity.updateProcessTime(LocalDateTime.now());
|
||||
|
||||
// 결과 요약 업데이트
|
||||
String resultSummary = generateResultSummary(response);
|
||||
historyEntity.updateResultSummary(resultSummary);
|
||||
|
||||
historyRepository.save(historyEntity);
|
||||
|
||||
log.info("요금조회 상태 업데이트 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금조회 상태 업데이트 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 결과 조회
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @return 요금조회 응답 데이터
|
||||
*/
|
||||
public BillInquiryResponse getBillInquiryResult(String requestId) {
|
||||
log.debug("요금조회 결과 조회 - 요청ID: {}", requestId);
|
||||
|
||||
try {
|
||||
BillInquiryHistoryEntity historyEntity = historyRepository.findByRequestId(requestId)
|
||||
.orElse(null);
|
||||
|
||||
if (historyEntity == null) {
|
||||
log.debug("요금조회 결과 없음 - 요청ID: {}", requestId);
|
||||
return null;
|
||||
}
|
||||
|
||||
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.valueOf(historyEntity.getStatus());
|
||||
|
||||
BillInquiryResponse response = BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(status)
|
||||
.build();
|
||||
|
||||
// 성공 상태이고 요금 정보가 있는 경우 (실제로는 별도 테이블에서 조회해야 함)
|
||||
if (status == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
||||
// TODO: 실제 요금 정보 조회 로직 구현
|
||||
// 현재는 결과 요약만 반환
|
||||
}
|
||||
|
||||
log.debug("요금조회 결과 조회 완료 - 요청ID: {}, 상태: {}", requestId, status);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금조회 결과 조회 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 이력 목록 조회
|
||||
*
|
||||
* @param userLineNumbers 사용자 권한이 있는 회선번호 목록
|
||||
* @param lineNumber 특정 회선번호 필터 (선택)
|
||||
* @param startDate 조회 시작일 (선택)
|
||||
* @param endDate 조회 종료일 (선택)
|
||||
* @param page 페이지 번호
|
||||
* @param size 페이지 크기
|
||||
* @param status 상태 필터 (선택)
|
||||
* @return 이력 응답 데이터
|
||||
*/
|
||||
public BillHistoryResponse getBillHistory(
|
||||
List<String> userLineNumbers, String lineNumber, String startDate, String endDate,
|
||||
Integer page, Integer size, BillInquiryResponse.ProcessStatus status) {
|
||||
|
||||
log.debug("요금조회 이력 목록 조회 - 사용자 회선수: {}, 필터 회선: {}, 기간: {} ~ {}, 페이지: {}/{}",
|
||||
userLineNumbers.size(), lineNumber, startDate, endDate, page, size);
|
||||
|
||||
try {
|
||||
// 페이징 설정 (최신순 정렬)
|
||||
Pageable pageable = PageRequest.of(page - 1, size, Sort.by("requestTime").descending());
|
||||
|
||||
// 검색 조건 설정
|
||||
LocalDateTime startDateTime = null;
|
||||
LocalDateTime endDateTime = null;
|
||||
|
||||
if (startDate != null && !startDate.trim().isEmpty()) {
|
||||
startDateTime = LocalDate.parse(startDate).atStartOfDay();
|
||||
}
|
||||
|
||||
if (endDate != null && !endDate.trim().isEmpty()) {
|
||||
endDateTime = LocalDate.parse(endDate).atTime(23, 59, 59);
|
||||
}
|
||||
|
||||
String statusFilter = status != null ? status.name() : null;
|
||||
|
||||
// 이력 조회
|
||||
Page<BillInquiryHistoryEntity> historyPage = historyRepository.findBillHistoryWithFilters(
|
||||
userLineNumbers, lineNumber, startDateTime, endDateTime, statusFilter, pageable
|
||||
);
|
||||
|
||||
// 응답 데이터 변환
|
||||
List<BillHistoryResponse.BillHistoryItem> historyItems = historyPage.getContent()
|
||||
.stream()
|
||||
.map(this::convertToHistoryItem)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 페이징 정보 구성
|
||||
BillHistoryResponse.PaginationInfo paginationInfo = BillHistoryResponse.PaginationInfo.builder()
|
||||
.currentPage(page)
|
||||
.totalPages(historyPage.getTotalPages())
|
||||
.totalItems(historyPage.getTotalElements())
|
||||
.pageSize(size)
|
||||
.hasNext(historyPage.hasNext())
|
||||
.hasPrevious(historyPage.hasPrevious())
|
||||
.build();
|
||||
|
||||
BillHistoryResponse response = BillHistoryResponse.builder()
|
||||
.items(historyItems)
|
||||
.pagination(paginationInfo)
|
||||
.build();
|
||||
|
||||
log.info("요금조회 이력 목록 조회 완료 - 총 {}건, 현재 페이지: {}/{}",
|
||||
historyPage.getTotalElements(), page, historyPage.getTotalPages());
|
||||
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금조회 이력 목록 조회 오류 - 오류: {}", e.getMessage(), e);
|
||||
throw new BillInquiryException("이력 조회 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티를 이력 아이템으로 변환
|
||||
*/
|
||||
private BillHistoryResponse.BillHistoryItem convertToHistoryItem(BillInquiryHistoryEntity entity) {
|
||||
BillInquiryResponse.ProcessStatus status = BillInquiryResponse.ProcessStatus.valueOf(entity.getStatus());
|
||||
|
||||
return BillHistoryResponse.BillHistoryItem.builder()
|
||||
.requestId(entity.getRequestId())
|
||||
.lineNumber(entity.getLineNumber())
|
||||
.inquiryMonth(entity.getInquiryMonth())
|
||||
.requestTime(entity.getRequestTime())
|
||||
.processTime(entity.getProcessTime())
|
||||
.status(status)
|
||||
.resultSummary(entity.getResultSummary())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 데이터를 기반으로 결과 요약 생성
|
||||
*/
|
||||
private String generateResultSummary(BillInquiryResponse response) {
|
||||
try {
|
||||
switch (response.getStatus()) {
|
||||
case COMPLETED:
|
||||
if (response.getBillInfo() != null) {
|
||||
return String.format("%s, %,d원",
|
||||
response.getBillInfo().getProductName(),
|
||||
response.getBillInfo().getTotalAmount());
|
||||
} else {
|
||||
return "조회 완료";
|
||||
}
|
||||
case PROCESSING:
|
||||
return "처리 중";
|
||||
case FAILED:
|
||||
return "조회 실패";
|
||||
default:
|
||||
return "알 수 없는 상태";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("결과 요약 생성 오류: {}", e.getMessage());
|
||||
return response.getStatus().name();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.phonebill.bill.service;
|
||||
|
||||
import com.phonebill.bill.dto.*;
|
||||
|
||||
/**
|
||||
* 요금조회 서비스 인터페이스
|
||||
*
|
||||
* 통신요금 조회와 관련된 비즈니스 로직을 정의
|
||||
* - 요금조회 메뉴 데이터 제공
|
||||
* - KOS 시스템 연동을 통한 실시간 요금 조회
|
||||
* - 요금조회 결과 상태 관리
|
||||
* - 요금조회 이력 관리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
public interface BillInquiryService {
|
||||
|
||||
/**
|
||||
* 요금조회 메뉴 조회
|
||||
*
|
||||
* UFR-BILL-010: 요금조회 메뉴 접근
|
||||
* - 인증된 사용자의 고객정보 조회
|
||||
* - 조회 가능한 월 목록 생성 (최근 12개월)
|
||||
* - 현재 월 정보 제공
|
||||
*
|
||||
* @return 요금조회 메뉴 응답 데이터
|
||||
*/
|
||||
BillMenuResponse getBillMenu();
|
||||
|
||||
/**
|
||||
* 요금조회 요청 처리
|
||||
*
|
||||
* UFR-BILL-020: 요금조회 신청
|
||||
* - Cache-Aside 패턴으로 캐시 확인
|
||||
* - 캐시 Miss 시 KOS 시스템 연동
|
||||
* - Circuit Breaker 패턴으로 장애 격리
|
||||
* - 비동기 처리 시 요청 상태 관리
|
||||
*
|
||||
* @param request 요금조회 요청 데이터
|
||||
* @return 요금조회 응답 데이터
|
||||
*/
|
||||
BillInquiryResponse inquireBill(BillInquiryRequest request);
|
||||
|
||||
/**
|
||||
* 요금조회 결과 확인
|
||||
*
|
||||
* 비동기로 처리된 요금조회의 상태와 결과를 반환
|
||||
* - PROCESSING: 처리 중 상태
|
||||
* - COMPLETED: 처리 완료 (요금 정보 포함)
|
||||
* - FAILED: 처리 실패 (오류 메시지 포함)
|
||||
*
|
||||
* @param requestId 요금조회 요청 ID
|
||||
* @return 요금조회 응답 데이터
|
||||
*/
|
||||
BillInquiryResponse getBillInquiryResult(String requestId);
|
||||
|
||||
/**
|
||||
* 요금조회 이력 조회
|
||||
*
|
||||
* UFR-BILL-040: 요금조회 결과 전송 및 이력 관리
|
||||
* - 사용자별 요금조회 이력 목록 조회
|
||||
* - 필터링: 회선번호, 기간, 상태
|
||||
* - 페이징 처리
|
||||
*
|
||||
* @param lineNumber 회선번호 (선택)
|
||||
* @param startDate 조회 시작일 (선택)
|
||||
* @param endDate 조회 종료일 (선택)
|
||||
* @param page 페이지 번호
|
||||
* @param size 페이지 크기
|
||||
* @param status 처리 상태 필터 (선택)
|
||||
* @return 요금조회 이력 응답 데이터
|
||||
*/
|
||||
BillHistoryResponse getBillHistory(
|
||||
String lineNumber,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Integer page,
|
||||
Integer size,
|
||||
BillInquiryResponse.ProcessStatus status
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,296 @@
|
||||
package com.phonebill.bill.service;
|
||||
|
||||
import com.phonebill.bill.dto.*;
|
||||
import com.phonebill.bill.exception.BillInquiryException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 요금조회 서비스 구현체
|
||||
*
|
||||
* 통신요금 조회와 관련된 비즈니스 로직 구현
|
||||
* - KOS 시스템 연동을 통한 실시간 데이터 조회
|
||||
* - Redis 캐싱을 통한 성능 최적화
|
||||
* - Circuit Breaker를 통한 외부 시스템 장애 격리
|
||||
* - 비동기 처리 및 이력 관리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class BillInquiryServiceImpl implements BillInquiryService {
|
||||
|
||||
private final BillCacheService billCacheService;
|
||||
private final KosClientService kosClientService;
|
||||
private final BillHistoryService billHistoryService;
|
||||
|
||||
/**
|
||||
* 요금조회 메뉴 조회
|
||||
*
|
||||
* UFR-BILL-010: 요금조회 메뉴 접근
|
||||
*/
|
||||
@Override
|
||||
public BillMenuResponse getBillMenu() {
|
||||
log.info("요금조회 메뉴 조회 시작");
|
||||
|
||||
// 현재 인증된 사용자의 고객 정보 조회 (JWT에서 추출)
|
||||
// TODO: SecurityContext에서 사용자 정보 추출 로직 구현
|
||||
String customerId = getCurrentCustomerId();
|
||||
String lineNumber = getCurrentLineNumber();
|
||||
|
||||
// 조회 가능한 월 목록 생성 (최근 12개월)
|
||||
List<String> availableMonths = generateAvailableMonths();
|
||||
|
||||
// 현재 월
|
||||
String currentMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
|
||||
BillMenuResponse response = BillMenuResponse.builder()
|
||||
.customerInfo(BillMenuResponse.CustomerInfo.builder()
|
||||
.customerId(customerId)
|
||||
.lineNumber(lineNumber)
|
||||
.build())
|
||||
.availableMonths(availableMonths)
|
||||
.currentMonth(currentMonth)
|
||||
.build();
|
||||
|
||||
log.info("요금조회 메뉴 조회 완료 - 고객: {}, 회선: {}", customerId, lineNumber);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 요청 처리
|
||||
*
|
||||
* UFR-BILL-020: 요금조회 신청
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public BillInquiryResponse inquireBill(BillInquiryRequest request) {
|
||||
log.info("요금조회 요청 처리 시작 - 회선: {}, 조회월: {}",
|
||||
request.getLineNumber(), request.getInquiryMonth());
|
||||
|
||||
// 요청 ID 생성
|
||||
String requestId = generateRequestId();
|
||||
|
||||
// 조회월 기본값 설정 (미입력시 당월)
|
||||
String inquiryMonth = request.getInquiryMonth();
|
||||
if (inquiryMonth == null || inquiryMonth.trim().isEmpty()) {
|
||||
inquiryMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
}
|
||||
|
||||
try {
|
||||
// 1단계: 캐시에서 데이터 확인 (Cache-Aside 패턴)
|
||||
BillInquiryResponse cachedResponse = billCacheService.getCachedBillData(
|
||||
request.getLineNumber(), inquiryMonth
|
||||
);
|
||||
|
||||
if (cachedResponse != null) {
|
||||
log.info("캐시에서 요금 데이터 조회 완료 - 요청ID: {}", requestId);
|
||||
cachedResponse = BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
|
||||
.billInfo(cachedResponse.getBillInfo())
|
||||
.build();
|
||||
|
||||
// 이력 저장 (비동기)
|
||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, cachedResponse);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// 2단계: KOS 시스템 연동 (Circuit Breaker 적용)
|
||||
CompletableFuture<BillInquiryResponse> kosResponseFuture = kosClientService.inquireBillFromKos(
|
||||
request.getLineNumber(), inquiryMonth
|
||||
);
|
||||
BillInquiryResponse kosResponse;
|
||||
try {
|
||||
kosResponse = kosResponseFuture.get();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BillInquiryException("요금조회 처리가 중단되었습니다", e);
|
||||
} catch (Exception e) {
|
||||
throw new BillInquiryException("요금조회 처리 중 오류가 발생했습니다", e);
|
||||
}
|
||||
|
||||
if (kosResponse != null && kosResponse.getStatus() == BillInquiryResponse.ProcessStatus.COMPLETED) {
|
||||
// 3단계: 캐시에 저장 (1시간 TTL)
|
||||
billCacheService.cacheBillData(request.getLineNumber(), inquiryMonth, kosResponse);
|
||||
|
||||
// 응답 데이터 구성
|
||||
BillInquiryResponse response = BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(BillInquiryResponse.ProcessStatus.COMPLETED)
|
||||
.billInfo(kosResponse.getBillInfo())
|
||||
.build();
|
||||
|
||||
// 이력 저장 (비동기)
|
||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
|
||||
|
||||
log.info("KOS 연동을 통한 요금조회 완료 - 요청ID: {}", requestId);
|
||||
return response;
|
||||
} else {
|
||||
// KOS에서 비동기 처리 중인 경우
|
||||
BillInquiryResponse response = BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
|
||||
.build();
|
||||
|
||||
// 이력 저장 (처리 중 상태)
|
||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, response);
|
||||
|
||||
log.info("KOS 연동 비동기 처리 - 요청ID: {}", requestId);
|
||||
return response;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("요금조회 처리 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
||||
|
||||
// 실패 응답 생성
|
||||
BillInquiryResponse errorResponse = BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(BillInquiryResponse.ProcessStatus.FAILED)
|
||||
.build();
|
||||
|
||||
// 이력 저장 (실패 상태)
|
||||
billHistoryService.saveInquiryHistoryAsync(requestId, request, errorResponse);
|
||||
|
||||
// 비즈니스 예외는 그대로 던지고, 시스템 예외는 래핑
|
||||
if (e instanceof BillInquiryException) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new BillInquiryException("요금조회 처리 중 시스템 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 결과 확인
|
||||
*/
|
||||
@Override
|
||||
public BillInquiryResponse getBillInquiryResult(String requestId) {
|
||||
log.info("요금조회 결과 확인 - 요청ID: {}", requestId);
|
||||
|
||||
// 이력에서 요청 정보 조회
|
||||
BillInquiryResponse response = billHistoryService.getBillInquiryResult(requestId);
|
||||
|
||||
if (response == null) {
|
||||
throw BillInquiryException.billDataNotFound(requestId, "요청 ID");
|
||||
}
|
||||
|
||||
// 처리 중인 경우 KOS에서 최신 상태 확인
|
||||
if (response.getStatus() == BillInquiryResponse.ProcessStatus.PROCESSING) {
|
||||
try {
|
||||
BillInquiryResponse latestResponse = kosClientService.checkInquiryStatus(requestId);
|
||||
if (latestResponse != null) {
|
||||
// 상태 업데이트
|
||||
billHistoryService.updateInquiryStatus(requestId, latestResponse);
|
||||
response = latestResponse;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("KOS 상태 확인 중 오류 발생 - 요청ID: {}, 오류: {}", requestId, e.getMessage());
|
||||
// 상태 확인 실패해도 기존 상태 그대로 반환
|
||||
}
|
||||
}
|
||||
|
||||
log.info("요금조회 결과 반환 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요금조회 이력 조회
|
||||
*/
|
||||
@Override
|
||||
public BillHistoryResponse getBillHistory(
|
||||
String lineNumber, String startDate, String endDate,
|
||||
Integer page, Integer size, BillInquiryResponse.ProcessStatus status) {
|
||||
|
||||
log.info("요금조회 이력 조회 - 회선: {}, 기간: {} ~ {}, 페이지: {}/{}, 상태: {}",
|
||||
lineNumber, startDate, endDate, page, size, status);
|
||||
|
||||
// 현재 사용자의 회선번호 목록 조회 (권한 확인)
|
||||
List<String> userLineNumbers = getCurrentUserLineNumbers();
|
||||
|
||||
// 지정된 회선번호가 사용자 소유가 아닌 경우 권한 오류
|
||||
if (lineNumber != null && !userLineNumbers.contains(lineNumber)) {
|
||||
throw new BillInquiryException("UNAUTHORIZED_LINE_NUMBER",
|
||||
"조회 권한이 없는 회선번호입니다", "회선번호: " + lineNumber);
|
||||
}
|
||||
|
||||
// 이력 조회 (사용자 권한 기반)
|
||||
BillHistoryResponse historyResponse = billHistoryService.getBillHistory(
|
||||
userLineNumbers, lineNumber, startDate, endDate, page, size, status
|
||||
);
|
||||
|
||||
log.info("요금조회 이력 조회 완료 - 총 {}건",
|
||||
historyResponse.getPagination().getTotalItems());
|
||||
|
||||
return historyResponse;
|
||||
}
|
||||
|
||||
// === Private Helper Methods ===
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자의 고객 ID 조회
|
||||
*/
|
||||
private String getCurrentCustomerId() {
|
||||
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 고객 ID 추출
|
||||
// 현재는 더미 데이터 반환
|
||||
return "CUST001";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자의 회선번호 조회
|
||||
*/
|
||||
private String getCurrentLineNumber() {
|
||||
// TODO: SecurityContext에서 JWT 토큰을 파싱하여 회선번호 추출
|
||||
// 현재는 더미 데이터 반환
|
||||
return "010-1234-5678";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자의 모든 회선번호 목록 조회
|
||||
*/
|
||||
private List<String> getCurrentUserLineNumbers() {
|
||||
// TODO: 사용자 권한에 따른 회선번호 목록 조회
|
||||
// 현재는 더미 데이터 반환
|
||||
List<String> lineNumbers = new ArrayList<>();
|
||||
lineNumbers.add("010-1234-5678");
|
||||
return lineNumbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회 가능한 월 목록 생성 (최근 12개월)
|
||||
*/
|
||||
private List<String> generateAvailableMonths() {
|
||||
List<String> months = new ArrayList<>();
|
||||
LocalDate currentDate = LocalDate.now();
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
for (int i = 0; i < 12; i++) {
|
||||
LocalDate monthDate = currentDate.minusMonths(i);
|
||||
months.add(monthDate.format(formatter));
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 ID 생성
|
||||
*/
|
||||
private String generateRequestId() {
|
||||
String currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
String uuid = UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
return String.format("REQ_%s_%s", currentDate, uuid);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,327 @@
|
||||
package com.phonebill.bill.service;
|
||||
|
||||
import com.phonebill.bill.config.KosProperties;
|
||||
import com.phonebill.bill.dto.BillInquiryResponse;
|
||||
import com.phonebill.bill.exception.CircuitBreakerException;
|
||||
import com.phonebill.bill.exception.KosConnectionException;
|
||||
import com.phonebill.bill.external.KosRequest;
|
||||
import com.phonebill.bill.external.KosResponse;
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* KOS 시스템 연동 클라이언트 서비스
|
||||
*
|
||||
* 통신사 백엔드 시스템(KOS)과의 연동을 담당하는 서비스
|
||||
* - Circuit Breaker 패턴으로 외부 시스템 장애 격리
|
||||
* - Retry 패턴으로 일시적 네트워크 오류 극복
|
||||
* - Timeout 설정으로 응답 지연 방지
|
||||
* - 데이터 변환 및 오류 처리
|
||||
*
|
||||
* @author 이개발(백엔더)
|
||||
* @version 1.0.0
|
||||
* @since 2025-09-08
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KosClientService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final KosProperties kosProperties;
|
||||
|
||||
/**
|
||||
* KOS 시스템에서 요금 정보 조회
|
||||
*
|
||||
* Circuit Breaker, Retry, TimeLimiter 패턴 적용
|
||||
*
|
||||
* @param lineNumber 회선번호
|
||||
* @param inquiryMonth 조회월
|
||||
* @return 요금조회 응답
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-bill-inquiry", fallbackMethod = "inquireBillFallback")
|
||||
@Retry(name = "kos-bill-inquiry")
|
||||
@TimeLimiter(name = "kos-bill-inquiry")
|
||||
public CompletableFuture<BillInquiryResponse> inquireBillFromKos(String lineNumber, String inquiryMonth) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
log.info("KOS 요금조회 요청 - 회선: {}, 조회월: {}", lineNumber, inquiryMonth);
|
||||
|
||||
try {
|
||||
// KOS 요청 데이터 구성
|
||||
KosRequest kosRequest = KosRequest.builder()
|
||||
.lineNumber(lineNumber)
|
||||
.inquiryMonth(inquiryMonth)
|
||||
.requestTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
|
||||
headers.set("X-Request-ID", java.util.UUID.randomUUID().toString());
|
||||
|
||||
HttpEntity<KosRequest> requestEntity = new HttpEntity<>(kosRequest, headers);
|
||||
|
||||
// KOS API 호출
|
||||
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/inquiry";
|
||||
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
|
||||
kosUrl, HttpMethod.POST, requestEntity, KosResponse.class
|
||||
);
|
||||
|
||||
KosResponse kosResponse = responseEntity.getBody();
|
||||
|
||||
if (kosResponse == null) {
|
||||
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
|
||||
}
|
||||
|
||||
// KOS 응답을 내부 모델로 변환
|
||||
BillInquiryResponse response = convertKosResponseToBillResponse(kosResponse);
|
||||
|
||||
log.info("KOS 요금조회 성공 - 회선: {}, 조회월: {}, 상태: {}",
|
||||
lineNumber, inquiryMonth, response.getStatus());
|
||||
|
||||
return response;
|
||||
|
||||
} catch (HttpClientErrorException e) {
|
||||
log.error("KOS API 클라이언트 오류 - 회선: {}, 상태: {}, 응답: {}",
|
||||
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||
|
||||
} catch (HttpServerErrorException e) {
|
||||
log.error("KOS API 서버 오류 - 회선: {}, 상태: {}, 응답: {}",
|
||||
lineNumber, e.getStatusCode(), e.getResponseBodyAsString());
|
||||
throw KosConnectionException.apiError("KOS-BILL-INQUIRY",
|
||||
String.valueOf(e.getStatusCode().value()), e.getResponseBodyAsString());
|
||||
|
||||
} catch (ResourceAccessException e) {
|
||||
log.error("KOS 네트워크 연결 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage());
|
||||
throw KosConnectionException.networkError("KOS-BILL-INQUIRY", e);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 연동 중 예상치 못한 오류 - 회선: {}, 오류: {}", lineNumber, e.getMessage(), e);
|
||||
throw new KosConnectionException("KOS-BILL-INQUIRY",
|
||||
"KOS 시스템 연동 중 오류가 발생했습니다", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 요금조회 Circuit Breaker Fallback 메소드
|
||||
*/
|
||||
public CompletableFuture<BillInquiryResponse> inquireBillFallback(String lineNumber, String inquiryMonth, Exception ex) {
|
||||
log.warn("KOS 요금조회 Circuit Breaker 작동 - 회선: {}, 조회월: {}, 오류: {}",
|
||||
lineNumber, inquiryMonth, ex.getMessage());
|
||||
|
||||
// Circuit Breaker가 Open 상태인 경우
|
||||
if (ex.getClass().getSimpleName().contains("CircuitBreakerOpenException")) {
|
||||
throw CircuitBreakerException.circuitBreakerOpen("KOS-BILL-INQUIRY");
|
||||
}
|
||||
|
||||
// 기타 오류의 경우 비동기 처리로 전환
|
||||
BillInquiryResponse fallbackResponse = BillInquiryResponse.builder()
|
||||
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
|
||||
.build();
|
||||
|
||||
log.info("KOS 요금조회 fallback 응답 - 비동기 처리로 전환");
|
||||
return CompletableFuture.completedFuture(fallbackResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 시스템에서 요금조회 상태 확인
|
||||
*
|
||||
* @param requestId 요청 ID
|
||||
* @return 요금조회 응답
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-status-check", fallbackMethod = "checkInquiryStatusFallback")
|
||||
@Retry(name = "kos-status-check")
|
||||
public BillInquiryResponse checkInquiryStatus(String requestId) {
|
||||
log.info("KOS 요금조회 상태 확인 - 요청ID: {}", requestId);
|
||||
|
||||
try {
|
||||
// HTTP 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-Service-Name", "MVNO-BILL-INQUIRY");
|
||||
headers.set("X-Request-ID", requestId);
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
|
||||
|
||||
// KOS 상태 확인 API 호출
|
||||
String kosUrl = kosProperties.getBaseUrl() + "/api/bill/status/" + requestId;
|
||||
ResponseEntity<KosResponse> responseEntity = restTemplate.exchange(
|
||||
kosUrl, HttpMethod.GET, requestEntity, KosResponse.class
|
||||
);
|
||||
|
||||
KosResponse kosResponse = responseEntity.getBody();
|
||||
|
||||
if (kosResponse == null) {
|
||||
throw KosConnectionException.apiError("KOS-STATUS-CHECK",
|
||||
String.valueOf(responseEntity.getStatusCodeValue()), "응답 데이터가 없습니다");
|
||||
}
|
||||
|
||||
// KOS 응답을 내부 모델로 변환
|
||||
BillInquiryResponse response = convertKosResponseToBillResponse(kosResponse);
|
||||
|
||||
log.info("KOS 상태 확인 완료 - 요청ID: {}, 상태: {}", requestId, response.getStatus());
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 상태 확인 오류 - 요청ID: {}, 오류: {}", requestId, e.getMessage(), e);
|
||||
throw new KosConnectionException("KOS-STATUS-CHECK",
|
||||
"KOS 상태 확인 중 오류가 발생했습니다", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 상태 확인 Circuit Breaker Fallback 메소드
|
||||
*/
|
||||
public BillInquiryResponse checkInquiryStatusFallback(String requestId, Exception ex) {
|
||||
log.warn("KOS 상태 확인 Circuit Breaker 작동 - 요청ID: {}, 오류: {}", requestId, ex.getMessage());
|
||||
|
||||
// 상태 확인 실패시 처리 중 상태로 반환
|
||||
return BillInquiryResponse.builder()
|
||||
.requestId(requestId)
|
||||
.status(BillInquiryResponse.ProcessStatus.PROCESSING)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 응답을 내부 응답 모델로 변환
|
||||
*/
|
||||
private BillInquiryResponse convertKosResponseToBillResponse(KosResponse kosResponse) {
|
||||
try {
|
||||
// 상태 변환
|
||||
BillInquiryResponse.ProcessStatus status;
|
||||
switch (kosResponse.getStatus().toUpperCase()) {
|
||||
case "SUCCESS":
|
||||
case "COMPLETED":
|
||||
status = BillInquiryResponse.ProcessStatus.COMPLETED;
|
||||
break;
|
||||
case "PROCESSING":
|
||||
case "PENDING":
|
||||
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||
break;
|
||||
case "FAILED":
|
||||
case "ERROR":
|
||||
status = BillInquiryResponse.ProcessStatus.FAILED;
|
||||
break;
|
||||
default:
|
||||
status = BillInquiryResponse.ProcessStatus.PROCESSING;
|
||||
break;
|
||||
}
|
||||
|
||||
BillInquiryResponse.BillInfo billInfo = null;
|
||||
|
||||
// 성공한 경우에만 요금 정보 변환
|
||||
if (status == BillInquiryResponse.ProcessStatus.COMPLETED && kosResponse.getBillData() != null) {
|
||||
// 할인 정보 변환
|
||||
List<BillInquiryResponse.DiscountInfo> discounts = new ArrayList<>();
|
||||
if (kosResponse.getBillData().getDiscounts() != null) {
|
||||
kosResponse.getBillData().getDiscounts().forEach(discount ->
|
||||
discounts.add(BillInquiryResponse.DiscountInfo.builder()
|
||||
.name(discount.getName())
|
||||
.amount(discount.getAmount())
|
||||
.build())
|
||||
);
|
||||
}
|
||||
|
||||
// 사용량 정보 변환
|
||||
BillInquiryResponse.UsageInfo usage = null;
|
||||
if (kosResponse.getBillData().getUsage() != null) {
|
||||
usage = BillInquiryResponse.UsageInfo.builder()
|
||||
.voice(kosResponse.getBillData().getUsage().getVoice())
|
||||
.sms(kosResponse.getBillData().getUsage().getSms())
|
||||
.data(kosResponse.getBillData().getUsage().getData())
|
||||
.build();
|
||||
}
|
||||
|
||||
// 납부 정보 변환
|
||||
BillInquiryResponse.PaymentInfo payment = null;
|
||||
if (kosResponse.getBillData().getPayment() != null) {
|
||||
BillInquiryResponse.PaymentStatus paymentStatus;
|
||||
switch (kosResponse.getBillData().getPayment().getStatus().toUpperCase()) {
|
||||
case "PAID":
|
||||
paymentStatus = BillInquiryResponse.PaymentStatus.PAID;
|
||||
break;
|
||||
case "UNPAID":
|
||||
paymentStatus = BillInquiryResponse.PaymentStatus.UNPAID;
|
||||
break;
|
||||
case "OVERDUE":
|
||||
paymentStatus = BillInquiryResponse.PaymentStatus.OVERDUE;
|
||||
break;
|
||||
default:
|
||||
paymentStatus = BillInquiryResponse.PaymentStatus.UNPAID;
|
||||
break;
|
||||
}
|
||||
|
||||
payment = BillInquiryResponse.PaymentInfo.builder()
|
||||
.billingDate(kosResponse.getBillData().getPayment().getBillingDate())
|
||||
.paymentStatus(paymentStatus)
|
||||
.paymentMethod(kosResponse.getBillData().getPayment().getMethod())
|
||||
.build();
|
||||
}
|
||||
|
||||
billInfo = BillInquiryResponse.BillInfo.builder()
|
||||
.productName(kosResponse.getBillData().getProductName())
|
||||
.contractInfo(kosResponse.getBillData().getContractInfo())
|
||||
.billingMonth(kosResponse.getBillData().getBillingMonth())
|
||||
.totalAmount(kosResponse.getBillData().getTotalAmount())
|
||||
.discountInfo(discounts)
|
||||
.usage(usage)
|
||||
.terminationFee(kosResponse.getBillData().getTerminationFee())
|
||||
.deviceInstallment(kosResponse.getBillData().getDeviceInstallment())
|
||||
.paymentInfo(payment)
|
||||
.build();
|
||||
}
|
||||
|
||||
return BillInquiryResponse.builder()
|
||||
.requestId(kosResponse.getRequestId())
|
||||
.status(status)
|
||||
.billInfo(billInfo)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("KOS 응답 변환 오류: {}", e.getMessage(), e);
|
||||
throw KosConnectionException.dataConversionError("KOS-BILL-INQUIRY", "BillInquiryResponse", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KOS 시스템 연결 상태 확인
|
||||
*
|
||||
* @return 연결 가능 여부
|
||||
*/
|
||||
@CircuitBreaker(name = "kos-health-check")
|
||||
public boolean isKosSystemAvailable() {
|
||||
try {
|
||||
String healthUrl = kosProperties.getBaseUrl() + "/health";
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(healthUrl, String.class);
|
||||
|
||||
boolean available = response.getStatusCode().is2xxSuccessful();
|
||||
log.debug("KOS 시스템 상태 확인 - 사용가능: {}", available);
|
||||
|
||||
return available;
|
||||
} catch (Exception e) {
|
||||
log.warn("KOS 시스템 상태 확인 실패: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
169
bill-service/src/main/resources/application-dev.yml
Normal file
169
bill-service/src/main/resources/application-dev.yml
Normal file
@ -0,0 +1,169 @@
|
||||
# 통신요금 관리 서비스 - Bill Service 개발환경 설정
|
||||
# 개발자 편의성과 디버깅을 위한 설정
|
||||
#
|
||||
# @author 이개발(백엔더)
|
||||
# @version 1.0.0
|
||||
# @since 2025-09-08
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:bill_inquiry_db}
|
||||
username: ${DB_USERNAME:bill_inquiry_user}
|
||||
password: ${DB_PASSWORD:BillUser2025!}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
leak-detection-threshold: 60000
|
||||
# JPA 설정
|
||||
jpa:
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:Redis2025Dev!}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
database: ${REDIS_DATABASE:1}
|
||||
|
||||
# 캐시 설정 (개발환경 - 짧은 TTL)
|
||||
cache:
|
||||
redis:
|
||||
time-to-live: 300000 # 5분
|
||||
|
||||
# 서버 설정 (개발환경)
|
||||
server:
|
||||
port: ${SERVER_PORT:8082}
|
||||
error:
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
include-stacktrace: always # 개발환경에서는 스택트레이스 포함
|
||||
include-exception: true # 예외 정보 포함
|
||||
|
||||
# 액추에이터 설정 (개발환경)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*" # 개발환경에서는 모든 엔드포인트 노출
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
show-components: always
|
||||
security:
|
||||
enabled: false # 개발환경에서는 액추에이터 보안 비활성화
|
||||
|
||||
# KOS 시스템 연동 설정 (개발환경)
|
||||
kos:
|
||||
base-url: http://localhost:9090 # 로컬 KOS Mock 서버
|
||||
connect-timeout: 3000
|
||||
read-timeout: 10000
|
||||
max-retries: 2
|
||||
retry-delay: 500
|
||||
|
||||
# Circuit Breaker 설정 (개발환경 - 관대한 설정)
|
||||
circuit-breaker:
|
||||
failure-rate-threshold: 0.7 # 70% 실패율
|
||||
slow-call-duration-threshold: 15000 # 15초
|
||||
slow-call-rate-threshold: 0.7 # 70% 느린 호출
|
||||
sliding-window-size: 5 # 작은 윈도우
|
||||
minimum-number-of-calls: 3 # 적은 최소 호출
|
||||
permitted-number-of-calls-in-half-open-state: 2
|
||||
wait-duration-in-open-state: 30000 # 30초
|
||||
|
||||
# 인증 설정 (개발환경)
|
||||
authentication:
|
||||
enabled: false # 개발환경에서는 인증 비활성화
|
||||
api-key: dev-api-key
|
||||
secret-key: dev-secret-key
|
||||
token-expiration-seconds: 7200 # 2시간
|
||||
|
||||
# 모니터링 설정 (개발환경)
|
||||
monitoring:
|
||||
performance-logging-enabled: true
|
||||
slow-request-threshold: 1000 # 1초 (더 민감한 감지)
|
||||
metrics-enabled: true
|
||||
health-check-interval: 10000 # 10초
|
||||
|
||||
# 로깅 설정 (개발환경)
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.phonebill: ${LOG_LEVEL_APP:DEBUG} # 애플리케이션 로그 디버그 레벨
|
||||
com.phonebill.bill.service: DEBUG
|
||||
com.phonebill.bill.repository: DEBUG
|
||||
org.springframework.cache: DEBUG
|
||||
org.springframework.web: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
org.hibernate.SQL: DEBUG # SQL 쿼리 로그
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # SQL 파라미터 로그
|
||||
io.github.resilience4j: DEBUG
|
||||
redis.clients.jedis: DEBUG
|
||||
org.springframework.web.client.RestTemplate: DEBUG
|
||||
pattern:
|
||||
console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
|
||||
file:
|
||||
name: ${LOG_FILE_NAME:logs/bill-service.log}
|
||||
max-size: 50MB
|
||||
max-history: 7 # 개발환경에서는 7일만 보관
|
||||
|
||||
# Swagger 설정 (개발환경)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
display-request-duration: true
|
||||
default-models-expand-depth: 2
|
||||
default-model-expand-depth: 2
|
||||
try-it-out-enabled: true
|
||||
filter: true
|
||||
doc-expansion: list
|
||||
show-actuator: true
|
||||
|
||||
# 개발환경 전용 설정
|
||||
debug: false # Spring Boot 디버그 모드
|
||||
|
||||
# 개발편의를 위한 프로파일 정보
|
||||
---
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
|
||||
# 개발환경 정보
|
||||
info:
|
||||
environment: development
|
||||
debug:
|
||||
enabled: true
|
||||
database:
|
||||
name: bill_service_dev
|
||||
host: localhost
|
||||
port: 3306
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
database: 1
|
||||
kos:
|
||||
host: localhost
|
||||
port: 9090
|
||||
mock: true
|
||||
237
bill-service/src/main/resources/application-prod.yml
Normal file
237
bill-service/src/main/resources/application-prod.yml
Normal file
@ -0,0 +1,237 @@
|
||||
# 통신요금 관리 서비스 - Bill Service 운영환경 설정
|
||||
# 운영환경 안정성과 보안을 위한 설정
|
||||
#
|
||||
# @author 이개발(백엔더)
|
||||
# @version 1.0.0
|
||||
# @since 2025-09-08
|
||||
|
||||
spring:
|
||||
# 데이터베이스 설정 (운영환경)
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:mysql://prod-db-host:3306/bill_service_prod?useUnicode=true&characterEncoding=utf8&useSSL=true&requireSSL=true&serverTimezone=Asia/Seoul}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
hikari:
|
||||
minimum-idle: 10
|
||||
maximum-pool-size: 50
|
||||
idle-timeout: 600000 # 10분
|
||||
max-lifetime: 1800000 # 30분
|
||||
connection-timeout: 30000 # 30초
|
||||
validation-timeout: 5000 # 5초
|
||||
leak-detection-threshold: 60000 # 1분
|
||||
|
||||
# JPA 설정 (운영환경)
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate # 운영환경에서는 스키마 검증만
|
||||
show-sql: false # 운영환경에서는 SQL 로그 비활성화
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
use_sql_comments: false
|
||||
default_batch_fetch_size: 100
|
||||
jdbc:
|
||||
batch_size: 50
|
||||
connection:
|
||||
provider_disables_autocommit: true
|
||||
|
||||
# Redis 설정 (운영환경)
|
||||
redis:
|
||||
host: ${REDIS_HOST:prod-redis-host}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: 5000
|
||||
ssl: ${REDIS_SSL:true}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 50
|
||||
max-idle: 20
|
||||
min-idle: 5
|
||||
max-wait: 5000
|
||||
cluster:
|
||||
refresh:
|
||||
adaptive: true
|
||||
period: 30s
|
||||
|
||||
# 캐시 설정 (운영환경)
|
||||
cache:
|
||||
redis:
|
||||
time-to-live: 3600000 # 1시간
|
||||
|
||||
# 서버 설정 (운영환경)
|
||||
server:
|
||||
port: ${SERVER_PORT:8081}
|
||||
error:
|
||||
include-message: never
|
||||
include-binding-errors: never
|
||||
include-stacktrace: never # 운영환경에서는 스택트레이스 숨김
|
||||
include-exception: false # 예외 정보 숨김
|
||||
tomcat:
|
||||
max-connections: 10000
|
||||
accept-count: 200
|
||||
threads:
|
||||
max: 300
|
||||
min-spare: 20
|
||||
connection-timeout: 20000
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css
|
||||
min-response-size: 1024
|
||||
|
||||
# 액추에이터 설정 (운영환경)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus # 제한적 노출
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when_authorized # 인증된 사용자에게만 상세 정보 제공
|
||||
show-components: when_authorized
|
||||
probes:
|
||||
enabled: true
|
||||
info:
|
||||
enabled: true
|
||||
metrics:
|
||||
enabled: true
|
||||
prometheus:
|
||||
enabled: true
|
||||
security:
|
||||
enabled: true
|
||||
health:
|
||||
redis:
|
||||
enabled: true
|
||||
db:
|
||||
enabled: true
|
||||
diskspace:
|
||||
enabled: true
|
||||
threshold: 500MB
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
descriptions: false
|
||||
distribution:
|
||||
percentiles-histogram:
|
||||
http.server.requests: true
|
||||
percentiles:
|
||||
http.server.requests: 0.95, 0.99
|
||||
sla:
|
||||
http.server.requests: 100ms, 500ms, 1s, 2s
|
||||
|
||||
# KOS 시스템 연동 설정 (운영환경)
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL}
|
||||
connect-timeout: 5000
|
||||
read-timeout: 30000
|
||||
max-retries: 3
|
||||
retry-delay: 1000
|
||||
|
||||
# Circuit Breaker 설정 (운영환경 - 엄격한 설정)
|
||||
circuit-breaker:
|
||||
failure-rate-threshold: 0.5 # 50% 실패율
|
||||
slow-call-duration-threshold: 10000 # 10초
|
||||
slow-call-rate-threshold: 0.5 # 50% 느린 호출
|
||||
sliding-window-size: 20 # 큰 윈도우로 정확한 측정
|
||||
minimum-number-of-calls: 10 # 충분한 샘플
|
||||
permitted-number-of-calls-in-half-open-state: 5
|
||||
wait-duration-in-open-state: 60000 # 60초
|
||||
|
||||
# 인증 설정 (운영환경)
|
||||
authentication:
|
||||
enabled: true
|
||||
api-key: ${KOS_API_KEY}
|
||||
secret-key: ${KOS_SECRET_KEY}
|
||||
token-expiration-seconds: 3600 # 1시간
|
||||
token-refresh-threshold-seconds: 300 # 5분
|
||||
|
||||
# 모니터링 설정 (운영환경)
|
||||
monitoring:
|
||||
performance-logging-enabled: true
|
||||
slow-request-threshold: 3000 # 3초
|
||||
metrics-enabled: true
|
||||
health-check-interval: 30000 # 30초
|
||||
|
||||
# 로깅 설정 (운영환경)
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.phonebill: INFO # 애플리케이션 로그는 INFO 레벨
|
||||
com.phonebill.bill.service: INFO
|
||||
com.phonebill.bill.repository: WARN
|
||||
org.springframework.cache: WARN
|
||||
org.springframework.web: WARN
|
||||
org.springframework.security: WARN
|
||||
org.hibernate.SQL: WARN # SQL 로그 비활성화
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: WARN
|
||||
io.github.resilience4j: INFO
|
||||
redis.clients.jedis: WARN
|
||||
org.springframework.web.client.RestTemplate: WARN
|
||||
pattern:
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}"
|
||||
file:
|
||||
name: ${LOG_FILE:/app/logs/bill-service.log}
|
||||
max-size: 200MB
|
||||
max-history: 30 # 30일 보관
|
||||
logback:
|
||||
rollingpolicy:
|
||||
total-size-cap: 5GB
|
||||
appender:
|
||||
console:
|
||||
enabled: false # 운영환경에서는 콘솔 로그 비활성화
|
||||
|
||||
# Swagger 설정 (운영환경 - 보안상 비활성화)
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
show-actuator: false
|
||||
|
||||
# 운영환경 보안 설정
|
||||
security:
|
||||
require-ssl: true
|
||||
headers:
|
||||
frame:
|
||||
deny: true
|
||||
content-type:
|
||||
nosniff: true
|
||||
xss-protection:
|
||||
and-block: true
|
||||
|
||||
# 운영환경 전용 설정
|
||||
debug: false
|
||||
|
||||
---
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
|
||||
# 운영환경 정보
|
||||
info:
|
||||
environment: production
|
||||
debug:
|
||||
enabled: false
|
||||
security:
|
||||
ssl-enabled: true
|
||||
database:
|
||||
name: bill_service_prod
|
||||
ssl-enabled: true
|
||||
redis:
|
||||
ssl-enabled: true
|
||||
cluster-enabled: true
|
||||
kos:
|
||||
ssl-enabled: true
|
||||
authentication-enabled: true
|
||||
|
||||
# 운영환경 JVM 옵션 권장사항
|
||||
# -Xms2g -Xmx4g
|
||||
# -XX:+UseG1GC
|
||||
# -XX:MaxGCPauseMillis=200
|
||||
# -XX:+HeapDumpOnOutOfMemoryError
|
||||
# -XX:HeapDumpPath=/app/logs/heap-dump.hprof
|
||||
# -Djava.security.egd=file:/dev/./urandom
|
||||
# -Dspring.profiles.active=prod
|
||||
256
bill-service/src/main/resources/application.yml
Normal file
256
bill-service/src/main/resources/application.yml
Normal file
@ -0,0 +1,256 @@
|
||||
# 통신요금 관리 서비스 - Bill Service 기본 설정
|
||||
# 공통 설정 및 개발환경 기본값
|
||||
#
|
||||
# @author 이개발(백엔더)
|
||||
# @version 1.0.0
|
||||
# @since 2025-09-08
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: bill-service
|
||||
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
include:
|
||||
- common
|
||||
|
||||
# 데이터베이스 설정
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/bill_inquiry_db}
|
||||
username: ${DB_USERNAME:bill_user}
|
||||
password: ${DB_PASSWORD:bill_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
minimum-idle: ${DB_MIN_IDLE:5}
|
||||
maximum-pool-size: ${DB_MAX_POOL:20}
|
||||
idle-timeout: ${DB_IDLE_TIMEOUT:300000}
|
||||
max-lifetime: ${DB_MAX_LIFETIME:1800000}
|
||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:30000}
|
||||
validation-timeout: ${DB_VALIDATION_TIMEOUT:5000}
|
||||
leak-detection-threshold: ${DB_LEAK_DETECTION:60000}
|
||||
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
naming:
|
||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: ${JPA_FORMAT_SQL:false}
|
||||
use_sql_comments: ${JPA_SQL_COMMENTS:false}
|
||||
default_batch_fetch_size: ${JPA_BATCH_SIZE:100}
|
||||
jdbc:
|
||||
batch_size: ${JPA_JDBC_BATCH_SIZE:20}
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
connection:
|
||||
provider_disables_autocommit: true
|
||||
open-in-view: false
|
||||
|
||||
# Redis 설정
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: ${REDIS_TIMEOUT:5000}
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: ${REDIS_MAX_ACTIVE:20}
|
||||
max-idle: ${REDIS_MAX_IDLE:8}
|
||||
min-idle: ${REDIS_MIN_IDLE:0}
|
||||
max-wait: ${REDIS_MAX_WAIT:-1}
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
write-durations-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
accept-single-value-as-array: true
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
|
||||
# Servlet 설정
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${SERVLET_MAX_FILE_SIZE:10MB}
|
||||
max-request-size: ${SERVLET_MAX_REQUEST_SIZE:100MB}
|
||||
|
||||
# 비동기 처리 설정
|
||||
task:
|
||||
execution:
|
||||
pool:
|
||||
core-size: ${ASYNC_CORE_SIZE:5}
|
||||
max-size: ${ASYNC_MAX_SIZE:20}
|
||||
queue-capacity: ${ASYNC_QUEUE_CAPACITY:100}
|
||||
keep-alive: ${ASYNC_KEEP_ALIVE:60s}
|
||||
thread-name-prefix: "bill-async-"
|
||||
|
||||
# 서버 설정
|
||||
server:
|
||||
port: ${SERVER_PORT:8081}
|
||||
servlet:
|
||||
context-path: /bill-service
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
error:
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
include-stacktrace: on_param
|
||||
include-exception: false
|
||||
tomcat:
|
||||
uri-encoding: UTF-8
|
||||
max-connections: ${TOMCAT_MAX_CONNECTIONS:8192}
|
||||
accept-count: ${TOMCAT_ACCEPT_COUNT:100}
|
||||
threads:
|
||||
max: ${TOMCAT_MAX_THREADS:200}
|
||||
min-spare: ${TOMCAT_MIN_THREADS:10}
|
||||
|
||||
# 액추에이터 설정 (모니터링)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus,env,beans
|
||||
base-path: /actuator
|
||||
path-mapping:
|
||||
health: health
|
||||
enabled-by-default: false
|
||||
endpoint:
|
||||
health:
|
||||
enabled: true
|
||||
show-details: ${ACTUATOR_HEALTH_DETAILS:when_authorized}
|
||||
show-components: always
|
||||
probes:
|
||||
enabled: true
|
||||
info:
|
||||
enabled: true
|
||||
metrics:
|
||||
enabled: true
|
||||
prometheus:
|
||||
enabled: true
|
||||
health:
|
||||
redis:
|
||||
enabled: true
|
||||
db:
|
||||
enabled: true
|
||||
diskspace:
|
||||
enabled: true
|
||||
ping:
|
||||
enabled: true
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
distribution:
|
||||
percentiles-histogram:
|
||||
http.server.requests: true
|
||||
percentiles:
|
||||
http.server.requests: 0.5, 0.95, 0.99
|
||||
sla:
|
||||
http.server.requests: 100ms, 300ms, 500ms
|
||||
|
||||
# KOS 시스템 연동 설정
|
||||
kos:
|
||||
base-url: ${KOS_BASE_URL:http://localhost:9090}
|
||||
connect-timeout: ${KOS_CONNECT_TIMEOUT:5000}
|
||||
read-timeout: ${KOS_READ_TIMEOUT:30000}
|
||||
max-retries: ${KOS_MAX_RETRIES:3}
|
||||
retry-delay: ${KOS_RETRY_DELAY:1000}
|
||||
|
||||
# Circuit Breaker 설정
|
||||
circuit-breaker:
|
||||
failure-rate-threshold: ${KOS_CB_FAILURE_RATE:0.5}
|
||||
slow-call-duration-threshold: ${KOS_CB_SLOW_DURATION:10000}
|
||||
slow-call-rate-threshold: ${KOS_CB_SLOW_RATE:0.5}
|
||||
sliding-window-size: ${KOS_CB_WINDOW_SIZE:10}
|
||||
minimum-number-of-calls: ${KOS_CB_MIN_CALLS:5}
|
||||
permitted-number-of-calls-in-half-open-state: ${KOS_CB_HALF_OPEN_CALLS:3}
|
||||
wait-duration-in-open-state: ${KOS_CB_OPEN_DURATION:60000}
|
||||
|
||||
# 인증 설정
|
||||
authentication:
|
||||
enabled: ${KOS_AUTH_ENABLED:true}
|
||||
api-key: ${KOS_API_KEY:}
|
||||
secret-key: ${KOS_SECRET_KEY:}
|
||||
token-expiration-seconds: ${KOS_TOKEN_EXPIRATION:3600}
|
||||
token-refresh-threshold-seconds: ${KOS_TOKEN_REFRESH_THRESHOLD:300}
|
||||
|
||||
# 모니터링 설정
|
||||
monitoring:
|
||||
performance-logging-enabled: ${KOS_PERF_LOGGING:true}
|
||||
slow-request-threshold: ${KOS_SLOW_THRESHOLD:3000}
|
||||
metrics-enabled: ${KOS_METRICS_ENABLED:true}
|
||||
health-check-interval: ${KOS_HEALTH_INTERVAL:30000}
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
root: ${LOG_LEVEL_ROOT:INFO}
|
||||
com.phonebill: ${LOG_LEVEL_APP:INFO}
|
||||
com.phonebill.bill.service: ${LOG_LEVEL_SERVICE:INFO}
|
||||
com.phonebill.bill.repository: ${LOG_LEVEL_REPOSITORY:INFO}
|
||||
org.springframework.cache: ${LOG_LEVEL_CACHE:INFO}
|
||||
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:INFO}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:WARN}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_LEVEL_SQL_PARAM:WARN}
|
||||
io.github.resilience4j: ${LOG_LEVEL_RESILIENCE4J:INFO}
|
||||
redis.clients.jedis: ${LOG_LEVEL_REDIS:INFO}
|
||||
pattern:
|
||||
console: "${LOG_PATTERN_CONSOLE:%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
||||
file: "${LOG_PATTERN_FILE:%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:%5p} ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx}}"
|
||||
file:
|
||||
name: ${LOG_FILE_NAME:logs/bill-service.log}
|
||||
max-size: ${LOG_FILE_MAX_SIZE:100MB}
|
||||
max-history: ${LOG_FILE_MAX_HISTORY:30}
|
||||
|
||||
# Swagger/OpenAPI 설정
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: ${SWAGGER_ENABLED:true}
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
enabled: ${SWAGGER_UI_ENABLED:true}
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
display-request-duration: true
|
||||
default-models-expand-depth: 1
|
||||
default-model-expand-depth: 1
|
||||
show-actuator: ${SWAGGER_SHOW_ACTUATOR:false}
|
||||
writer-with-default-pretty-printer: true
|
||||
|
||||
# JWT 보안 설정
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
|
||||
expiration: ${JWT_EXPIRATION:86400000} # 24시간 (밀리초)
|
||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일 (밀리초)
|
||||
header: ${JWT_HEADER:Authorization}
|
||||
prefix: ${JWT_PREFIX:Bearer }
|
||||
|
||||
# 애플리케이션 정보
|
||||
info:
|
||||
app:
|
||||
name: ${spring.application.name}
|
||||
description: 통신요금 조회 및 관리 서비스
|
||||
version: ${BUILD_VERSION:1.0.0}
|
||||
author: 이개발(백엔더)
|
||||
contact: dev@phonebill.com
|
||||
build:
|
||||
time: ${BUILD_TIME:@project.build.time@}
|
||||
artifact: ${BUILD_ARTIFACT:@project.artifactId@}
|
||||
group: ${BUILD_GROUP:@project.groupId@}
|
||||
java:
|
||||
version: ${java.version}
|
||||
git:
|
||||
branch: ${GIT_BRANCH:unknown}
|
||||
commit: ${GIT_COMMIT:unknown}
|
||||
124
build.gradle
Normal file
124
build.gradle
Normal file
@ -0,0 +1,124 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.3.0' apply false
|
||||
id 'io.spring.dependency-management' version '1.1.6' apply false
|
||||
id 'io.freefair.lombok' version '8.10' apply false
|
||||
}
|
||||
|
||||
group = 'com.unicorn.phonebill'
|
||||
version = '1.0.0'
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'org.springframework.boot'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
apply plugin: 'io.freefair.lombok'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// Common versions for all subprojects
|
||||
ext {
|
||||
jjwtVersion = '0.12.5'
|
||||
springdocVersion = '2.5.0'
|
||||
mapstructVersion = '1.5.5.Final'
|
||||
commonsLang3Version = '3.14.0'
|
||||
commonsIoVersion = '2.16.1'
|
||||
hypersistenceVersion = '3.7.3'
|
||||
openaiVersion = '0.18.2'
|
||||
feignJacksonVersion = '13.1'
|
||||
}
|
||||
}
|
||||
|
||||
// Configure only service modules (exclude common and api-gateway)
|
||||
configure(subprojects.findAll { it.name != 'common' && it.name != 'api-gateway' }) {
|
||||
|
||||
dependencies {
|
||||
|
||||
// Common Spring Boot Starters
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-cache'
|
||||
|
||||
// Actuator for health checks and monitoring
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
|
||||
// JWT Authentication (common across all services)
|
||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
|
||||
|
||||
// JSON Processing
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// API Documentation (common across all services)
|
||||
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
||||
|
||||
// Common Utilities
|
||||
implementation "org.apache.commons:commons-lang3:${commonsLang3Version}"
|
||||
|
||||
// Testing
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testImplementation 'org.testcontainers:junit-jupiter'
|
||||
testImplementation 'org.testcontainers:testcontainers'
|
||||
testImplementation 'org.mockito:mockito-junit-jupiter'
|
||||
testImplementation 'org.awaitility:awaitility:4.2.0'
|
||||
|
||||
// Configuration Processor
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
}
|
||||
|
||||
// Configure API Gateway separately (uses WebFlux instead of Web)
|
||||
configure(subprojects.findAll { it.name == 'api-gateway' }) {
|
||||
dependencies {
|
||||
// WebFlux instead of Web for reactive programming
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// Actuator for health checks and monitoring
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
|
||||
// JWT Authentication (same as other services)
|
||||
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||
implementation "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||
implementation "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
|
||||
|
||||
// API Documentation for WebFlux
|
||||
implementation "org.springdoc:springdoc-openapi-starter-webflux-ui:${springdocVersion}"
|
||||
|
||||
// Testing (WebFlux specific)
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
|
||||
// Configuration Processor
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
}
|
||||
}
|
||||
|
||||
37
common/build.gradle
Normal file
37
common/build.gradle
Normal file
@ -0,0 +1,37 @@
|
||||
// Common 모듈: 일반 jar 생성
|
||||
// java-library 플러그인 추가로 api/implementation 사용 가능
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Spring Boot BOM 추가 (의존성 관리를 위해)
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
jar {
|
||||
enabled = true
|
||||
archiveClassifier = ''
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot Starters
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// JWT 라이브러리
|
||||
api "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
|
||||
|
||||
// MapStruct
|
||||
api "org.mapstruct:mapstruct:${mapstructVersion}"
|
||||
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
|
||||
|
||||
// Jackson
|
||||
api 'com.fasterxml.jackson.core:jackson-databind'
|
||||
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.phonebill.common.aop;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 로깅 AOP
|
||||
* 메소드 실행 시간과 파라미터를 로깅합니다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class LoggingAspect {
|
||||
|
||||
@Around("execution(* com.phonebill..service..*(..))")
|
||||
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
Object[] args = joinPoint.getArgs();
|
||||
|
||||
log.info("[SERVICE] {}.{}() called with args: {}", className, methodName, args);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
try {
|
||||
Object result = joinPoint.proceed();
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.info("[SERVICE] {}.{}() completed in {}ms", className, methodName, executionTime);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.error("[SERVICE] {}.{}() failed in {}ms with error: {}", className, methodName, executionTime, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Around("execution(* com.phonebill..controller..*(..))")
|
||||
public Object logControllerMethods(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
Object[] args = joinPoint.getArgs();
|
||||
|
||||
log.info("[CONTROLLER] {}.{}() called with args: {}", className, methodName, args);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
try {
|
||||
Object result = joinPoint.proceed();
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.info("[CONTROLLER] {}.{}() completed in {}ms", className, methodName, executionTime);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
long executionTime = System.currentTimeMillis() - startTime;
|
||||
log.error("[CONTROLLER] {}.{}() failed in {}ms with error: {}", className, methodName, executionTime, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.phonebill.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* JPA 설정
|
||||
* JPA Auditing과 Repository 설정을 제공합니다.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing
|
||||
@EnableJpaRepositories(basePackages = "com.phonebill")
|
||||
public class JpaConfig {
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package com.phonebill.common.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 표준 API 응답 DTO
|
||||
* 모든 API 응답의 일관성을 보장하기 위한 공통 응답 구조
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
|
||||
/**
|
||||
* 응답 성공 여부
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* 응답 메시지
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 응답 데이터
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 오류 코드 (실패시)
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 타임스탬프
|
||||
*/
|
||||
private long timestamp;
|
||||
|
||||
private ApiResponse(boolean success, String message, T data, String errorCode) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
this.errorCode = errorCode;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, "Success", data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 응답 생성 (메시지 포함)
|
||||
*/
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(true, message, data, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(String message) {
|
||||
return new ApiResponse<>(false, message, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답 생성 (오류 코드 포함)
|
||||
*/
|
||||
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||
return new ApiResponse<>(false, message, null, errorCode);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.phonebill.common.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 오류 응답 구조
|
||||
* API 오류 발생 시 표준화된 응답 형식을 제공합니다.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ErrorResponse {
|
||||
|
||||
private String code;
|
||||
private String message;
|
||||
private String detail;
|
||||
private LocalDateTime timestamp;
|
||||
private String path;
|
||||
|
||||
public static ErrorResponse of(String code, String message) {
|
||||
return ErrorResponse.builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ErrorResponse of(String code, String message, String detail) {
|
||||
return ErrorResponse.builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.detail(detail)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ErrorResponse of(String code, String message, String detail, String path) {
|
||||
return ErrorResponse.builder()
|
||||
.code(code)
|
||||
.message(message)
|
||||
.detail(detail)
|
||||
.timestamp(LocalDateTime.now())
|
||||
.path(path)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.phonebill.common.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 페이징 요청 DTO
|
||||
* 목록 조회시 페이징 처리를 위한 공통 요청 구조
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class PageableRequest {
|
||||
|
||||
/**
|
||||
* 페이지 번호 (0부터 시작)
|
||||
*/
|
||||
@Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.")
|
||||
private int page = 0;
|
||||
|
||||
/**
|
||||
* 페이지 크기
|
||||
*/
|
||||
@Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.")
|
||||
private int size = 20;
|
||||
|
||||
/**
|
||||
* 정렬 기준 (예: "id,desc" 또는 "name,asc")
|
||||
*/
|
||||
private String sort;
|
||||
|
||||
public PageableRequest(int page, int size) {
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public PageableRequest(int page, int size, String sort) {
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
this.sort = sort;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package com.phonebill.common.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 페이징 응답 DTO
|
||||
* 목록 조회 결과의 페이징 정보를 포함하는 공통 응답 구조
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class PageableResponse<T> {
|
||||
|
||||
/**
|
||||
* 실제 데이터 목록
|
||||
*/
|
||||
private List<T> content;
|
||||
|
||||
/**
|
||||
* 현재 페이지 번호 (0부터 시작)
|
||||
*/
|
||||
private int page;
|
||||
|
||||
/**
|
||||
* 페이지 크기
|
||||
*/
|
||||
private int size;
|
||||
|
||||
/**
|
||||
* 전체 요소 개수
|
||||
*/
|
||||
private long totalElements;
|
||||
|
||||
/**
|
||||
* 전체 페이지 수
|
||||
*/
|
||||
private int totalPages;
|
||||
|
||||
/**
|
||||
* 첫 번째 페이지 여부
|
||||
*/
|
||||
private boolean first;
|
||||
|
||||
/**
|
||||
* 마지막 페이지 여부
|
||||
*/
|
||||
private boolean last;
|
||||
|
||||
/**
|
||||
* 정렬 기준
|
||||
*/
|
||||
private String sort;
|
||||
|
||||
public PageableResponse(List<T> content, int page, int size, long totalElements, String sort) {
|
||||
this.content = content;
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
this.totalElements = totalElements;
|
||||
this.totalPages = (int) Math.ceil((double) totalElements / size);
|
||||
this.first = page == 0;
|
||||
this.last = page >= totalPages - 1;
|
||||
this.sort = sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이징 응답 생성
|
||||
*/
|
||||
public static <T> PageableResponse<T> of(List<T> content, PageableRequest request, long totalElements) {
|
||||
return new PageableResponse<>(content, request.getPage(), request.getSize(), totalElements, request.getSort());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.phonebill.common.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EntityListeners;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 기본 엔티티 클래스
|
||||
* 생성일시, 수정일시를 자동으로 관리하는 JPA Auditing 기능을 제공합니다.
|
||||
*/
|
||||
@Getter
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 비즈니스 로직 처리 중 발생하는 예외
|
||||
* 일반적인 업무 처리 과정에서 예상되는 오류 상황을 나타냄
|
||||
*/
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* 오류 코드
|
||||
*/
|
||||
private final String errorCode;
|
||||
|
||||
/**
|
||||
* HTTP 상태 코드
|
||||
*/
|
||||
private final int httpStatus;
|
||||
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
this.errorCode = "BUSINESS_ERROR";
|
||||
this.httpStatus = 400; // Bad Request
|
||||
}
|
||||
|
||||
public BusinessException(String message, String errorCode) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = 400; // Bad Request
|
||||
}
|
||||
|
||||
public BusinessException(String message, String errorCode, int httpStatus) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public BusinessException(String message, String errorCode, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = 400; // Bad Request
|
||||
}
|
||||
|
||||
public BusinessException(String message, String errorCode, int httpStatus, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 오류 코드 열거형
|
||||
* 시스템 전체에서 사용되는 표준화된 오류 코드를 정의합니다.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
// 공통 오류
|
||||
INTERNAL_SERVER_ERROR("E0001", "내부 서버 오류가 발생했습니다."),
|
||||
INVALID_INPUT_VALUE("E0002", "입력값이 올바르지 않습니다."),
|
||||
METHOD_NOT_ALLOWED("E0003", "허용되지 않은 HTTP 메소드입니다."),
|
||||
ENTITY_NOT_FOUND("E0004", "요청한 리소스를 찾을 수 없습니다."),
|
||||
INVALID_TYPE_VALUE("E0005", "잘못된 타입의 값입니다."),
|
||||
HANDLE_ACCESS_DENIED("E0006", "접근이 거부되었습니다."),
|
||||
|
||||
// 인증/인가 오류
|
||||
UNAUTHORIZED("E1001", "인증이 필요합니다."),
|
||||
FORBIDDEN("E1002", "권한이 없습니다."),
|
||||
INVALID_TOKEN("E1003", "유효하지 않은 토큰입니다."),
|
||||
TOKEN_EXPIRED("E1004", "토큰이 만료되었습니다."),
|
||||
LOGIN_REQUIRED("E1005", "로그인이 필요합니다."),
|
||||
ACCOUNT_LOCKED("E1006", "계정이 잠겨있습니다."),
|
||||
INVALID_CREDENTIALS("E1007", "잘못된 인증 정보입니다."),
|
||||
|
||||
// 비즈니스 오류
|
||||
BUSINESS_ERROR("E2001", "비즈니스 로직 오류가 발생했습니다."),
|
||||
VALIDATION_ERROR("E2002", "검증 오류가 발생했습니다."),
|
||||
DUPLICATE_RESOURCE("E2003", "중복된 리소스입니다."),
|
||||
RESOURCE_NOT_FOUND("E2004", "요청한 리소스를 찾을 수 없습니다."),
|
||||
OPERATION_NOT_ALLOWED("E2005", "허용되지 않은 작업입니다."),
|
||||
|
||||
// 외부 시스템 연동 오류
|
||||
EXTERNAL_SYSTEM_ERROR("E3001", "외부 시스템 연동 오류가 발생했습니다."),
|
||||
CIRCUIT_BREAKER_OPEN("E3002", "외부 시스템이 일시적으로 사용할 수 없습니다."),
|
||||
TIMEOUT_ERROR("E3003", "요청 시간이 초과되었습니다."),
|
||||
CONNECTION_ERROR("E3004", "연결 오류가 발생했습니다."),
|
||||
|
||||
// 데이터베이스 오류
|
||||
DATABASE_ERROR("E4001", "데이터베이스 오류가 발생했습니다."),
|
||||
CONSTRAINT_VIOLATION("E4002", "데이터 제약 조건 위반이 발생했습니다."),
|
||||
TRANSACTION_ERROR("E4003", "트랜잭션 오류가 발생했습니다."),
|
||||
|
||||
// 캐시 오류
|
||||
CACHE_ERROR("E5001", "캐시 오류가 발생했습니다."),
|
||||
CACHE_NOT_FOUND("E5002", "캐시에서 데이터를 찾을 수 없습니다."),
|
||||
|
||||
// 요금조회 관련 오류
|
||||
BILL_INQUIRY_ERROR("E6001", "요금조회 중 오류가 발생했습니다."),
|
||||
BILL_NOT_FOUND("E6002", "요금 정보를 찾을 수 없습니다."),
|
||||
BILL_INQUIRY_FAILED("E6003", "요금조회에 실패했습니다."),
|
||||
|
||||
// 상품변경 관련 오류
|
||||
PRODUCT_CHANGE_ERROR("E7001", "상품변경 중 오류가 발생했습니다."),
|
||||
PRODUCT_NOT_FOUND("E7002", "상품 정보를 찾을 수 없습니다."),
|
||||
PRODUCT_VALIDATION_ERROR("E7003", "상품변경 검증에 실패했습니다."),
|
||||
PRODUCT_CHANGE_FAILED("E7004", "상품변경에 실패했습니다."),
|
||||
|
||||
// KOS 연동 오류
|
||||
KOS_CONNECTION_ERROR("E8001", "KOS 시스템 연결 오류가 발생했습니다."),
|
||||
KOS_RESPONSE_ERROR("E8002", "KOS 시스템 응답 오류가 발생했습니다."),
|
||||
KOS_TIMEOUT_ERROR("E8003", "KOS 시스템 응답 시간 초과가 발생했습니다."),
|
||||
KOS_SERVICE_UNAVAILABLE("E8004", "KOS 시스템이 일시적으로 사용할 수 없습니다.");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
import com.phonebill.common.dto.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 전역 예외 처리기
|
||||
* 모든 컨트롤러에서 발생하는 예외를 일관된 형태로 처리
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
|
||||
log.warn("Business exception occurred: {}", ex.getMessage());
|
||||
|
||||
ApiResponse<Void> response = ApiResponse.error(ex.getMessage(), ex.getErrorCode());
|
||||
return ResponseEntity.status(ex.getHttpStatus()).body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효성 검증 실패 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(MethodArgumentNotValidException ex) {
|
||||
log.warn("Validation exception occurred: {}", ex.getMessage());
|
||||
|
||||
Map<String, String> errors = new HashMap<>();
|
||||
ex.getBindingResult().getFieldErrors().forEach(error -> {
|
||||
errors.put(error.getField(), error.getDefaultMessage());
|
||||
});
|
||||
|
||||
ApiResponse<Map<String, String>> response = ApiResponse.error("입력값이 올바르지 않습니다.", "VALIDATION_ERROR");
|
||||
response.setData(errors);
|
||||
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반적인 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
|
||||
log.error("Unexpected exception occurred", ex);
|
||||
|
||||
ApiResponse<Void> response = ApiResponse.error("서버 내부 오류가 발생했습니다.", "INTERNAL_SERVER_ERROR");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* IllegalArgumentException 처리
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException ex) {
|
||||
log.warn("Illegal argument exception occurred: {}", ex.getMessage());
|
||||
|
||||
ApiResponse<Void> response = ApiResponse.error(ex.getMessage(), "INVALID_ARGUMENT");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* RuntimeException 처리
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException ex) {
|
||||
log.error("Runtime exception occurred", ex);
|
||||
|
||||
ApiResponse<Void> response = ApiResponse.error("처리 중 오류가 발생했습니다.", "RUNTIME_ERROR");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
/**
|
||||
* 인프라 예외
|
||||
* 데이터베이스, 캐시, 외부 시스템 연동 등 인프라 관련 오류를 나타냅니다.
|
||||
*/
|
||||
public class InfraException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
public InfraException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public InfraException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public InfraException(ErrorCode errorCode, Throwable cause) {
|
||||
super(errorCode.getMessage(), cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public InfraException(ErrorCode errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public ErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
/**
|
||||
* 리소스를 찾을 수 없는 경우 발생하는 예외
|
||||
* 사용자, 요금제, 청구서 등의 데이터가 존재하지 않을 때 사용
|
||||
*/
|
||||
public class ResourceNotFoundException extends BusinessException {
|
||||
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message, "RESOURCE_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
public ResourceNotFoundException(String resourceType, Object id) {
|
||||
super(String.format("%s를 찾을 수 없습니다. ID: %s", resourceType, id), "RESOURCE_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
public ResourceNotFoundException(String message, Throwable cause) {
|
||||
super(message, "RESOURCE_NOT_FOUND", 404, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
/**
|
||||
* 인증되지 않은 요청에 대한 예외
|
||||
* JWT 토큰이 유효하지 않거나 만료된 경우 발생
|
||||
*/
|
||||
public class UnauthorizedException extends BusinessException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super(message, "UNAUTHORIZED", 401);
|
||||
}
|
||||
|
||||
public UnauthorizedException() {
|
||||
super("인증이 필요합니다.", "UNAUTHORIZED", 401);
|
||||
}
|
||||
|
||||
public UnauthorizedException(String message, Throwable cause) {
|
||||
super(message, "UNAUTHORIZED", 401, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.phonebill.common.exception;
|
||||
|
||||
/**
|
||||
* 데이터 검증 실패시 발생하는 예외
|
||||
* 입력 데이터가 비즈니스 규칙에 맞지 않을 때 사용
|
||||
*/
|
||||
public class ValidationException extends BusinessException {
|
||||
|
||||
public ValidationException(String message) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
}
|
||||
|
||||
public ValidationException(String field, String message) {
|
||||
super(String.format("%s: %s", field, message), "VALIDATION_ERROR", 400);
|
||||
}
|
||||
|
||||
public ValidationException(String message, Throwable cause) {
|
||||
super(message, "VALIDATION_ERROR", 400, cause);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package com.phonebill.common.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터
|
||||
* HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String token = jwtTokenProvider.resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||
String userId = jwtTokenProvider.getUserId(token);
|
||||
String username = null;
|
||||
String authority = null;
|
||||
|
||||
try {
|
||||
username = jwtTokenProvider.getUsername(token);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT에 username 클레임이 없음: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
authority = jwtTokenProvider.getAuthority(token);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(userId)) {
|
||||
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
|
||||
UserPrincipal userPrincipal = UserPrincipal.builder()
|
||||
.userId(userId)
|
||||
.username(username != null ? username : "unknown")
|
||||
.authority(authority != null ? authority : "USER")
|
||||
.build();
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userPrincipal,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority(authority != null ? authority : "USER"))
|
||||
);
|
||||
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.startsWith("/actuator") ||
|
||||
path.startsWith("/swagger-ui") ||
|
||||
path.startsWith("/v3/api-docs") ||
|
||||
path.equals("/health");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package com.phonebill.common.security;
|
||||
|
||||
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 io.jsonwebtoken.security.SecurityException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 토큰 제공자
|
||||
* JWT 토큰의 생성, 검증, 파싱을 담당
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long tokenValidityInMilliseconds;
|
||||
|
||||
public JwtTokenProvider(@Value("${security.jwt.secret:}") String secret,
|
||||
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
|
||||
if (StringUtils.hasText(secret)) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
// 개발용 기본 시크릿 키 (32바이트 이상)
|
||||
this.secretKey = Keys.hmacShaKeyFor("phonebill-default-secret-key-for-development-only".getBytes(StandardCharsets.UTF_8));
|
||||
log.warn("JWT secret key not provided, using default development key");
|
||||
}
|
||||
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청에서 JWT 토큰 추출
|
||||
*/
|
||||
public String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 유효성 검증
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token);
|
||||
return true;
|
||||
} catch (SecurityException | MalformedJwtException e) {
|
||||
log.debug("Invalid JWT signature: {}", e.getMessage());
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.debug("Expired JWT token: {}", e.getMessage());
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.debug("Unsupported JWT token: {}", e.getMessage());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug("JWT token compact of handler are invalid: {}", e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 ID 추출
|
||||
*/
|
||||
public String getUserId(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자명 추출
|
||||
*/
|
||||
public String getUsername(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.get("username", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 권한 정보 추출
|
||||
*/
|
||||
public String getAuthority(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.get("authority", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간 확인
|
||||
*/
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.getExpiration().before(new Date());
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 만료 시간 추출
|
||||
*/
|
||||
public Date getExpirationDate(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
|
||||
return claims.getExpiration();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.phonebill.common.security;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 인증된 사용자 정보
|
||||
* JWT 토큰에서 추출된 사용자 정보를 담는 Principal 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@RequiredArgsConstructor
|
||||
public class UserPrincipal {
|
||||
|
||||
/**
|
||||
* 사용자 고유 ID
|
||||
*/
|
||||
private final String userId;
|
||||
|
||||
/**
|
||||
* 사용자명
|
||||
*/
|
||||
private final String username;
|
||||
|
||||
/**
|
||||
* 사용자 권한
|
||||
*/
|
||||
private final String authority;
|
||||
|
||||
/**
|
||||
* 사용자 ID 반환 (별칭)
|
||||
*/
|
||||
public String getName() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한 여부 확인
|
||||
*/
|
||||
public boolean isAdmin() {
|
||||
return "ADMIN".equals(authority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 사용자 권한 여부 확인
|
||||
*/
|
||||
public boolean isUser() {
|
||||
return "USER".equals(authority) || authority == null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package com.phonebill.common.util;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 날짜/시간 관련 유틸리티
|
||||
* 날짜 포맷팅, 파싱 등의 공통 기능을 제공
|
||||
*/
|
||||
public class DateTimeUtils {
|
||||
|
||||
/**
|
||||
* 표준 날짜/시간 포맷터
|
||||
*/
|
||||
public static final DateTimeFormatter STANDARD_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 날짜 포맷터
|
||||
*/
|
||||
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* 시간 포맷터
|
||||
*/
|
||||
public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* ISO 8601 포맷터
|
||||
*/
|
||||
public static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
/**
|
||||
* LocalDateTime을 문자열로 변환
|
||||
*/
|
||||
public static String format(LocalDateTime dateTime) {
|
||||
if (dateTime == null) {
|
||||
return null;
|
||||
}
|
||||
return dateTime.format(STANDARD_DATETIME_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDateTime을 지정된 포맷으로 변환
|
||||
*/
|
||||
public static String format(LocalDateTime dateTime, DateTimeFormatter formatter) {
|
||||
if (dateTime == null) {
|
||||
return null;
|
||||
}
|
||||
return dateTime.format(formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 LocalDateTime으로 파싱
|
||||
*/
|
||||
public static LocalDateTime parse(String dateTimeString) {
|
||||
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return LocalDateTime.parse(dateTimeString, STANDARD_DATETIME_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 지정된 포맷으로 LocalDateTime으로 파싱
|
||||
*/
|
||||
public static LocalDateTime parse(String dateTimeString, DateTimeFormatter formatter) {
|
||||
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return LocalDateTime.parse(dateTimeString, formatter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜/시간을 표준 포맷으로 반환
|
||||
*/
|
||||
public static String getCurrentDateTime() {
|
||||
return LocalDateTime.now().format(STANDARD_DATETIME_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜를 반환
|
||||
*/
|
||||
public static String getCurrentDate() {
|
||||
return LocalDateTime.now().format(DATE_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 반환
|
||||
*/
|
||||
public static String getCurrentTime() {
|
||||
return LocalDateTime.now().format(TIME_FORMATTER);
|
||||
}
|
||||
}
|
||||
108
common/src/main/java/com/phonebill/common/util/DateUtil.java
Normal file
108
common/src/main/java/com/phonebill/common/util/DateUtil.java
Normal file
@ -0,0 +1,108 @@
|
||||
package com.phonebill.common.util;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 날짜 유틸리티
|
||||
* 날짜 관련 공통 기능을 제공합니다.
|
||||
*/
|
||||
public class DateUtil {
|
||||
|
||||
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
|
||||
public static final String DEFAULT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
public static final String DEFAULT_TIMESTAMP_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS";
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT);
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATETIME_FORMAT);
|
||||
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_TIMESTAMP_FORMAT);
|
||||
|
||||
/**
|
||||
* 현재 날짜를 문자열로 반환
|
||||
*/
|
||||
public static String getCurrentDateString() {
|
||||
return LocalDate.now().format(DATE_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 날짜시간을 문자열로 반환
|
||||
*/
|
||||
public static String getCurrentDateTimeString() {
|
||||
return LocalDateTime.now().format(DATETIME_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 타임스탬프를 문자열로 반환
|
||||
*/
|
||||
public static String getCurrentTimestampString() {
|
||||
return LocalDateTime.now().format(TIMESTAMP_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDate를 문자열로 변환
|
||||
*/
|
||||
public static String formatDate(LocalDate date) {
|
||||
return date != null ? date.format(DATE_FORMATTER) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalDateTime을 문자열로 변환
|
||||
*/
|
||||
public static String formatDateTime(LocalDateTime dateTime) {
|
||||
return dateTime != null ? dateTime.format(DATETIME_FORMATTER) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 LocalDate로 변환
|
||||
*/
|
||||
public static LocalDate parseDate(String dateString) {
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(dateString, DATE_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("Invalid date format: " + dateString, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 LocalDateTime으로 변환
|
||||
*/
|
||||
public static LocalDateTime parseDateTime(String dateTimeString) {
|
||||
if (dateTimeString == null || dateTimeString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(dateTimeString, DATETIME_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("Invalid datetime format: " + dateTimeString, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 유효성 검사
|
||||
*/
|
||||
public static boolean isValidDate(String dateString) {
|
||||
try {
|
||||
parseDate(dateString);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜시간 유효성 검사
|
||||
*/
|
||||
public static boolean isValidDateTime(String dateTimeString) {
|
||||
try {
|
||||
parseDateTime(dateTimeString);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.phonebill.common.util;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 보안 유틸리티
|
||||
* Spring Security 관련 공통 기능을 제공합니다.
|
||||
*/
|
||||
public class SecurityUtil {
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자 ID를 반환
|
||||
*/
|
||||
public static Optional<String> getCurrentUserId() {
|
||||
return getCurrentUserDetails()
|
||||
.map(UserDetails::getUsername);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자 정보를 반환
|
||||
*/
|
||||
public static Optional<UserDetails> getCurrentUserDetails() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof UserDetails) {
|
||||
return Optional.of((UserDetails) principal);
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자의 권한을 확인
|
||||
*/
|
||||
public static boolean hasAuthority(String authority) {
|
||||
return getCurrentUserDetails()
|
||||
.map(user -> user.getAuthorities().stream()
|
||||
.anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals(authority)))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자가 특정 역할을 가지고 있는지 확인
|
||||
*/
|
||||
public static boolean hasRole(String role) {
|
||||
return hasAuthority("ROLE_" + role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자가 인증되었는지 확인
|
||||
*/
|
||||
public static boolean isAuthenticated() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return authentication != null && authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자의 인증 정보를 반환
|
||||
*/
|
||||
public static Optional<Authentication> getCurrentAuthentication() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return Optional.ofNullable(authentication);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package com.phonebill.common.util;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 검증 유틸리티
|
||||
* 입력값 검증 관련 공통 기능을 제공합니다.
|
||||
*/
|
||||
public class ValidatorUtil {
|
||||
|
||||
// 전화번호 패턴 (010-1234-5678, 01012345678)
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$");
|
||||
|
||||
// 이메일 패턴
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||
|
||||
// 사용자 ID 패턴 (영문, 숫자, 3-20자)
|
||||
private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{3,20}$");
|
||||
|
||||
// 비밀번호 패턴 (영문, 숫자, 특수문자 포함 8-20자)
|
||||
private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{8,20}$");
|
||||
|
||||
/**
|
||||
* 전화번호 형식 검증
|
||||
*/
|
||||
public static boolean isValidPhoneNumber(String phoneNumber) {
|
||||
if (phoneNumber == null || phoneNumber.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return PHONE_PATTERN.matcher(phoneNumber.trim()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 형식 검증
|
||||
*/
|
||||
public static boolean isValidEmail(String email) {
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return EMAIL_PATTERN.matcher(email.trim()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 ID 형식 검증
|
||||
*/
|
||||
public static boolean isValidUserId(String userId) {
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return USER_ID_PATTERN.matcher(userId.trim()).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 형식 검증
|
||||
*/
|
||||
public static boolean isValidPassword(String password) {
|
||||
if (password == null || password.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return PASSWORD_PATTERN.matcher(password).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 비어있는지 검증
|
||||
*/
|
||||
public static boolean isNullOrEmpty(String str) {
|
||||
return str == null || str.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열이 null이거나 비어있지 않은지 검증
|
||||
*/
|
||||
public static boolean isNotNullOrEmpty(String str) {
|
||||
return !isNullOrEmpty(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 길이 검증
|
||||
*/
|
||||
public static boolean isValidLength(String str, int minLength, int maxLength) {
|
||||
if (str == null) {
|
||||
return minLength == 0;
|
||||
}
|
||||
int length = str.length();
|
||||
return length >= minLength && length <= maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 문자열 검증
|
||||
*/
|
||||
public static boolean isNumeric(String str) {
|
||||
if (str == null || str.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Long.parseLong(str.trim());
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 양수 검증
|
||||
*/
|
||||
public static boolean isPositiveNumber(String str) {
|
||||
if (!isNumeric(str)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
long number = Long.parseLong(str.trim());
|
||||
return number > 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
259
design/backend/api/API설계서.md
Normal file
259
design/backend/api/API설계서.md
Normal file
@ -0,0 +1,259 @@
|
||||
# API 설계서 - 통신요금 관리 서비스
|
||||
|
||||
**최적안**: 이개발(백엔더)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
통신요금 관리 서비스의 3개 마이크로서비스에 대한 RESTful API 설계입니다.
|
||||
유저스토리와 외부시퀀스설계서를 기반으로 OpenAPI 3.0 표준에 따라 설계되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 설계된 API 서비스
|
||||
|
||||
### 1. Auth Service
|
||||
- **파일**: `auth-service-api.yaml`
|
||||
- **목적**: 사용자 인증 및 인가 관리
|
||||
- **관련 유저스토리**: UFR-AUTH-010, UFR-AUTH-020
|
||||
- **주요 엔드포인트**: 7개 API
|
||||
|
||||
### 2. Bill-Inquiry Service
|
||||
- **파일**: `bill-inquiry-service-api.yaml`
|
||||
- **목적**: 요금 조회 서비스
|
||||
- **관련 유저스토리**: UFR-BILL-010, UFR-BILL-020, UFR-BILL-030, UFR-BILL-040
|
||||
- **주요 엔드포인트**: 4개 API
|
||||
|
||||
### 3. Product-Change Service
|
||||
- **파일**: `product-change-service-api.yaml`
|
||||
- **목적**: 상품 변경 서비스
|
||||
- **관련 유저스토리**: UFR-PROD-010, UFR-PROD-020, UFR-PROD-030, UFR-PROD-040
|
||||
- **주요 엔드포인트**: 7개 API
|
||||
|
||||
---
|
||||
|
||||
## API 설계 원칙 준수 현황
|
||||
|
||||
### ✅ 유저스토리 완벽 매칭
|
||||
- **10개 유저스토리 100% 반영**
|
||||
- 각 API에 x-user-story 필드로 유저스토리 ID 매핑
|
||||
- 불필요한 추가 설계 없음
|
||||
|
||||
### ✅ 외부시퀀스설계서 일치
|
||||
- **모든 API가 외부시퀀스와 완벽 일치**
|
||||
- 서비스 간 호출 순서 및 데이터 플로우 반영
|
||||
- Cache-Aside, Circuit Breaker 패턴 반영
|
||||
|
||||
### ✅ OpenAPI 3.0 표준 준수
|
||||
- **YAML 문법 검증 완료**: ✅ 모든 파일 Valid
|
||||
- **servers 섹션 포함**: SwaggerHub Mock URL 포함
|
||||
- **상세한 스키마 정의**: Request/Response 모든 스키마 포함
|
||||
- **보안 스키마 정의**: JWT Bearer Token 표준
|
||||
|
||||
### ✅ RESTful 설계 원칙
|
||||
- **HTTP 메서드 적절 사용**: GET, POST, PUT, DELETE
|
||||
- **리소스 중심 URL**: /auth, /bills, /products
|
||||
- **상태 코드 표준화**: 200, 201, 400, 401, 403, 500 등
|
||||
- **HATEOAS 고려**: 관련 리소스 링크 제공
|
||||
|
||||
---
|
||||
|
||||
## Auth Service API 상세
|
||||
|
||||
### 🔐 주요 기능
|
||||
- **사용자 인증**: JWT 토큰 기반 로그인/로그아웃
|
||||
- **권한 관리**: 서비스별 세분화된 권한 확인
|
||||
- **세션 관리**: Redis 캐시 기반 세션 처리
|
||||
- **보안 강화**: 5회 실패 시 계정 잠금
|
||||
|
||||
### 📋 API 목록 (7개)
|
||||
1. **POST /auth/login** - 사용자 로그인
|
||||
2. **POST /auth/logout** - 사용자 로그아웃
|
||||
3. **GET /auth/verify** - JWT 토큰 검증
|
||||
4. **POST /auth/refresh** - 토큰 갱신
|
||||
5. **GET /auth/permissions** - 사용자 권한 조회
|
||||
6. **POST /auth/permissions/check** - 특정 서비스 접근 권한 확인
|
||||
7. **GET /auth/user-info** - 사용자 정보 조회
|
||||
|
||||
### 🔒 보안 특징
|
||||
- **JWT 토큰**: Access Token (30분), Refresh Token (24시간)
|
||||
- **계정 보안**: 연속 실패 시 자동 잠금
|
||||
- **세션 캐싱**: Redis TTL 30분/24시간
|
||||
- **IP 추적**: 보안 모니터링
|
||||
|
||||
---
|
||||
|
||||
## Bill-Inquiry Service API 상세
|
||||
|
||||
### 💰 주요 기능
|
||||
- **요금조회 메뉴**: 인증된 사용자 메뉴 제공
|
||||
- **요금 조회**: KOS 시스템 연동 요금 정보 조회
|
||||
- **캐시 전략**: Redis 1시간 TTL로 성능 최적화
|
||||
- **이력 관리**: 요청/처리 이력 완전 추적
|
||||
|
||||
### 📋 API 목록 (4개)
|
||||
1. **GET /bills/menu** - 요금조회 메뉴 조회
|
||||
2. **POST /bills/inquiry** - 요금 조회 요청
|
||||
3. **GET /bills/inquiry/{requestId}** - 요금조회 결과 확인
|
||||
4. **GET /bills/history** - 요금조회 이력 조회
|
||||
|
||||
### ⚡ 성능 최적화
|
||||
- **캐시 전략**: Cache-Aside 패턴 (1시간 TTL)
|
||||
- **Circuit Breaker**: KOS 연동 장애 격리
|
||||
- **비동기 처리**: 이력 저장 백그라운드 처리
|
||||
- **응답 시간**: < 1초 (캐시 히트 시 < 200ms)
|
||||
|
||||
---
|
||||
|
||||
## Product-Change Service API 상세
|
||||
|
||||
### 🔄 주요 기능
|
||||
- **상품변경 메뉴**: 고객/상품 정보 통합 제공
|
||||
- **사전 체크**: 변경 가능성 사전 검증
|
||||
- **상품 변경**: KOS 시스템 연동 변경 처리
|
||||
- **상태 관리**: 진행중/완료/실패 상태 추적
|
||||
|
||||
### 📋 API 목록 (7개)
|
||||
1. **GET /products/menu** - 상품변경 메뉴 조회
|
||||
2. **GET /products/customer/{lineNumber}** - 고객 정보 조회
|
||||
3. **GET /products/available** - 변경 가능한 상품 목록 조회
|
||||
4. **POST /products/change/validation** - 상품변경 사전체크
|
||||
5. **POST /products/change** - 상품변경 요청
|
||||
6. **GET /products/change/{requestId}** - 상품변경 결과 조회
|
||||
7. **GET /products/history** - 상품변경 이력 조회
|
||||
|
||||
### 🎯 프로세스 관리
|
||||
- **사전 체크**: 판매중 상품, 사업자 일치, 회선 상태 확인
|
||||
- **비동기 처리**: 202 Accepted 응답 후 백그라운드 처리
|
||||
- **트랜잭션**: 요청 ID 기반 완전한 추적성
|
||||
- **캐시 무효화**: 변경 완료 시 관련 캐시 삭제
|
||||
|
||||
---
|
||||
|
||||
## 공통 설계 특징
|
||||
|
||||
### 🔗 서비스 간 통신
|
||||
- **API Gateway**: 단일 진입점 및 라우팅
|
||||
- **JWT 인증**: 모든 서비스에서 통일된 인증
|
||||
- **Circuit Breaker**: 외부 시스템 연동 안정성
|
||||
- **캐시 전략**: Redis 기반 성능 최적화
|
||||
|
||||
### 📊 응답 구조 표준화
|
||||
```yaml
|
||||
# 성공 응답
|
||||
{
|
||||
"success": true,
|
||||
"message": "요청이 성공했습니다",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
# 오류 응답
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "AUTH001",
|
||||
"message": "사용자 인증에 실패했습니다",
|
||||
"details": "ID 또는 비밀번호를 확인해주세요",
|
||||
"timestamp": "2025-01-08T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🏷️ 오류 코드 체계
|
||||
- **AUTH001~AUTH011**: 인증 서비스 오류
|
||||
- **BILL001~BILL008**: 요금조회 서비스 오류
|
||||
- **PROD001~PROD010**: 상품변경 서비스 오류
|
||||
|
||||
### 🔄 Cache-Aside 패턴 적용
|
||||
- **Auth Service**: 세션 캐시 (TTL: 30분~24시간)
|
||||
- **Bill-Inquiry**: 요금정보 캐시 (TTL: 1시간)
|
||||
- **Product-Change**: 상품정보 캐시 (TTL: 24시간)
|
||||
|
||||
---
|
||||
|
||||
## 기술 패턴 적용 현황
|
||||
|
||||
### ✅ API Gateway 패턴
|
||||
- **단일 진입점**: 모든 클라이언트 요청 통합 처리
|
||||
- **인증/인가 중앙화**: JWT 토큰 검증 통합
|
||||
- **서비스별 라우팅**: 경로 기반 마이크로서비스 연결
|
||||
- **Rate Limiting**: 서비스 보호
|
||||
|
||||
### ✅ Cache-Aside 패턴
|
||||
- **읽기 최적화**: 캐시 먼저 확인 후 DB 조회
|
||||
- **쓰기 일관성**: 데이터 변경 시 캐시 무효화
|
||||
- **TTL 전략**: 데이터 특성에 맞는 TTL 설정
|
||||
- **성능 향상**: 85% 캐시 적중률 목표
|
||||
|
||||
### ✅ Circuit Breaker 패턴
|
||||
- **외부 연동 보호**: KOS 시스템 장애 시 서비스 보호
|
||||
- **자동 복구**: 타임아웃/오류 발생 시 자동 차단/복구
|
||||
- **Fallback**: 대체 응답 또는 캐시된 데이터 제공
|
||||
- **모니터링**: 연동 상태 실시간 추적
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### 🔍 문법 검증 완료
|
||||
```bash
|
||||
✅ auth-service-api.yaml is valid
|
||||
✅ bill-inquiry-service-api.yaml is valid
|
||||
✅ product-change-service-api.yaml is valid
|
||||
```
|
||||
|
||||
### 📋 설계 품질 검증
|
||||
- ✅ **유저스토리 매핑**: 10개 스토리 100% 반영
|
||||
- ✅ **외부시퀀스 일치**: 3개 플로우 완벽 매칭
|
||||
- ✅ **OpenAPI 3.0**: 표준 스펙 완전 준수
|
||||
- ✅ **보안 고려**: JWT 인증 및 권한 관리
|
||||
- ✅ **오류 처리**: 체계적인 오류 코드 및 메시지
|
||||
- ✅ **캐시 전략**: 성능 최적화 반영
|
||||
- ✅ **Circuit Breaker**: 외부 연동 안정성 확보
|
||||
|
||||
---
|
||||
|
||||
## API 확인 및 테스트 방법
|
||||
|
||||
### 1. Swagger Editor 확인
|
||||
1. https://editor.swagger.io/ 접속
|
||||
2. 각 YAML 파일 내용을 붙여넣기
|
||||
3. API 문서 확인 및 테스트 실행
|
||||
|
||||
### 2. 파일 위치
|
||||
```
|
||||
design/backend/api/
|
||||
├── auth-service-api.yaml # 인증 서비스 API
|
||||
├── bill-inquiry-service-api.yaml # 요금조회 서비스 API
|
||||
├── product-change-service-api.yaml # 상품변경 서비스 API
|
||||
└── API설계서.md # 이 문서
|
||||
```
|
||||
|
||||
### 3. 개발 단계별 활용
|
||||
- **백엔드 개발**: API 명세를 기반으로 컨트롤러/서비스 구현
|
||||
- **프론트엔드 개발**: API 클라이언트 코드 생성 및 연동
|
||||
- **테스트**: API 테스트 케이스 작성 및 검증
|
||||
- **문서화**: 개발자/운영자를 위한 API 문서
|
||||
|
||||
---
|
||||
|
||||
## 팀 검토 결과
|
||||
|
||||
### 김기획(기획자)
|
||||
"비즈니스 요구사항이 API에 정확히 반영되었고, 유저스토리별 추적이 완벽합니다."
|
||||
|
||||
### 박화면(프론트)
|
||||
"프론트엔드 개발에 필요한 모든 API가 명세되어 있고, 응답 구조가 표준화되어 개발이 수월합니다."
|
||||
|
||||
### 최운영(데옵스)
|
||||
"캐시 전략과 Circuit Breaker 패턴이 잘 반영되어 운영 안정성이 확보되었습니다."
|
||||
|
||||
### 정테스트(QA매니저)
|
||||
"오류 케이스와 상태 코드가 체계적으로 정의되어 테스트 시나리오 작성에 완벽합니다."
|
||||
|
||||
---
|
||||
|
||||
**작성자**: 이개발(백엔더)
|
||||
**작성일**: 2025-01-08
|
||||
**검토자**: 김기획(기획자), 박화면(프론트), 최운영(데옵스), 정테스트(QA매니저)
|
||||
820
design/backend/api/auth-service-api.yaml
Normal file
820
design/backend/api/auth-service-api.yaml
Normal file
@ -0,0 +1,820 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Auth Service API
|
||||
description: |
|
||||
통신요금 관리 서비스의 사용자 인증 및 인가를 담당하는 Auth Service API
|
||||
|
||||
## 주요 기능
|
||||
- 사용자 로그인/로그아웃 처리
|
||||
- JWT 토큰 기반 인증
|
||||
- Redis를 통한 세션 관리
|
||||
- 서비스별 접근 권한 검증
|
||||
- 토큰 갱신 처리
|
||||
|
||||
## 보안 고려사항
|
||||
- 5회 연속 로그인 실패 시 30분간 계정 잠금
|
||||
- JWT Access Token: 30분 만료
|
||||
- JWT Refresh Token: 24시간 만료
|
||||
- Redis 세션 캐싱 (TTL: 30분, 자동로그인 시 24시간)
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Backend Development Team
|
||||
email: backend@mvno.com
|
||||
license:
|
||||
name: Private
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8081
|
||||
description: Development server
|
||||
- url: https://api-dev.mvno.com
|
||||
description: Development environment
|
||||
- url: https://api.mvno.com
|
||||
description: Production environment
|
||||
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: 사용자 인증 관련 API
|
||||
- name: Authorization
|
||||
description: 사용자 권한 확인 관련 API
|
||||
- name: Token Management
|
||||
description: 토큰 관리 관련 API
|
||||
- name: Session Management
|
||||
description: 세션 관리 관련 API
|
||||
|
||||
paths:
|
||||
/auth/login:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: 사용자 로그인
|
||||
description: |
|
||||
MVNO 고객의 로그인을 처리합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- UFR-AUTH-010 유저스토리 구현
|
||||
- 로그인 시도 횟수 확인 (최대 5회)
|
||||
- 비밀번호 검증
|
||||
- JWT 토큰 생성 (Access Token: 30분, Refresh Token: 24시간)
|
||||
- Redis 세션 생성 및 캐싱
|
||||
- 로그인 이력 기록
|
||||
|
||||
## 보안 정책
|
||||
- 5회 연속 실패 시 30분간 계정 잠금
|
||||
- 비밀번호 해싱 검증 (bcrypt)
|
||||
- IP 기반 로그인 이력 추적
|
||||
operationId: login
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
example:
|
||||
userId: "mvno001"
|
||||
password: "securePassword123!"
|
||||
autoLogin: false
|
||||
responses:
|
||||
'200':
|
||||
description: 로그인 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "로그인이 성공적으로 완료되었습니다."
|
||||
data:
|
||||
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
expiresIn: 1800
|
||||
user:
|
||||
userId: "mvno001"
|
||||
userName: "홍길동"
|
||||
phoneNumber: "010-1234-5678"
|
||||
permissions:
|
||||
- "BILL_INQUIRY"
|
||||
- "PRODUCT_CHANGE"
|
||||
'401':
|
||||
description: 인증 실패
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
invalid_credentials:
|
||||
summary: 잘못된 인증 정보
|
||||
value:
|
||||
success: false
|
||||
error:
|
||||
code: "AUTH_001"
|
||||
message: "ID 또는 비밀번호를 확인해주세요"
|
||||
details: "입력된 인증 정보가 올바르지 않습니다."
|
||||
account_locked:
|
||||
summary: 계정 잠금 (5회 실패)
|
||||
value:
|
||||
success: false
|
||||
error:
|
||||
code: "AUTH_002"
|
||||
message: "5회 연속 실패하여 30분간 계정이 잠금되었습니다."
|
||||
details: "30분 후 다시 시도해주세요."
|
||||
account_temp_locked:
|
||||
summary: 계정 일시 잠금
|
||||
value:
|
||||
success: false
|
||||
error:
|
||||
code: "AUTH_003"
|
||||
message: "계정이 잠금되었습니다. 30분 후 다시 시도해주세요."
|
||||
details: "이전 5회 연속 실패로 인한 임시 잠금 상태입니다."
|
||||
'400':
|
||||
description: 잘못된 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "VALIDATION_ERROR"
|
||||
message: "요청 데이터가 올바르지 않습니다."
|
||||
details: "userId는 필수 입력 항목입니다."
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "INTERNAL_SERVER_ERROR"
|
||||
message: "서버 내부 오류가 발생했습니다."
|
||||
details: "잠시 후 다시 시도해주세요."
|
||||
|
||||
/auth/logout:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: 사용자 로그아웃
|
||||
description: |
|
||||
현재 사용자의 로그아웃을 처리합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- Redis 세션 삭제
|
||||
- 로그아웃 이력 기록
|
||||
- 클라이언트의 토큰 무효화 안내
|
||||
operationId: logout
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 로그아웃 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "로그아웃이 성공적으로 완료되었습니다."
|
||||
'401':
|
||||
description: 인증되지 않은 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "UNAUTHORIZED"
|
||||
message: "인증이 필요합니다."
|
||||
details: "유효한 토큰이 필요합니다."
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/verify:
|
||||
get:
|
||||
tags:
|
||||
- Token Management
|
||||
summary: JWT 토큰 검증
|
||||
description: |
|
||||
JWT 토큰의 유효성을 검증하고 사용자 정보를 반환합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- JWT 토큰 유효성 검사
|
||||
- Redis 세션 확인 (Cache-Aside 패턴)
|
||||
- 세션 미스 시 DB에서 재조회 후 캐시 갱신
|
||||
- 토큰 만료 검사
|
||||
operationId: verifyToken
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 토큰 검증 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenVerifyResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "토큰이 유효합니다."
|
||||
data:
|
||||
valid: true
|
||||
user:
|
||||
userId: "mvno001"
|
||||
userName: "홍길동"
|
||||
phoneNumber: "010-1234-5678"
|
||||
permissions:
|
||||
- "BILL_INQUIRY"
|
||||
- "PRODUCT_CHANGE"
|
||||
expiresIn: 1200
|
||||
'401':
|
||||
description: 토큰 무효 또는 만료
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
token_expired:
|
||||
summary: 토큰 만료
|
||||
value:
|
||||
success: false
|
||||
error:
|
||||
code: "TOKEN_EXPIRED"
|
||||
message: "토큰이 만료되었습니다."
|
||||
details: "새로운 토큰을 발급받아주세요."
|
||||
token_invalid:
|
||||
summary: 유효하지 않은 토큰
|
||||
value:
|
||||
success: false
|
||||
error:
|
||||
code: "TOKEN_INVALID"
|
||||
message: "유효하지 않은 토큰입니다."
|
||||
details: "올바른 토큰을 제공해주세요."
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/refresh:
|
||||
post:
|
||||
tags:
|
||||
- Token Management
|
||||
summary: 토큰 갱신
|
||||
description: |
|
||||
Refresh Token을 사용하여 새로운 Access Token을 발급합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- Refresh Token 유효성 검증
|
||||
- 새로운 Access Token 생성 (30분 만료)
|
||||
- Redis 세션 갱신
|
||||
- 토큰 갱신 이력 기록
|
||||
operationId: refreshToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshTokenRequest'
|
||||
example:
|
||||
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
responses:
|
||||
'200':
|
||||
description: 토큰 갱신 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshTokenResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "토큰이 성공적으로 갱신되었습니다."
|
||||
data:
|
||||
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
expiresIn: 1800
|
||||
'401':
|
||||
description: Refresh Token 무효 또는 만료
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "REFRESH_TOKEN_INVALID"
|
||||
message: "Refresh Token이 유효하지 않습니다."
|
||||
details: "다시 로그인해주세요."
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/permissions:
|
||||
get:
|
||||
tags:
|
||||
- Authorization
|
||||
summary: 사용자 권한 조회
|
||||
description: |
|
||||
현재 사용자의 서비스 접근 권한을 조회합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- UFR-AUTH-020 유저스토리 구현
|
||||
- 사용자 권한 정보 조회
|
||||
- 서비스별 접근 권한 확인
|
||||
- Redis 캐시 우선 조회 (Cache-Aside 패턴)
|
||||
operationId: getUserPermissions
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 권한 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionsResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "권한 정보를 성공적으로 조회했습니다."
|
||||
data:
|
||||
userId: "mvno001"
|
||||
permissions:
|
||||
- permission: "BILL_INQUIRY"
|
||||
description: "요금 조회 서비스"
|
||||
granted: true
|
||||
- permission: "PRODUCT_CHANGE"
|
||||
description: "상품 변경 서비스"
|
||||
granted: true
|
||||
'401':
|
||||
description: 인증되지 않은 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/permissions/check:
|
||||
post:
|
||||
tags:
|
||||
- Authorization
|
||||
summary: 특정 서비스 접근 권한 확인
|
||||
description: |
|
||||
사용자가 특정 서비스에 접근할 권한이 있는지 확인합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- 서비스별 접근 권한 검증
|
||||
- BILL_INQUIRY: 요금 조회 서비스 권한
|
||||
- PRODUCT_CHANGE: 상품 변경 서비스 권한
|
||||
- Redis 세션 데이터 기반 권한 확인
|
||||
operationId: checkPermission
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionCheckRequest'
|
||||
example:
|
||||
serviceType: "BILL_INQUIRY"
|
||||
responses:
|
||||
'200':
|
||||
description: 권한 확인 완료
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionCheckResponse'
|
||||
examples:
|
||||
permission_granted:
|
||||
summary: 권한 있음
|
||||
value:
|
||||
success: true
|
||||
message: "서비스 접근 권한이 확인되었습니다."
|
||||
data:
|
||||
serviceType: "BILL_INQUIRY"
|
||||
hasPermission: true
|
||||
permissionDetails:
|
||||
permission: "BILL_INQUIRY"
|
||||
description: "요금 조회 서비스"
|
||||
granted: true
|
||||
permission_denied:
|
||||
summary: 권한 없음
|
||||
value:
|
||||
success: true
|
||||
message: "서비스 접근 권한이 없습니다."
|
||||
data:
|
||||
serviceType: "BILL_INQUIRY"
|
||||
hasPermission: false
|
||||
permissionDetails:
|
||||
permission: "BILL_INQUIRY"
|
||||
description: "요금 조회 서비스"
|
||||
granted: false
|
||||
'400':
|
||||
description: 잘못된 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "VALIDATION_ERROR"
|
||||
message: "serviceType은 필수 입력 항목입니다."
|
||||
details: "BILL_INQUIRY 또는 PRODUCT_CHANGE 값을 입력해주세요."
|
||||
'401':
|
||||
description: 인증되지 않은 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/user-info:
|
||||
get:
|
||||
tags:
|
||||
- Session Management
|
||||
summary: 사용자 정보 조회
|
||||
description: |
|
||||
현재 인증된 사용자의 상세 정보를 조회합니다.
|
||||
|
||||
## 비즈니스 로직
|
||||
- JWT 토큰에서 사용자 식별
|
||||
- Redis 세션 우선 조회
|
||||
- 캐시 미스 시 DB 조회 후 캐시 갱신
|
||||
- 사용자 기본 정보 및 권한 정보 반환
|
||||
operationId: getUserInfo
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 사용자 정보 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserInfoResponse'
|
||||
example:
|
||||
success: true
|
||||
message: "사용자 정보를 성공적으로 조회했습니다."
|
||||
data:
|
||||
userId: "mvno001"
|
||||
userName: "홍길동"
|
||||
phoneNumber: "010-1234-5678"
|
||||
email: "hong@example.com"
|
||||
status: "ACTIVE"
|
||||
lastLoginAt: "2024-01-15T09:30:00Z"
|
||||
permissions:
|
||||
- "BILL_INQUIRY"
|
||||
- "PRODUCT_CHANGE"
|
||||
'401':
|
||||
description: 인증되지 않은 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: 사용자 정보 없음
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "USER_NOT_FOUND"
|
||||
message: "사용자 정보를 찾을 수 없습니다."
|
||||
details: "해당 사용자가 존재하지 않습니다."
|
||||
'500':
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: "JWT 토큰을 Authorization 헤더에 포함해주세요. (예: Bearer eyJhbGciOiJIUzI1NiIs...)"
|
||||
|
||||
schemas:
|
||||
# Request Schemas
|
||||
LoginRequest:
|
||||
type: object
|
||||
required:
|
||||
- userId
|
||||
- password
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: 사용자 ID (고객 식별자)
|
||||
minLength: 3
|
||||
maxLength: 20
|
||||
pattern: '^[a-zA-Z0-9_-]+$'
|
||||
example: "mvno001"
|
||||
password:
|
||||
type: string
|
||||
description: 사용자 비밀번호
|
||||
format: password
|
||||
minLength: 8
|
||||
maxLength: 50
|
||||
example: "securePassword123!"
|
||||
autoLogin:
|
||||
type: boolean
|
||||
description: 자동 로그인 옵션 (true 시 24시간 세션 유지)
|
||||
default: false
|
||||
example: false
|
||||
|
||||
RefreshTokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- refreshToken
|
||||
properties:
|
||||
refreshToken:
|
||||
type: string
|
||||
description: JWT Refresh Token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
|
||||
PermissionCheckRequest:
|
||||
type: object
|
||||
required:
|
||||
- serviceType
|
||||
properties:
|
||||
serviceType:
|
||||
type: string
|
||||
description: 확인하려는 서비스 타입
|
||||
enum:
|
||||
- BILL_INQUIRY
|
||||
- PRODUCT_CHANGE
|
||||
example: "BILL_INQUIRY"
|
||||
|
||||
# Response Schemas
|
||||
LoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: 응답 성공 여부
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
description: 응답 메시지
|
||||
example: "로그인이 성공적으로 완료되었습니다."
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
accessToken:
|
||||
type: string
|
||||
description: JWT Access Token (30분 만료)
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
refreshToken:
|
||||
type: string
|
||||
description: JWT Refresh Token (24시간 만료)
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
expiresIn:
|
||||
type: integer
|
||||
description: Access Token 만료까지 남은 시간 (초)
|
||||
example: 1800
|
||||
user:
|
||||
$ref: '#/components/schemas/UserInfo'
|
||||
|
||||
TokenVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "토큰이 유효합니다."
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
description: 토큰 유효성
|
||||
example: true
|
||||
user:
|
||||
$ref: '#/components/schemas/UserInfo'
|
||||
expiresIn:
|
||||
type: integer
|
||||
description: 토큰 만료까지 남은 시간 (초)
|
||||
example: 1200
|
||||
|
||||
RefreshTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "토큰이 성공적으로 갱신되었습니다."
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
accessToken:
|
||||
type: string
|
||||
description: 새로 발급된 JWT Access Token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
expiresIn:
|
||||
type: integer
|
||||
description: 새 토큰 만료까지 남은 시간 (초)
|
||||
example: 1800
|
||||
|
||||
PermissionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "권한 정보를 성공적으로 조회했습니다."
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
example: "mvno001"
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Permission'
|
||||
|
||||
PermissionCheckResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "서비스 접근 권한이 확인되었습니다."
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
serviceType:
|
||||
type: string
|
||||
example: "BILL_INQUIRY"
|
||||
hasPermission:
|
||||
type: boolean
|
||||
example: true
|
||||
permissionDetails:
|
||||
$ref: '#/components/schemas/Permission'
|
||||
|
||||
UserInfoResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "사용자 정보를 성공적으로 조회했습니다."
|
||||
data:
|
||||
$ref: '#/components/schemas/UserInfoDetail'
|
||||
|
||||
SuccessResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "요청이 성공적으로 처리되었습니다."
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 오류 코드
|
||||
example: "AUTH_001"
|
||||
message:
|
||||
type: string
|
||||
description: 사용자에게 표시될 오류 메시지
|
||||
example: "ID 또는 비밀번호를 확인해주세요"
|
||||
details:
|
||||
type: string
|
||||
description: 상세 오류 정보
|
||||
example: "입력된 인증 정보가 올바르지 않습니다."
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 오류 발생 시간
|
||||
example: "2024-01-15T10:30:00Z"
|
||||
|
||||
# Common Schemas
|
||||
UserInfo:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: 사용자 ID
|
||||
example: "mvno001"
|
||||
userName:
|
||||
type: string
|
||||
description: 사용자 이름
|
||||
example: "홍길동"
|
||||
phoneNumber:
|
||||
type: string
|
||||
description: 휴대폰 번호
|
||||
example: "010-1234-5678"
|
||||
permissions:
|
||||
type: array
|
||||
description: 사용자 권한 목록
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- BILL_INQUIRY
|
||||
- PRODUCT_CHANGE
|
||||
example:
|
||||
- "BILL_INQUIRY"
|
||||
- "PRODUCT_CHANGE"
|
||||
|
||||
UserInfoDetail:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
example: "mvno001"
|
||||
userName:
|
||||
type: string
|
||||
example: "홍길동"
|
||||
phoneNumber:
|
||||
type: string
|
||||
example: "010-1234-5678"
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
example: "hong@example.com"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ACTIVE
|
||||
- INACTIVE
|
||||
- LOCKED
|
||||
example: "ACTIVE"
|
||||
lastLoginAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 마지막 로그인 시간
|
||||
example: "2024-01-15T09:30:00Z"
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- "BILL_INQUIRY"
|
||||
- "PRODUCT_CHANGE"
|
||||
|
||||
Permission:
|
||||
type: object
|
||||
properties:
|
||||
permission:
|
||||
type: string
|
||||
enum:
|
||||
- BILL_INQUIRY
|
||||
- PRODUCT_CHANGE
|
||||
example: "BILL_INQUIRY"
|
||||
description:
|
||||
type: string
|
||||
example: "요금 조회 서비스"
|
||||
granted:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
# API 오류 코드 정의
|
||||
# AUTH_001: 잘못된 인증 정보
|
||||
# AUTH_002: 계정 잠금 (5회 실패)
|
||||
# AUTH_003: 계정 일시 잠금
|
||||
# TOKEN_EXPIRED: 토큰 만료
|
||||
# TOKEN_INVALID: 유효하지 않은 토큰
|
||||
# REFRESH_TOKEN_INVALID: Refresh Token 무효
|
||||
# USER_NOT_FOUND: 사용자 정보 없음
|
||||
# UNAUTHORIZED: 인증 필요
|
||||
# VALIDATION_ERROR: 입력 데이터 검증 오류
|
||||
# INTERNAL_SERVER_ERROR: 서버 내부 오류
|
||||
847
design/backend/api/bill-inquiry-service-api.yaml
Normal file
847
design/backend/api/bill-inquiry-service-api.yaml
Normal file
@ -0,0 +1,847 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Bill-Inquiry Service API
|
||||
description: |
|
||||
통신요금 조회 서비스 API
|
||||
|
||||
## 주요 기능
|
||||
- 요금조회 메뉴 조회
|
||||
- 요금 조회 요청 처리
|
||||
- 요금 조회 결과 확인
|
||||
- 요금조회 이력 조회
|
||||
|
||||
## 외부 시스템 연동
|
||||
- KOS-Order: 실제 요금 데이터 조회
|
||||
- Redis Cache: 성능 최적화를 위한 캐싱
|
||||
- MVNO AP Server: 결과 전송
|
||||
|
||||
## 설계 원칙
|
||||
- Circuit Breaker 패턴: KOS 시스템 연동 시 장애 격리
|
||||
- Cache-Aside 패턴: 1시간 TTL 캐싱으로 성능 최적화
|
||||
- 비동기 이력 저장: 응답 성능에 영향 없는 이력 관리
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: 이개발/백엔더
|
||||
email: backend@mvno.com
|
||||
license:
|
||||
name: MIT
|
||||
servers:
|
||||
- url: https://api-dev.mvno.com
|
||||
description: Development server
|
||||
- url: https://api.mvno.com
|
||||
description: Production server
|
||||
|
||||
paths:
|
||||
/bills/menu:
|
||||
get:
|
||||
summary: 요금조회 메뉴 조회
|
||||
description: |
|
||||
UFR-BILL-010: 요금조회 메뉴 접근
|
||||
- 고객 회선번호 표시
|
||||
- 조회월 선택 옵션 제공
|
||||
- 요금 조회 신청 버튼 활성화
|
||||
tags:
|
||||
- Bill Inquiry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 요금조회 메뉴 정보
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillMenuResponse'
|
||||
example:
|
||||
success: true
|
||||
data:
|
||||
customerInfo:
|
||||
customerId: "CUST001"
|
||||
lineNumber: "010-1234-5678"
|
||||
availableMonths:
|
||||
- "2024-01"
|
||||
- "2024-02"
|
||||
- "2024-03"
|
||||
currentMonth: "2024-03"
|
||||
message: "요금조회 메뉴를 성공적으로 조회했습니다"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/bills/inquiry:
|
||||
post:
|
||||
summary: 요금 조회 요청
|
||||
description: |
|
||||
UFR-BILL-020: 요금조회 신청
|
||||
- 시나리오 1: 조회월 미선택 (당월 청구요금 조회)
|
||||
- 시나리오 2: 조회월 선택 (특정월 청구요금 조회)
|
||||
|
||||
## 처리 과정
|
||||
1. Cache-Aside 패턴으로 캐시 확인 (1시간 TTL)
|
||||
2. 캐시 Miss 시 KOS-Order 시스템 연동
|
||||
3. Circuit Breaker 패턴으로 장애 격리
|
||||
4. 결과를 MVNO AP Server로 전송
|
||||
5. 비동기 이력 저장
|
||||
tags:
|
||||
- Bill Inquiry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillInquiryRequest'
|
||||
examples:
|
||||
currentMonth:
|
||||
summary: 당월 요금 조회
|
||||
value:
|
||||
lineNumber: "010-1234-5678"
|
||||
specificMonth:
|
||||
summary: 특정월 요금 조회
|
||||
value:
|
||||
lineNumber: "010-1234-5678"
|
||||
inquiryMonth: "2024-02"
|
||||
responses:
|
||||
'200':
|
||||
description: 요금조회 요청 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillInquiryResponse'
|
||||
example:
|
||||
success: true
|
||||
data:
|
||||
requestId: "REQ_20240308_001"
|
||||
status: "COMPLETED"
|
||||
billInfo:
|
||||
productName: "5G 프리미엄 플랜"
|
||||
contractInfo: "24개월 약정"
|
||||
billingMonth: "2024-03"
|
||||
totalAmount: 89000
|
||||
discountInfo:
|
||||
- name: "가족할인"
|
||||
amount: 10000
|
||||
- name: "온라인할인"
|
||||
amount: 5000
|
||||
usage:
|
||||
voice: "300분"
|
||||
sms: "무제한"
|
||||
data: "100GB"
|
||||
terminationFee: 150000
|
||||
deviceInstallment: 45000
|
||||
paymentInfo:
|
||||
billingDate: "2024-03-25"
|
||||
paymentStatus: "PAID"
|
||||
paymentMethod: "자동이체"
|
||||
message: "요금조회가 완료되었습니다"
|
||||
'202':
|
||||
description: 요금조회 요청 접수 (비동기 처리 중)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillInquiryAsyncResponse'
|
||||
example:
|
||||
success: true
|
||||
data:
|
||||
requestId: "REQ_20240308_002"
|
||||
status: "PROCESSING"
|
||||
estimatedTime: "30초"
|
||||
message: "요금조회 요청이 접수되었습니다"
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
'503':
|
||||
description: KOS 시스템 장애 (Circuit Breaker Open)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "SERVICE_UNAVAILABLE"
|
||||
message: "일시적으로 서비스 이용이 어렵습니다"
|
||||
detail: "외부 시스템 연동 장애로 인한 서비스 제한"
|
||||
|
||||
/bills/inquiry/{requestId}:
|
||||
get:
|
||||
summary: 요금 조회 결과 확인
|
||||
description: |
|
||||
비동기로 처리된 요금조회 결과를 확인합니다.
|
||||
requestId를 통해 조회 상태와 결과를 반환합니다.
|
||||
tags:
|
||||
- Bill Inquiry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: requestId
|
||||
in: path
|
||||
required: true
|
||||
description: 요금조회 요청 ID
|
||||
schema:
|
||||
type: string
|
||||
example: "REQ_20240308_001"
|
||||
responses:
|
||||
'200':
|
||||
description: 요금조회 결과 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillInquiryStatusResponse'
|
||||
examples:
|
||||
completed:
|
||||
summary: 조회 완료
|
||||
value:
|
||||
success: true
|
||||
data:
|
||||
requestId: "REQ_20240308_001"
|
||||
status: "COMPLETED"
|
||||
billInfo:
|
||||
productName: "5G 프리미엄 플랜"
|
||||
contractInfo: "24개월 약정"
|
||||
billingMonth: "2024-03"
|
||||
totalAmount: 89000
|
||||
discountInfo:
|
||||
- name: "가족할인"
|
||||
amount: 10000
|
||||
usage:
|
||||
voice: "300분"
|
||||
sms: "무제한"
|
||||
data: "100GB"
|
||||
terminationFee: 150000
|
||||
deviceInstallment: 45000
|
||||
paymentInfo:
|
||||
billingDate: "2024-03-25"
|
||||
paymentStatus: "PAID"
|
||||
paymentMethod: "자동이체"
|
||||
message: "요금조회 결과를 조회했습니다"
|
||||
processing:
|
||||
summary: 처리 중
|
||||
value:
|
||||
success: true
|
||||
data:
|
||||
requestId: "REQ_20240308_002"
|
||||
status: "PROCESSING"
|
||||
progress: 75
|
||||
message: "요금조회를 처리중입니다"
|
||||
failed:
|
||||
summary: 조회 실패
|
||||
value:
|
||||
success: false
|
||||
data:
|
||||
requestId: "REQ_20240308_003"
|
||||
status: "FAILED"
|
||||
errorMessage: "KOS 시스템 연동 실패"
|
||||
message: "요금조회에 실패했습니다"
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFoundError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/bills/history:
|
||||
get:
|
||||
summary: 요금조회 이력 조회
|
||||
description: |
|
||||
UFR-BILL-040: 요금조회 결과 전송 및 이력 관리
|
||||
- 요금 조회 요청 이력: MVNO → MP
|
||||
- 요금 조회 처리 이력: MP → KOS
|
||||
tags:
|
||||
- Bill Inquiry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: lineNumber
|
||||
in: query
|
||||
description: 회선번호 (미입력시 인증된 사용자의 모든 회선)
|
||||
schema:
|
||||
type: string
|
||||
example: "010-1234-5678"
|
||||
- name: startDate
|
||||
in: query
|
||||
description: 조회 시작일 (YYYY-MM-DD)
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
example: "2024-01-01"
|
||||
- name: endDate
|
||||
in: query
|
||||
description: 조회 종료일 (YYYY-MM-DD)
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
example: "2024-03-31"
|
||||
- name: page
|
||||
in: query
|
||||
description: 페이지 번호 (1부터 시작)
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
example: 1
|
||||
- name: size
|
||||
in: query
|
||||
description: 페이지 크기
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
maximum: 100
|
||||
example: 20
|
||||
- name: status
|
||||
in: query
|
||||
description: 처리 상태 필터
|
||||
schema:
|
||||
type: string
|
||||
enum: [COMPLETED, PROCESSING, FAILED]
|
||||
example: "COMPLETED"
|
||||
responses:
|
||||
'200':
|
||||
description: 요금조회 이력 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BillHistoryResponse'
|
||||
example:
|
||||
success: true
|
||||
data:
|
||||
items:
|
||||
- requestId: "REQ_20240308_001"
|
||||
lineNumber: "010-1234-5678"
|
||||
inquiryMonth: "2024-03"
|
||||
requestTime: "2024-03-08T10:30:00Z"
|
||||
processTime: "2024-03-08T10:30:15Z"
|
||||
status: "COMPLETED"
|
||||
resultSummary: "5G 프리미엄 플랜, 89,000원"
|
||||
- requestId: "REQ_20240307_045"
|
||||
lineNumber: "010-1234-5678"
|
||||
inquiryMonth: "2024-02"
|
||||
requestTime: "2024-03-07T15:20:00Z"
|
||||
processTime: "2024-03-07T15:20:12Z"
|
||||
status: "COMPLETED"
|
||||
resultSummary: "5G 프리미엄 플랜, 87,500원"
|
||||
pagination:
|
||||
currentPage: 1
|
||||
totalPages: 3
|
||||
totalItems: 45
|
||||
pageSize: 20
|
||||
hasNext: true
|
||||
hasPrevious: false
|
||||
message: "요금조회 이력을 조회했습니다"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Auth Service에서 발급된 JWT 토큰
|
||||
|
||||
schemas:
|
||||
BillMenuResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
$ref: '#/components/schemas/BillMenuData'
|
||||
message:
|
||||
type: string
|
||||
example: "요금조회 메뉴를 성공적으로 조회했습니다"
|
||||
|
||||
BillMenuData:
|
||||
type: object
|
||||
required:
|
||||
- customerInfo
|
||||
- availableMonths
|
||||
- currentMonth
|
||||
properties:
|
||||
customerInfo:
|
||||
$ref: '#/components/schemas/CustomerInfo'
|
||||
availableMonths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: date
|
||||
description: 조회 가능한 월 (YYYY-MM 형식)
|
||||
example: ["2024-01", "2024-02", "2024-03"]
|
||||
currentMonth:
|
||||
type: string
|
||||
format: date
|
||||
description: 현재 월 (기본 조회 대상)
|
||||
example: "2024-03"
|
||||
|
||||
CustomerInfo:
|
||||
type: object
|
||||
required:
|
||||
- customerId
|
||||
- lineNumber
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
description: 고객 ID
|
||||
example: "CUST001"
|
||||
lineNumber:
|
||||
type: string
|
||||
pattern: '^010-\d{4}-\d{4}$'
|
||||
description: 고객 회선번호
|
||||
example: "010-1234-5678"
|
||||
|
||||
BillInquiryRequest:
|
||||
type: object
|
||||
required:
|
||||
- lineNumber
|
||||
properties:
|
||||
lineNumber:
|
||||
type: string
|
||||
pattern: '^010-\d{4}-\d{4}$'
|
||||
description: 조회할 회선번호
|
||||
example: "010-1234-5678"
|
||||
inquiryMonth:
|
||||
type: string
|
||||
pattern: '^\d{4}-\d{2}$'
|
||||
description: 조회월 (YYYY-MM 형식, 미입력시 당월 조회)
|
||||
example: "2024-02"
|
||||
|
||||
BillInquiryResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
$ref: '#/components/schemas/BillInquiryData'
|
||||
message:
|
||||
type: string
|
||||
example: "요금조회가 완료되었습니다"
|
||||
|
||||
BillInquiryData:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- status
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
description: 요금조회 요청 ID
|
||||
example: "REQ_20240308_001"
|
||||
status:
|
||||
type: string
|
||||
enum: [COMPLETED, PROCESSING, FAILED]
|
||||
description: 처리 상태
|
||||
example: "COMPLETED"
|
||||
billInfo:
|
||||
$ref: '#/components/schemas/BillInfo'
|
||||
|
||||
BillInquiryAsyncResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- status
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
description: 요금조회 요청 ID
|
||||
example: "REQ_20240308_002"
|
||||
status:
|
||||
type: string
|
||||
enum: [PROCESSING]
|
||||
description: 처리 상태
|
||||
example: "PROCESSING"
|
||||
estimatedTime:
|
||||
type: string
|
||||
description: 예상 처리 시간
|
||||
example: "30초"
|
||||
message:
|
||||
type: string
|
||||
example: "요금조회 요청이 접수되었습니다"
|
||||
|
||||
BillInfo:
|
||||
type: object
|
||||
description: KOS-Order 시스템에서 조회된 요금 정보
|
||||
required:
|
||||
- productName
|
||||
- billingMonth
|
||||
- totalAmount
|
||||
properties:
|
||||
productName:
|
||||
type: string
|
||||
description: 현재 이용 중인 요금제
|
||||
example: "5G 프리미엄 플랜"
|
||||
contractInfo:
|
||||
type: string
|
||||
description: 계약 약정 조건
|
||||
example: "24개월 약정"
|
||||
billingMonth:
|
||||
type: string
|
||||
pattern: '^\d{4}-\d{2}$'
|
||||
description: 요금 청구 월
|
||||
example: "2024-03"
|
||||
totalAmount:
|
||||
type: integer
|
||||
description: 청구 요금 금액 (원)
|
||||
example: 89000
|
||||
discountInfo:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiscountInfo'
|
||||
description: 적용된 할인 내역
|
||||
usage:
|
||||
$ref: '#/components/schemas/UsageInfo'
|
||||
terminationFee:
|
||||
type: integer
|
||||
description: 중도 해지 시 비용 (원)
|
||||
example: 150000
|
||||
deviceInstallment:
|
||||
type: integer
|
||||
description: 단말기 할부 잔액 (원)
|
||||
example: 45000
|
||||
paymentInfo:
|
||||
$ref: '#/components/schemas/PaymentInfo'
|
||||
|
||||
DiscountInfo:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- amount
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 할인 명칭
|
||||
example: "가족할인"
|
||||
amount:
|
||||
type: integer
|
||||
description: 할인 금액 (원)
|
||||
example: 10000
|
||||
|
||||
UsageInfo:
|
||||
type: object
|
||||
required:
|
||||
- voice
|
||||
- sms
|
||||
- data
|
||||
properties:
|
||||
voice:
|
||||
type: string
|
||||
description: 통화 사용량
|
||||
example: "300분"
|
||||
sms:
|
||||
type: string
|
||||
description: SMS 사용량
|
||||
example: "무제한"
|
||||
data:
|
||||
type: string
|
||||
description: 데이터 사용량
|
||||
example: "100GB"
|
||||
|
||||
PaymentInfo:
|
||||
type: object
|
||||
required:
|
||||
- billingDate
|
||||
- paymentStatus
|
||||
- paymentMethod
|
||||
properties:
|
||||
billingDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 요금 청구일
|
||||
example: "2024-03-25"
|
||||
paymentStatus:
|
||||
type: string
|
||||
enum: [PAID, UNPAID, OVERDUE]
|
||||
description: 납부 상태
|
||||
example: "PAID"
|
||||
paymentMethod:
|
||||
type: string
|
||||
description: 납부 방법
|
||||
example: "자동이체"
|
||||
|
||||
BillInquiryStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
$ref: '#/components/schemas/BillInquiryStatusData'
|
||||
message:
|
||||
type: string
|
||||
example: "요금조회 결과를 조회했습니다"
|
||||
|
||||
BillInquiryStatusData:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- status
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
description: 요금조회 요청 ID
|
||||
example: "REQ_20240308_001"
|
||||
status:
|
||||
type: string
|
||||
enum: [COMPLETED, PROCESSING, FAILED]
|
||||
description: 처리 상태
|
||||
example: "COMPLETED"
|
||||
progress:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: 처리 진행률 (PROCESSING 상태일 때)
|
||||
example: 75
|
||||
billInfo:
|
||||
$ref: '#/components/schemas/BillInfo'
|
||||
errorMessage:
|
||||
type: string
|
||||
description: 오류 메시지 (FAILED 상태일 때)
|
||||
example: "KOS 시스템 연동 실패"
|
||||
|
||||
BillHistoryResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- message
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
$ref: '#/components/schemas/BillHistoryData'
|
||||
message:
|
||||
type: string
|
||||
example: "요금조회 이력을 조회했습니다"
|
||||
|
||||
BillHistoryData:
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
- pagination
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BillHistoryItem'
|
||||
pagination:
|
||||
$ref: '#/components/schemas/PaginationInfo'
|
||||
|
||||
BillHistoryItem:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- lineNumber
|
||||
- requestTime
|
||||
- status
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
description: 요금조회 요청 ID
|
||||
example: "REQ_20240308_001"
|
||||
lineNumber:
|
||||
type: string
|
||||
description: 회선번호
|
||||
example: "010-1234-5678"
|
||||
inquiryMonth:
|
||||
type: string
|
||||
pattern: '^\d{4}-\d{2}$'
|
||||
description: 조회월
|
||||
example: "2024-03"
|
||||
requestTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 요청일시
|
||||
example: "2024-03-08T10:30:00Z"
|
||||
processTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 처리일시
|
||||
example: "2024-03-08T10:30:15Z"
|
||||
status:
|
||||
type: string
|
||||
enum: [COMPLETED, PROCESSING, FAILED]
|
||||
description: 처리 결과
|
||||
example: "COMPLETED"
|
||||
resultSummary:
|
||||
type: string
|
||||
description: 결과 요약
|
||||
example: "5G 프리미엄 플랜, 89,000원"
|
||||
|
||||
PaginationInfo:
|
||||
type: object
|
||||
required:
|
||||
- currentPage
|
||||
- totalPages
|
||||
- totalItems
|
||||
- pageSize
|
||||
- hasNext
|
||||
- hasPrevious
|
||||
properties:
|
||||
currentPage:
|
||||
type: integer
|
||||
description: 현재 페이지
|
||||
example: 1
|
||||
totalPages:
|
||||
type: integer
|
||||
description: 전체 페이지 수
|
||||
example: 3
|
||||
totalItems:
|
||||
type: integer
|
||||
description: 전체 항목 수
|
||||
example: 45
|
||||
pageSize:
|
||||
type: integer
|
||||
description: 페이지 크기
|
||||
example: 20
|
||||
hasNext:
|
||||
type: boolean
|
||||
description: 다음 페이지 존재 여부
|
||||
example: true
|
||||
hasPrevious:
|
||||
type: boolean
|
||||
description: 이전 페이지 존재 여부
|
||||
example: false
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- error
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
$ref: '#/components/schemas/ErrorDetail'
|
||||
|
||||
ErrorDetail:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 오류 코드
|
||||
example: "VALIDATION_ERROR"
|
||||
message:
|
||||
type: string
|
||||
description: 오류 메시지
|
||||
example: "요청 데이터가 올바르지 않습니다"
|
||||
detail:
|
||||
type: string
|
||||
description: 상세 오류 정보
|
||||
example: "lineNumber 필드는 필수입니다"
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 오류 발생 시간
|
||||
example: "2024-03-08T10:30:00Z"
|
||||
|
||||
responses:
|
||||
BadRequestError:
|
||||
description: 잘못된 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "VALIDATION_ERROR"
|
||||
message: "요청 데이터가 올바르지 않습니다"
|
||||
detail: "lineNumber 필드는 필수입니다"
|
||||
|
||||
UnauthorizedError:
|
||||
description: 인증 실패
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "UNAUTHORIZED"
|
||||
message: "인증이 필요합니다"
|
||||
detail: "유효한 JWT 토큰을 제공해주세요"
|
||||
|
||||
ForbiddenError:
|
||||
description: 권한 부족
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "FORBIDDEN"
|
||||
message: "서비스 이용 권한이 없습니다"
|
||||
detail: "요금조회 서비스에 대한 접근 권한이 필요합니다"
|
||||
|
||||
NotFoundError:
|
||||
description: 리소스를 찾을 수 없음
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "NOT_FOUND"
|
||||
message: "요청한 데이터를 찾을 수 없습니다"
|
||||
detail: "해당 requestId에 대한 조회 결과가 없습니다"
|
||||
|
||||
InternalServerError:
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "INTERNAL_SERVER_ERROR"
|
||||
message: "서버 내부 오류가 발생했습니다"
|
||||
detail: "잠시 후 다시 시도해주세요"
|
||||
|
||||
tags:
|
||||
- name: Bill Inquiry
|
||||
description: 요금조회 관련 API
|
||||
externalDocs:
|
||||
description: 외부시퀀스설계서 - 요금조회플로우
|
||||
url: "design/backend/sequence/outer/요금조회플로우.puml"
|
||||
|
||||
externalDocs:
|
||||
description: 통신요금 관리 서비스 유저스토리
|
||||
url: "design/userstory.md"
|
||||
943
design/backend/api/product-change-service-api.yaml
Normal file
943
design/backend/api/product-change-service-api.yaml
Normal file
@ -0,0 +1,943 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Product-Change Service API
|
||||
description: |
|
||||
통신요금 관리 서비스 중 상품변경 서비스 API
|
||||
|
||||
## 주요 기능
|
||||
- 상품변경 메뉴 조회 (UFR-PROD-010)
|
||||
- 상품변경 화면 데이터 조회 (UFR-PROD-020)
|
||||
- 상품변경 요청 및 사전체크 (UFR-PROD-030)
|
||||
- KOS 연동 상품변경 처리 (UFR-PROD-040)
|
||||
|
||||
## 설계 원칙
|
||||
- KOS 시스템 연동 고려
|
||||
- 사전체크 단계 포함
|
||||
- 상태 관리 (진행중/완료/실패)
|
||||
- 트랜잭션 처리 고려
|
||||
- Circuit Breaker 패턴 적용
|
||||
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Backend Development Team
|
||||
email: backend@mvno.com
|
||||
|
||||
servers:
|
||||
- url: https://api.mvno.com/v1/product-change
|
||||
description: Production Server
|
||||
- url: https://api-dev.mvno.com/v1/product-change
|
||||
description: Development Server
|
||||
|
||||
tags:
|
||||
- name: menu
|
||||
description: 상품변경 메뉴 관련 API
|
||||
- name: customer
|
||||
description: 고객 정보 관련 API
|
||||
- name: product
|
||||
description: 상품 정보 관련 API
|
||||
- name: change
|
||||
description: 상품변경 처리 관련 API
|
||||
- name: history
|
||||
description: 상품변경 이력 관련 API
|
||||
|
||||
paths:
|
||||
/products/menu:
|
||||
get:
|
||||
tags:
|
||||
- menu
|
||||
summary: 상품변경 메뉴 조회
|
||||
description: |
|
||||
상품변경 메뉴 접근 시 필요한 기본 정보를 조회합니다.
|
||||
- UFR-PROD-010 구현
|
||||
- 고객 회선번호 및 기본 정보 제공
|
||||
- 캐시를 활용한 성능 최적화
|
||||
operationId: getProductMenu
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: 메뉴 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductMenuResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/customer/{lineNumber}:
|
||||
get:
|
||||
tags:
|
||||
- customer
|
||||
summary: 고객 정보 조회
|
||||
description: |
|
||||
특정 회선번호의 고객 정보와 현재 상품 정보를 조회합니다.
|
||||
- UFR-PROD-020 구현
|
||||
- KOS 시스템 연동
|
||||
- Redis 캐시 활용 (TTL: 4시간)
|
||||
operationId: getCustomerInfo
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: lineNumber
|
||||
in: path
|
||||
required: true
|
||||
description: 고객 회선번호
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^010[0-9]{8}$'
|
||||
example: "01012345678"
|
||||
responses:
|
||||
'200':
|
||||
description: 고객 정보 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerInfoResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'404':
|
||||
description: 고객 정보를 찾을 수 없음
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/available:
|
||||
get:
|
||||
tags:
|
||||
- product
|
||||
summary: 변경 가능한 상품 목록 조회
|
||||
description: |
|
||||
현재 판매중이고 변경 가능한 상품 목록을 조회합니다.
|
||||
- UFR-PROD-020 구현
|
||||
- KOS 시스템 연동
|
||||
- Redis 캐시 활용 (TTL: 24시간)
|
||||
operationId: getAvailableProducts
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: currentProductCode
|
||||
in: query
|
||||
required: false
|
||||
description: 현재 상품코드 (필터링용)
|
||||
schema:
|
||||
type: string
|
||||
example: "PLAN001"
|
||||
- name: operatorCode
|
||||
in: query
|
||||
required: false
|
||||
description: 사업자 코드
|
||||
schema:
|
||||
type: string
|
||||
example: "MVNO001"
|
||||
responses:
|
||||
'200':
|
||||
description: 상품 목록 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AvailableProductsResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/change/validation:
|
||||
post:
|
||||
tags:
|
||||
- change
|
||||
summary: 상품변경 사전체크
|
||||
description: |
|
||||
상품변경 요청 전 사전체크를 수행합니다.
|
||||
- UFR-PROD-030 구현
|
||||
- 판매중인 상품 확인
|
||||
- 사업자 일치 확인
|
||||
- 회선 사용상태 확인
|
||||
operationId: validateProductChange
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 사전체크 완료 (성공/실패 포함)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeValidationResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/change:
|
||||
post:
|
||||
tags:
|
||||
- change
|
||||
summary: 상품변경 요청
|
||||
description: |
|
||||
실제 상품변경 처리를 요청합니다.
|
||||
- UFR-PROD-040 구현
|
||||
- KOS 시스템 연동
|
||||
- Circuit Breaker 패턴 적용
|
||||
- 비동기 이력 저장
|
||||
operationId: requestProductChange
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 상품변경 처리 완료
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeResponse'
|
||||
'202':
|
||||
description: 상품변경 요청 접수 (비동기 처리)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeAsyncResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'409':
|
||||
description: 사전체크 실패 또는 처리 불가 상태
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeFailureResponse'
|
||||
'503':
|
||||
description: KOS 시스템 장애 (Circuit Breaker Open)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/change/{requestId}:
|
||||
get:
|
||||
tags:
|
||||
- change
|
||||
summary: 상품변경 결과 조회
|
||||
description: |
|
||||
특정 요청ID의 상품변경 처리 결과를 조회합니다.
|
||||
- 비동기 처리 결과 조회
|
||||
- 상태별 상세 정보 제공
|
||||
operationId: getProductChangeResult
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: requestId
|
||||
in: path
|
||||
required: true
|
||||
description: 상품변경 요청 ID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
responses:
|
||||
'200':
|
||||
description: 처리 결과 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeResultResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'404':
|
||||
description: 요청 정보를 찾을 수 없음
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/products/history:
|
||||
get:
|
||||
tags:
|
||||
- history
|
||||
summary: 상품변경 이력 조회
|
||||
description: |
|
||||
고객의 상품변경 이력을 조회합니다.
|
||||
- UFR-PROD-040 구현 (이력 관리)
|
||||
- 페이징 지원
|
||||
- 기간별 필터링 지원
|
||||
operationId: getProductChangeHistory
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: lineNumber
|
||||
in: query
|
||||
required: false
|
||||
description: 회선번호 (미입력시 로그인 고객 기준)
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^010[0-9]{8}$'
|
||||
- name: startDate
|
||||
in: query
|
||||
required: false
|
||||
description: 조회 시작일 (YYYY-MM-DD)
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
example: "2024-01-01"
|
||||
- name: endDate
|
||||
in: query
|
||||
required: false
|
||||
description: 조회 종료일 (YYYY-MM-DD)
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
example: "2024-12-31"
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
description: 페이지 번호 (1부터 시작)
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: size
|
||||
in: query
|
||||
required: false
|
||||
description: 페이지 크기
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 10
|
||||
responses:
|
||||
'200':
|
||||
description: 이력 조회 성공
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductChangeHistoryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT 토큰을 Authorization 헤더에 포함
|
||||
|
||||
schemas:
|
||||
ProductMenuResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- customerId
|
||||
- lineNumber
|
||||
- menuItems
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
description: 고객 ID
|
||||
example: "CUST001"
|
||||
lineNumber:
|
||||
type: string
|
||||
description: 고객 회선번호
|
||||
example: "01012345678"
|
||||
currentProduct:
|
||||
$ref: '#/components/schemas/ProductInfo'
|
||||
menuItems:
|
||||
type: array
|
||||
description: 메뉴 항목들
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
menuId:
|
||||
type: string
|
||||
example: "MENU001"
|
||||
menuName:
|
||||
type: string
|
||||
example: "상품변경"
|
||||
available:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
CustomerInfoResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
$ref: '#/components/schemas/CustomerInfo'
|
||||
|
||||
CustomerInfo:
|
||||
type: object
|
||||
required:
|
||||
- customerId
|
||||
- lineNumber
|
||||
- customerName
|
||||
- currentProduct
|
||||
- lineStatus
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
description: 고객 ID
|
||||
example: "CUST001"
|
||||
lineNumber:
|
||||
type: string
|
||||
description: 회선번호
|
||||
example: "01012345678"
|
||||
customerName:
|
||||
type: string
|
||||
description: 고객명
|
||||
example: "홍길동"
|
||||
currentProduct:
|
||||
$ref: '#/components/schemas/ProductInfo'
|
||||
lineStatus:
|
||||
type: string
|
||||
description: 회선 상태
|
||||
enum: [ACTIVE, SUSPENDED, TERMINATED]
|
||||
example: "ACTIVE"
|
||||
contractInfo:
|
||||
type: object
|
||||
properties:
|
||||
contractDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 계약일
|
||||
termEndDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 약정 만료일
|
||||
earlyTerminationFee:
|
||||
type: number
|
||||
description: 예상 해지비용
|
||||
example: 150000
|
||||
|
||||
AvailableProductsResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- products
|
||||
properties:
|
||||
products:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductInfo'
|
||||
totalCount:
|
||||
type: integer
|
||||
description: 전체 상품 수
|
||||
example: 15
|
||||
|
||||
ProductInfo:
|
||||
type: object
|
||||
required:
|
||||
- productCode
|
||||
- productName
|
||||
- monthlyFee
|
||||
- isAvailable
|
||||
properties:
|
||||
productCode:
|
||||
type: string
|
||||
description: 상품 코드
|
||||
example: "PLAN001"
|
||||
productName:
|
||||
type: string
|
||||
description: 상품명
|
||||
example: "5G 프리미엄 플랜"
|
||||
monthlyFee:
|
||||
type: number
|
||||
description: 월 요금
|
||||
example: 55000
|
||||
dataAllowance:
|
||||
type: string
|
||||
description: 데이터 제공량
|
||||
example: "100GB"
|
||||
voiceAllowance:
|
||||
type: string
|
||||
description: 음성 제공량
|
||||
example: "무제한"
|
||||
smsAllowance:
|
||||
type: string
|
||||
description: SMS 제공량
|
||||
example: "기본 무료"
|
||||
isAvailable:
|
||||
type: boolean
|
||||
description: 변경 가능 여부
|
||||
example: true
|
||||
operatorCode:
|
||||
type: string
|
||||
description: 사업자 코드
|
||||
example: "MVNO001"
|
||||
|
||||
ProductChangeValidationRequest:
|
||||
type: object
|
||||
required:
|
||||
- lineNumber
|
||||
- currentProductCode
|
||||
- targetProductCode
|
||||
properties:
|
||||
lineNumber:
|
||||
type: string
|
||||
description: 회선번호
|
||||
pattern: '^010[0-9]{8}$'
|
||||
example: "01012345678"
|
||||
currentProductCode:
|
||||
type: string
|
||||
description: 현재 상품 코드
|
||||
example: "PLAN001"
|
||||
targetProductCode:
|
||||
type: string
|
||||
description: 변경 대상 상품 코드
|
||||
example: "PLAN002"
|
||||
|
||||
ProductChangeValidationResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- validationResult
|
||||
properties:
|
||||
validationResult:
|
||||
type: string
|
||||
enum: [SUCCESS, FAILURE]
|
||||
example: "SUCCESS"
|
||||
validationDetails:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
checkType:
|
||||
type: string
|
||||
enum: [PRODUCT_AVAILABLE, OPERATOR_MATCH, LINE_STATUS]
|
||||
example: "PRODUCT_AVAILABLE"
|
||||
result:
|
||||
type: string
|
||||
enum: [PASS, FAIL]
|
||||
example: "PASS"
|
||||
message:
|
||||
type: string
|
||||
example: "현재 판매중인 상품입니다"
|
||||
failureReason:
|
||||
type: string
|
||||
description: 실패 사유 (실패 시에만)
|
||||
example: "회선이 정지 상태입니다"
|
||||
|
||||
ProductChangeRequest:
|
||||
type: object
|
||||
required:
|
||||
- lineNumber
|
||||
- currentProductCode
|
||||
- targetProductCode
|
||||
properties:
|
||||
lineNumber:
|
||||
type: string
|
||||
description: 회선번호
|
||||
pattern: '^010[0-9]{8}$'
|
||||
example: "01012345678"
|
||||
currentProductCode:
|
||||
type: string
|
||||
description: 현재 상품 코드
|
||||
example: "PLAN001"
|
||||
targetProductCode:
|
||||
type: string
|
||||
description: 변경 대상 상품 코드
|
||||
example: "PLAN002"
|
||||
requestDate:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 요청 일시
|
||||
example: "2024-03-15T10:30:00Z"
|
||||
changeEffectiveDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 변경 적용일 (선택)
|
||||
example: "2024-03-16"
|
||||
|
||||
ProductChangeResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- processStatus
|
||||
- resultCode
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 요청 ID
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
processStatus:
|
||||
type: string
|
||||
enum: [COMPLETED, FAILED]
|
||||
example: "COMPLETED"
|
||||
resultCode:
|
||||
type: string
|
||||
description: 처리 결과 코드
|
||||
example: "SUCCESS"
|
||||
resultMessage:
|
||||
type: string
|
||||
description: 처리 결과 메시지
|
||||
example: "상품 변경이 완료되었습니다"
|
||||
changedProduct:
|
||||
$ref: '#/components/schemas/ProductInfo'
|
||||
processedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 처리 완료 시간
|
||||
example: "2024-03-15T10:35:00Z"
|
||||
|
||||
ProductChangeAsyncResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- processStatus
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 요청 ID
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
processStatus:
|
||||
type: string
|
||||
enum: [PENDING, PROCESSING]
|
||||
example: "PROCESSING"
|
||||
estimatedCompletionTime:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 예상 완료 시간
|
||||
example: "2024-03-15T10:35:00Z"
|
||||
message:
|
||||
type: string
|
||||
example: "상품 변경이 진행되었습니다"
|
||||
|
||||
ProductChangeFailureResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- error
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [VALIDATION_FAILED, CHANGE_DENIED, LINE_SUSPENDED]
|
||||
example: "VALIDATION_FAILED"
|
||||
message:
|
||||
type: string
|
||||
example: "상품 사전 체크에 실패하였습니다"
|
||||
details:
|
||||
type: string
|
||||
description: 상세 실패 사유
|
||||
example: "회선이 정지 상태입니다"
|
||||
|
||||
ProductChangeResultResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- processStatus
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
lineNumber:
|
||||
type: string
|
||||
example: "01012345678"
|
||||
processStatus:
|
||||
type: string
|
||||
enum: [PENDING, PROCESSING, COMPLETED, FAILED]
|
||||
example: "COMPLETED"
|
||||
currentProductCode:
|
||||
type: string
|
||||
example: "PLAN001"
|
||||
targetProductCode:
|
||||
type: string
|
||||
example: "PLAN002"
|
||||
requestedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2024-03-15T10:30:00Z"
|
||||
processedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2024-03-15T10:35:00Z"
|
||||
resultCode:
|
||||
type: string
|
||||
example: "SUCCESS"
|
||||
resultMessage:
|
||||
type: string
|
||||
example: "상품 변경이 완료되었습니다"
|
||||
failureReason:
|
||||
type: string
|
||||
description: 실패 사유 (실패 시에만)
|
||||
|
||||
ProductChangeHistoryResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- history
|
||||
- pagination
|
||||
properties:
|
||||
history:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductChangeHistoryItem'
|
||||
pagination:
|
||||
$ref: '#/components/schemas/PaginationInfo'
|
||||
|
||||
ProductChangeHistoryItem:
|
||||
type: object
|
||||
required:
|
||||
- requestId
|
||||
- lineNumber
|
||||
- processStatus
|
||||
- requestedAt
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
lineNumber:
|
||||
type: string
|
||||
example: "01012345678"
|
||||
processStatus:
|
||||
type: string
|
||||
enum: [PENDING, PROCESSING, COMPLETED, FAILED]
|
||||
example: "COMPLETED"
|
||||
currentProductCode:
|
||||
type: string
|
||||
example: "PLAN001"
|
||||
currentProductName:
|
||||
type: string
|
||||
example: "5G 베이직 플랜"
|
||||
targetProductCode:
|
||||
type: string
|
||||
example: "PLAN002"
|
||||
targetProductName:
|
||||
type: string
|
||||
example: "5G 프리미엄 플랜"
|
||||
requestedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2024-03-15T10:30:00Z"
|
||||
processedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2024-03-15T10:35:00Z"
|
||||
resultMessage:
|
||||
type: string
|
||||
example: "상품 변경이 완료되었습니다"
|
||||
|
||||
PaginationInfo:
|
||||
type: object
|
||||
required:
|
||||
- page
|
||||
- size
|
||||
- totalElements
|
||||
- totalPages
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
description: 현재 페이지 번호
|
||||
example: 1
|
||||
size:
|
||||
type: integer
|
||||
description: 페이지 크기
|
||||
example: 10
|
||||
totalElements:
|
||||
type: integer
|
||||
description: 전체 요소 수
|
||||
example: 45
|
||||
totalPages:
|
||||
type: integer
|
||||
description: 전체 페이지 수
|
||||
example: 5
|
||||
hasNext:
|
||||
type: boolean
|
||||
description: 다음 페이지 존재 여부
|
||||
example: true
|
||||
hasPrevious:
|
||||
type: boolean
|
||||
description: 이전 페이지 존재 여부
|
||||
example: false
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- error
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: false
|
||||
error:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: "INVALID_REQUEST"
|
||||
message:
|
||||
type: string
|
||||
example: "요청이 올바르지 않습니다"
|
||||
details:
|
||||
type: string
|
||||
description: 상세 오류 정보
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2024-03-15T10:30:00Z"
|
||||
path:
|
||||
type: string
|
||||
description: 요청 경로
|
||||
example: "/products/change"
|
||||
|
||||
responses:
|
||||
BadRequestError:
|
||||
description: 잘못된 요청
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "INVALID_REQUEST"
|
||||
message: "요청 파라미터가 올바르지 않습니다"
|
||||
timestamp: "2024-03-15T10:30:00Z"
|
||||
|
||||
UnauthorizedError:
|
||||
description: 인증 실패
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "UNAUTHORIZED"
|
||||
message: "인증이 필요합니다"
|
||||
timestamp: "2024-03-15T10:30:00Z"
|
||||
|
||||
ForbiddenError:
|
||||
description: 권한 없음
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "FORBIDDEN"
|
||||
message: "서비스 이용 권한이 없습니다"
|
||||
timestamp: "2024-03-15T10:30:00Z"
|
||||
|
||||
InternalServerError:
|
||||
description: 서버 내부 오류
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
success: false
|
||||
error:
|
||||
code: "INTERNAL_SERVER_ERROR"
|
||||
message: "서버 내부 오류가 발생했습니다"
|
||||
timestamp: "2024-03-15T10:30:00Z"
|
||||
215
design/backend/class/auth-simple.puml
Normal file
215
design/backend/class/auth-simple.puml
Normal file
@ -0,0 +1,215 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
title Auth Service - Simple Class Design
|
||||
|
||||
package "com.unicorn.phonebill.auth" {
|
||||
|
||||
package "controller" {
|
||||
class AuthController {
|
||||
+login()
|
||||
+logout()
|
||||
+verifyToken()
|
||||
+refreshToken()
|
||||
+getUserPermissions()
|
||||
+checkPermission()
|
||||
+getUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
package "dto" {
|
||||
class LoginRequest
|
||||
class LoginResponse
|
||||
class RefreshTokenRequest
|
||||
class RefreshTokenResponse
|
||||
class TokenVerifyResponse
|
||||
class PermissionCheckRequest
|
||||
class PermissionCheckResponse
|
||||
class PermissionsResponse
|
||||
class UserInfoResponse
|
||||
class UserInfo
|
||||
class Permission
|
||||
class SuccessResponse
|
||||
}
|
||||
|
||||
package "service" {
|
||||
interface AuthService
|
||||
class AuthServiceImpl
|
||||
interface TokenService
|
||||
class TokenServiceImpl
|
||||
interface PermissionService
|
||||
class PermissionServiceImpl
|
||||
}
|
||||
|
||||
package "domain" {
|
||||
class User
|
||||
enum UserStatus
|
||||
class UserSession
|
||||
class AuthenticationResult
|
||||
class DecodedToken
|
||||
class PermissionResult
|
||||
class TokenRefreshResult
|
||||
class UserInfoDetail
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface UserRepository
|
||||
interface UserPermissionRepository
|
||||
interface LoginHistoryRepository
|
||||
|
||||
package "entity" {
|
||||
class UserEntity
|
||||
class UserPermissionEntity
|
||||
class LoginHistoryEntity
|
||||
}
|
||||
|
||||
package "jpa" {
|
||||
interface UserJpaRepository
|
||||
interface UserPermissionJpaRepository
|
||||
interface LoginHistoryJpaRepository
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class SecurityConfig
|
||||
class JwtConfig
|
||||
class RedisConfig
|
||||
}
|
||||
}
|
||||
|
||||
' Common Base Classes
|
||||
package "Common Module" <<External>> {
|
||||
class ApiResponse<T>
|
||||
class ErrorResponse
|
||||
abstract class BaseTimeEntity
|
||||
enum ErrorCode
|
||||
class BusinessException
|
||||
}
|
||||
|
||||
' 관계 정의 (간단화)
|
||||
AuthController --> AuthService
|
||||
AuthController --> TokenService
|
||||
|
||||
AuthServiceImpl --> UserRepository
|
||||
AuthServiceImpl --> TokenService
|
||||
AuthServiceImpl --> PermissionService
|
||||
AuthServiceImpl --> LoginHistoryRepository
|
||||
|
||||
PermissionServiceImpl --> UserPermissionRepository
|
||||
|
||||
UserRepository --> UserEntity
|
||||
UserPermissionRepository --> UserPermissionEntity
|
||||
LoginHistoryRepository --> LoginHistoryEntity
|
||||
|
||||
UserEntity --|> BaseTimeEntity
|
||||
UserPermissionEntity --|> BaseTimeEntity
|
||||
LoginHistoryEntity --|> BaseTimeEntity
|
||||
|
||||
AuthService <|-- AuthServiceImpl
|
||||
TokenService <|-- TokenServiceImpl
|
||||
PermissionService <|-- PermissionServiceImpl
|
||||
|
||||
UserRepository <|-- UserJpaRepository
|
||||
UserPermissionRepository <|-- UserPermissionJpaRepository
|
||||
LoginHistoryRepository <|-- LoginHistoryJpaRepository
|
||||
|
||||
User --> UserStatus
|
||||
|
||||
' API 매핑표
|
||||
note as N1
|
||||
<b>AuthController API Mapping</b>
|
||||
===
|
||||
<b>POST /auth/login</b>
|
||||
- Method: login(LoginRequest)
|
||||
- Response: ApiResponse<LoginResponse>
|
||||
- Description: 사용자 로그인 처리
|
||||
|
||||
<b>POST /auth/logout</b>
|
||||
- Method: logout()
|
||||
- Response: ApiResponse<SuccessResponse>
|
||||
- Description: 사용자 로그아웃 처리
|
||||
|
||||
<b>GET /auth/verify</b>
|
||||
- Method: verifyToken()
|
||||
- Response: ApiResponse<TokenVerifyResponse>
|
||||
- Description: JWT 토큰 검증
|
||||
|
||||
<b>POST /auth/refresh</b>
|
||||
- Method: refreshToken(RefreshTokenRequest)
|
||||
- Response: ApiResponse<RefreshTokenResponse>
|
||||
- Description: 토큰 갱신
|
||||
|
||||
<b>GET /auth/permissions</b>
|
||||
- Method: getUserPermissions()
|
||||
- Response: ApiResponse<PermissionsResponse>
|
||||
- Description: 사용자 권한 조회
|
||||
|
||||
<b>POST /auth/permissions/check</b>
|
||||
- Method: checkPermission(PermissionCheckRequest)
|
||||
- Response: ApiResponse<PermissionCheckResponse>
|
||||
- Description: 특정 서비스 접근 권한 확인
|
||||
|
||||
<b>GET /auth/user-info</b>
|
||||
- Method: getUserInfo()
|
||||
- Response: ApiResponse<UserInfoResponse>
|
||||
- Description: 사용자 정보 조회
|
||||
end note
|
||||
|
||||
N1 .. AuthController
|
||||
|
||||
' 패키지 구조 설명
|
||||
note as N2
|
||||
<b>패키지 구조 (Layered Architecture)</b>
|
||||
===
|
||||
<b>controller</b>
|
||||
- AuthController: REST API 엔드포인트
|
||||
|
||||
<b>dto</b>
|
||||
- Request/Response 객체들
|
||||
- API 계층과 Service 계층 간 데이터 전송
|
||||
|
||||
<b>service</b>
|
||||
- AuthService: 인증/인가 비즈니스 로직
|
||||
- TokenService: JWT 토큰 관리
|
||||
- PermissionService: 권한 관리
|
||||
|
||||
<b>domain</b>
|
||||
- 도메인 모델 및 비즈니스 엔티티
|
||||
- 비즈니스 로직 포함
|
||||
|
||||
<b>repository</b>
|
||||
- 데이터 접근 계층
|
||||
- entity: JPA 엔티티
|
||||
- jpa: JPA Repository 인터페이스
|
||||
|
||||
<b>config</b>
|
||||
- 설정 클래스들 (Security, JWT, Redis)
|
||||
end note
|
||||
|
||||
N2 .. "com.unicorn.phonebill.auth"
|
||||
|
||||
' 핵심 기능 설명
|
||||
note as N3
|
||||
<b>핵심 기능</b>
|
||||
===
|
||||
<b>인증 (Authentication)</b>
|
||||
- 로그인/로그아웃 처리
|
||||
- JWT 토큰 생성/검증/갱신
|
||||
- 세션 관리 (Redis 캐시)
|
||||
- 로그인 실패 횟수 관리 (5회 실패 시 30분 잠금)
|
||||
|
||||
<b>인가 (Authorization)</b>
|
||||
- 서비스별 접근 권한 확인
|
||||
- 권한 캐싱 (Redis, TTL: 4시간)
|
||||
- Cache-Aside 패턴 적용
|
||||
|
||||
<b>보안</b>
|
||||
- bcrypt 패스워드 해싱
|
||||
- JWT 토큰 기반 인증
|
||||
- Redis 세션 캐싱 (TTL: 30분/24시간)
|
||||
- IP 기반 로그인 이력 추적
|
||||
end note
|
||||
|
||||
N3 .. AuthServiceImpl
|
||||
|
||||
@enduml
|
||||
564
design/backend/class/auth.puml
Normal file
564
design/backend/class/auth.puml
Normal file
@ -0,0 +1,564 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
title Auth Service - Detailed Class Design
|
||||
|
||||
package "com.unicorn.phonebill.auth" {
|
||||
|
||||
package "controller" {
|
||||
class AuthController {
|
||||
-authService: AuthService
|
||||
-tokenService: TokenService
|
||||
|
||||
+login(request: LoginRequest): ApiResponse<LoginResponse>
|
||||
+logout(): ApiResponse<SuccessResponse>
|
||||
+verifyToken(): ApiResponse<TokenVerifyResponse>
|
||||
+refreshToken(request: RefreshTokenRequest): ApiResponse<RefreshTokenResponse>
|
||||
+getUserPermissions(): ApiResponse<PermissionsResponse>
|
||||
+checkPermission(request: PermissionCheckRequest): ApiResponse<PermissionCheckResponse>
|
||||
+getUserInfo(): ApiResponse<UserInfoResponse>
|
||||
}
|
||||
}
|
||||
|
||||
package "dto" {
|
||||
class LoginRequest {
|
||||
-userId: String
|
||||
-password: String
|
||||
-autoLogin: boolean
|
||||
|
||||
+getUserId(): String
|
||||
+getPassword(): String
|
||||
+isAutoLogin(): boolean
|
||||
+validate(): void
|
||||
}
|
||||
|
||||
class LoginResponse {
|
||||
-accessToken: String
|
||||
-refreshToken: String
|
||||
-expiresIn: int
|
||||
-user: UserInfo
|
||||
|
||||
+getAccessToken(): String
|
||||
+getRefreshToken(): String
|
||||
+getExpiresIn(): int
|
||||
+getUser(): UserInfo
|
||||
}
|
||||
|
||||
class RefreshTokenRequest {
|
||||
-refreshToken: String
|
||||
|
||||
+getRefreshToken(): String
|
||||
+validate(): void
|
||||
}
|
||||
|
||||
class RefreshTokenResponse {
|
||||
-accessToken: String
|
||||
-expiresIn: int
|
||||
|
||||
+getAccessToken(): String
|
||||
+getExpiresIn(): int
|
||||
}
|
||||
|
||||
class TokenVerifyResponse {
|
||||
-valid: boolean
|
||||
-user: UserInfo
|
||||
-expiresIn: int
|
||||
|
||||
+isValid(): boolean
|
||||
+getUser(): UserInfo
|
||||
+getExpiresIn(): int
|
||||
}
|
||||
|
||||
class PermissionCheckRequest {
|
||||
-serviceType: String
|
||||
|
||||
+getServiceType(): String
|
||||
+validate(): void
|
||||
}
|
||||
|
||||
class PermissionCheckResponse {
|
||||
-serviceType: String
|
||||
-hasPermission: boolean
|
||||
-permissionDetails: Permission
|
||||
|
||||
+getServiceType(): String
|
||||
+isHasPermission(): boolean
|
||||
+getPermissionDetails(): Permission
|
||||
}
|
||||
|
||||
class PermissionsResponse {
|
||||
-userId: String
|
||||
-permissions: List<Permission>
|
||||
|
||||
+getUserId(): String
|
||||
+getPermissions(): List<Permission>
|
||||
}
|
||||
|
||||
class UserInfoResponse {
|
||||
-userId: String
|
||||
-userName: String
|
||||
-phoneNumber: String
|
||||
-email: String
|
||||
-status: String
|
||||
-lastLoginAt: LocalDateTime
|
||||
-permissions: List<String>
|
||||
|
||||
+getUserId(): String
|
||||
+getUserName(): String
|
||||
+getPhoneNumber(): String
|
||||
+getEmail(): String
|
||||
+getStatus(): String
|
||||
+getLastLoginAt(): LocalDateTime
|
||||
+getPermissions(): List<String>
|
||||
}
|
||||
|
||||
class UserInfo {
|
||||
-userId: String
|
||||
-userName: String
|
||||
-phoneNumber: String
|
||||
-permissions: List<String>
|
||||
|
||||
+getUserId(): String
|
||||
+getUserName(): String
|
||||
+getPhoneNumber(): String
|
||||
+getPermissions(): List<String>
|
||||
}
|
||||
|
||||
class Permission {
|
||||
-permission: String
|
||||
-description: String
|
||||
-granted: boolean
|
||||
|
||||
+getPermission(): String
|
||||
+getDescription(): String
|
||||
+isGranted(): boolean
|
||||
}
|
||||
|
||||
class SuccessResponse {
|
||||
-message: String
|
||||
|
||||
+getMessage(): String
|
||||
}
|
||||
}
|
||||
|
||||
package "service" {
|
||||
interface AuthService {
|
||||
+authenticateUser(userId: String, password: String): AuthenticationResult
|
||||
+getUserInfo(userId: String): UserInfoDetail
|
||||
+refreshUserToken(userId: String): TokenRefreshResult
|
||||
+checkServicePermission(userId: String, serviceType: String): PermissionResult
|
||||
+invalidateUserPermissions(userId: String): void
|
||||
}
|
||||
|
||||
class AuthServiceImpl {
|
||||
-userRepository: UserRepository
|
||||
-tokenService: TokenService
|
||||
-permissionService: PermissionService
|
||||
-redisTemplate: RedisTemplate
|
||||
-passwordEncoder: PasswordEncoder
|
||||
-loginHistoryRepository: LoginHistoryRepository
|
||||
|
||||
+authenticateUser(userId: String, password: String): AuthenticationResult
|
||||
+getUserInfo(userId: String): UserInfoDetail
|
||||
+refreshUserToken(userId: String): TokenRefreshResult
|
||||
+checkServicePermission(userId: String, serviceType: String): PermissionResult
|
||||
+invalidateUserPermissions(userId: String): void
|
||||
-validateLoginAttempts(user: User): void
|
||||
-handleFailedLogin(userId: String): void
|
||||
-handleSuccessfulLogin(user: User): void
|
||||
-createUserSession(user: User, autoLogin: boolean): void
|
||||
-saveLoginHistory(userId: String, ipAddress: String): void
|
||||
}
|
||||
|
||||
interface TokenService {
|
||||
+generateAccessToken(userInfo: UserInfoDetail): String
|
||||
+generateRefreshToken(userId: String): String
|
||||
+validateAccessToken(token: String): DecodedToken
|
||||
+validateRefreshToken(token: String): boolean
|
||||
+extractUserId(token: String): String
|
||||
+getTokenExpiration(token: String): LocalDateTime
|
||||
}
|
||||
|
||||
class TokenServiceImpl {
|
||||
-jwtSecret: String
|
||||
-accessTokenExpiry: int
|
||||
-refreshTokenExpiry: int
|
||||
|
||||
+generateAccessToken(userInfo: UserInfoDetail): String
|
||||
+generateRefreshToken(userId: String): String
|
||||
+validateAccessToken(token: String): DecodedToken
|
||||
+validateRefreshToken(token: String): boolean
|
||||
+extractUserId(token: String): String
|
||||
+getTokenExpiration(token: String): LocalDateTime
|
||||
-createJwtToken(subject: String, claims: Map<String, Object>, expiry: int): String
|
||||
-parseJwtToken(token: String): Claims
|
||||
}
|
||||
|
||||
interface PermissionService {
|
||||
+validateServiceAccess(permissions: List<String>, serviceType: String): PermissionResult
|
||||
+getUserPermissions(userId: String): List<Permission>
|
||||
+cacheUserPermissions(userId: String, permissions: List<Permission>): void
|
||||
+invalidateUserPermissions(userId: String): void
|
||||
}
|
||||
|
||||
class PermissionServiceImpl {
|
||||
-userPermissionRepository: UserPermissionRepository
|
||||
-redisTemplate: RedisTemplate
|
||||
|
||||
+validateServiceAccess(permissions: List<String>, serviceType: String): PermissionResult
|
||||
+getUserPermissions(userId: String): List<Permission>
|
||||
+cacheUserPermissions(userId: String, permissions: List<Permission>): void
|
||||
+invalidateUserPermissions(userId: String): void
|
||||
-mapServiceTypeToPermission(serviceType: String): String
|
||||
-checkPermissionGranted(permissions: List<String>, requiredPermission: String): boolean
|
||||
}
|
||||
}
|
||||
|
||||
package "domain" {
|
||||
class User {
|
||||
-userId: String
|
||||
-userName: String
|
||||
-phoneNumber: String
|
||||
-email: String
|
||||
-passwordHash: String
|
||||
-salt: String
|
||||
-status: UserStatus
|
||||
-loginAttemptCount: int
|
||||
-lockedUntil: LocalDateTime
|
||||
-lastLoginAt: LocalDateTime
|
||||
-createdAt: LocalDateTime
|
||||
-updatedAt: LocalDateTime
|
||||
|
||||
+getUserId(): String
|
||||
+getUserName(): String
|
||||
+getPhoneNumber(): String
|
||||
+getEmail(): String
|
||||
+getPasswordHash(): String
|
||||
+getSalt(): String
|
||||
+getStatus(): UserStatus
|
||||
+getLoginAttemptCount(): int
|
||||
+getLockedUntil(): LocalDateTime
|
||||
+getLastLoginAt(): LocalDateTime
|
||||
+isAccountLocked(): boolean
|
||||
+canAttemptLogin(): boolean
|
||||
+incrementLoginAttempt(): void
|
||||
+resetLoginAttempt(): void
|
||||
+lockAccount(duration: Duration): void
|
||||
+updateLastLoginAt(loginTime: LocalDateTime): void
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
LOCKED
|
||||
|
||||
+getValue(): String
|
||||
}
|
||||
|
||||
class UserSession {
|
||||
-userId: String
|
||||
-sessionId: String
|
||||
-userInfo: UserInfoDetail
|
||||
-permissions: List<String>
|
||||
-lastAccessTime: LocalDateTime
|
||||
-createdAt: LocalDateTime
|
||||
-ttl: Duration
|
||||
|
||||
+getUserId(): String
|
||||
+getSessionId(): String
|
||||
+getUserInfo(): UserInfoDetail
|
||||
+getPermissions(): List<String>
|
||||
+getLastAccessTime(): LocalDateTime
|
||||
+getCreatedAt(): LocalDateTime
|
||||
+getTtl(): Duration
|
||||
+updateLastAccessTime(): void
|
||||
+isExpired(): boolean
|
||||
}
|
||||
|
||||
class AuthenticationResult {
|
||||
-success: boolean
|
||||
-accessToken: String
|
||||
-refreshToken: String
|
||||
-userInfo: UserInfoDetail
|
||||
-errorMessage: String
|
||||
|
||||
+isSuccess(): boolean
|
||||
+getAccessToken(): String
|
||||
+getRefreshToken(): String
|
||||
+getUserInfo(): UserInfoDetail
|
||||
+getErrorMessage(): String
|
||||
}
|
||||
|
||||
class DecodedToken {
|
||||
-userId: String
|
||||
-permissions: List<String>
|
||||
-expiresAt: LocalDateTime
|
||||
-issuedAt: LocalDateTime
|
||||
|
||||
+getUserId(): String
|
||||
+getPermissions(): List<String>
|
||||
+getExpiresAt(): LocalDateTime
|
||||
+getIssuedAt(): LocalDateTime
|
||||
+isExpired(): boolean
|
||||
}
|
||||
|
||||
class PermissionResult {
|
||||
-granted: boolean
|
||||
-serviceType: String
|
||||
-reason: String
|
||||
-permissionDetails: Permission
|
||||
|
||||
+isGranted(): boolean
|
||||
+getServiceType(): String
|
||||
+getReason(): String
|
||||
+getPermissionDetails(): Permission
|
||||
}
|
||||
|
||||
class TokenRefreshResult {
|
||||
-newAccessToken: String
|
||||
-expiresIn: int
|
||||
|
||||
+getNewAccessToken(): String
|
||||
+getExpiresIn(): int
|
||||
}
|
||||
|
||||
class UserInfoDetail {
|
||||
-userId: String
|
||||
-userName: String
|
||||
-phoneNumber: String
|
||||
-email: String
|
||||
-status: UserStatus
|
||||
-lastLoginAt: LocalDateTime
|
||||
-permissions: List<String>
|
||||
|
||||
+getUserId(): String
|
||||
+getUserName(): String
|
||||
+getPhoneNumber(): String
|
||||
+getEmail(): String
|
||||
+getStatus(): UserStatus
|
||||
+getLastLoginAt(): LocalDateTime
|
||||
+getPermissions(): List<String>
|
||||
}
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface UserRepository {
|
||||
+findUserById(userId: String): Optional<User>
|
||||
+save(user: User): User
|
||||
+incrementLoginAttempt(userId: String): void
|
||||
+resetLoginAttempt(userId: String): void
|
||||
+lockAccount(userId: String, duration: Duration): void
|
||||
+updateLastLoginAt(userId: String, loginTime: LocalDateTime): void
|
||||
}
|
||||
|
||||
interface UserPermissionRepository {
|
||||
+findPermissionsByUserId(userId: String): List<UserPermission>
|
||||
+save(userPermission: UserPermission): UserPermission
|
||||
+deleteByUserId(userId: String): void
|
||||
}
|
||||
|
||||
interface LoginHistoryRepository {
|
||||
+save(loginHistory: LoginHistory): LoginHistory
|
||||
+findByUserIdOrderByLoginTimeDesc(userId: String, pageable: Pageable): List<LoginHistory>
|
||||
}
|
||||
|
||||
package "entity" {
|
||||
class UserEntity {
|
||||
-id: Long
|
||||
-userId: String
|
||||
-userName: String
|
||||
-phoneNumber: String
|
||||
-email: String
|
||||
-passwordHash: String
|
||||
-salt: String
|
||||
-status: String
|
||||
-loginAttemptCount: int
|
||||
-lockedUntil: LocalDateTime
|
||||
-lastLoginAt: LocalDateTime
|
||||
-createdAt: LocalDateTime
|
||||
-updatedAt: LocalDateTime
|
||||
|
||||
+getId(): Long
|
||||
+getUserId(): String
|
||||
+getUserName(): String
|
||||
+getPhoneNumber(): String
|
||||
+getEmail(): String
|
||||
+getPasswordHash(): String
|
||||
+getSalt(): String
|
||||
+getStatus(): String
|
||||
+getLoginAttemptCount(): int
|
||||
+getLockedUntil(): LocalDateTime
|
||||
+getLastLoginAt(): LocalDateTime
|
||||
+getCreatedAt(): LocalDateTime
|
||||
+getUpdatedAt(): LocalDateTime
|
||||
+toDomain(): User
|
||||
}
|
||||
|
||||
class UserPermissionEntity {
|
||||
-id: Long
|
||||
-userId: String
|
||||
-permissionCode: String
|
||||
-status: String
|
||||
-createdAt: LocalDateTime
|
||||
-updatedAt: LocalDateTime
|
||||
|
||||
+getId(): Long
|
||||
+getUserId(): String
|
||||
+getPermissionCode(): String
|
||||
+getStatus(): String
|
||||
+getCreatedAt(): LocalDateTime
|
||||
+getUpdatedAt(): LocalDateTime
|
||||
+toDomain(): UserPermission
|
||||
}
|
||||
|
||||
class LoginHistoryEntity {
|
||||
-id: Long
|
||||
-userId: String
|
||||
-loginTime: LocalDateTime
|
||||
-ipAddress: String
|
||||
-userAgent: String
|
||||
-success: boolean
|
||||
-failureReason: String
|
||||
-createdAt: LocalDateTime
|
||||
|
||||
+getId(): Long
|
||||
+getUserId(): String
|
||||
+getLoginTime(): LocalDateTime
|
||||
+getIpAddress(): String
|
||||
+getUserAgent(): String
|
||||
+isSuccess(): boolean
|
||||
+getFailureReason(): String
|
||||
+getCreatedAt(): LocalDateTime
|
||||
+toDomain(): LoginHistory
|
||||
}
|
||||
}
|
||||
|
||||
package "jpa" {
|
||||
interface UserJpaRepository {
|
||||
+findByUserId(userId: String): Optional<UserEntity>
|
||||
+save(userEntity: UserEntity): UserEntity
|
||||
+existsByUserId(userId: String): boolean
|
||||
}
|
||||
|
||||
interface UserPermissionJpaRepository {
|
||||
+findByUserIdAndStatus(userId: String, status: String): List<UserPermissionEntity>
|
||||
+save(userPermissionEntity: UserPermissionEntity): UserPermissionEntity
|
||||
+deleteByUserId(userId: String): void
|
||||
}
|
||||
|
||||
interface LoginHistoryJpaRepository {
|
||||
+save(loginHistoryEntity: LoginHistoryEntity): LoginHistoryEntity
|
||||
+findByUserIdOrderByLoginTimeDesc(userId: String, pageable: Pageable): List<LoginHistoryEntity>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class SecurityConfig {
|
||||
-jwtSecret: String
|
||||
-accessTokenExpiry: int
|
||||
-refreshTokenExpiry: int
|
||||
|
||||
+passwordEncoder(): PasswordEncoder
|
||||
+corsConfigurationSource(): CorsConfigurationSource
|
||||
+filterChain(http: HttpSecurity): SecurityFilterChain
|
||||
+authenticationManager(): AuthenticationManager
|
||||
}
|
||||
|
||||
class JwtConfig {
|
||||
-secret: String
|
||||
-accessTokenExpiry: int
|
||||
-refreshTokenExpiry: int
|
||||
|
||||
+getSecret(): String
|
||||
+getAccessTokenExpiry(): int
|
||||
+getRefreshTokenExpiry(): int
|
||||
+jwtEncoder(): JwtEncoder
|
||||
+jwtDecoder(): JwtDecoder
|
||||
}
|
||||
|
||||
class RedisConfig {
|
||||
-host: String
|
||||
-port: int
|
||||
-password: String
|
||||
-database: int
|
||||
|
||||
+redisConnectionFactory(): RedisConnectionFactory
|
||||
+redisTemplate(): RedisTemplate<String, Object>
|
||||
+cacheManager(): RedisCacheManager
|
||||
+sessionRedisTemplate(): RedisTemplate<String, UserSession>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' Common Base Classes 사용
|
||||
package "Common Module" <<External>> {
|
||||
class ApiResponse<T>
|
||||
class ErrorResponse
|
||||
abstract class BaseTimeEntity
|
||||
enum ErrorCode
|
||||
class BusinessException
|
||||
}
|
||||
|
||||
' 관계 정의
|
||||
AuthController --> AuthService : uses
|
||||
AuthController --> TokenService : uses
|
||||
AuthController ..> LoginRequest : uses
|
||||
AuthController ..> LoginResponse : creates
|
||||
AuthController ..> UserInfoResponse : creates
|
||||
AuthController ..> PermissionCheckResponse : creates
|
||||
|
||||
AuthServiceImpl --> UserRepository : uses
|
||||
AuthServiceImpl --> TokenService : uses
|
||||
AuthServiceImpl --> PermissionService : uses
|
||||
AuthServiceImpl --> LoginHistoryRepository : uses
|
||||
|
||||
TokenServiceImpl ..> DecodedToken : creates
|
||||
TokenServiceImpl ..> AuthenticationResult : creates
|
||||
|
||||
PermissionServiceImpl --> UserPermissionRepository : uses
|
||||
|
||||
UserRepository --> UserEntity : works with
|
||||
UserPermissionRepository --> UserPermissionEntity : works with
|
||||
LoginHistoryRepository --> LoginHistoryEntity : works with
|
||||
|
||||
UserRepository --> User : returns
|
||||
UserPermissionRepository --> UserPermission : returns
|
||||
LoginHistoryRepository --> LoginHistory : returns
|
||||
|
||||
UserEntity ..> User : converts to
|
||||
UserPermissionEntity ..> UserPermission : converts to
|
||||
LoginHistoryEntity ..> LoginHistory : converts to
|
||||
|
||||
UserJpaRepository --> UserEntity : manages
|
||||
UserPermissionJpaRepository --> UserPermissionEntity : manages
|
||||
LoginHistoryJpaRepository --> LoginHistoryEntity : manages
|
||||
|
||||
User --> UserStatus : has
|
||||
UserSession --> UserInfoDetail : contains
|
||||
|
||||
AuthServiceImpl ..> AuthenticationResult : creates
|
||||
AuthServiceImpl ..> UserInfoDetail : creates
|
||||
PermissionServiceImpl ..> PermissionResult : creates
|
||||
|
||||
' Inheritance
|
||||
UserEntity --|> BaseTimeEntity
|
||||
UserPermissionEntity --|> BaseTimeEntity
|
||||
LoginHistoryEntity --|> BaseTimeEntity
|
||||
|
||||
AuthService <|-- AuthServiceImpl : implements
|
||||
TokenService <|-- TokenServiceImpl : implements
|
||||
PermissionService <|-- PermissionServiceImpl : implements
|
||||
|
||||
UserRepository <|-- UserJpaRepository : implements
|
||||
UserPermissionRepository <|-- UserPermissionJpaRepository : implements
|
||||
LoginHistoryRepository <|-- LoginHistoryJpaRepository : implements
|
||||
|
||||
' Notes
|
||||
note top of AuthController : "REST API 엔드포인트 제공\n- 로그인/로그아웃\n- 토큰 검증/갱신\n- 권한 확인"
|
||||
note top of AuthServiceImpl : "인증/인가 비즈니스 로직\n- 사용자 인증 처리\n- 세션 관리\n- 권한 검증"
|
||||
note top of TokenServiceImpl : "JWT 토큰 관리\n- 토큰 생성/검증\n- 페이로드 추출"
|
||||
note top of UserEntity : "사용자 정보 저장\n- 로그인 시도 횟수 관리\n- 계정 잠금 처리"
|
||||
note top of RedisConfig : "Redis 캐시 설정\n- 세션 캐싱\n- 권한 캐싱"
|
||||
|
||||
@enduml
|
||||
138
design/backend/class/bill-inquiry-simple.puml
Normal file
138
design/backend/class/bill-inquiry-simple.puml
Normal file
@ -0,0 +1,138 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
title Bill-Inquiry Service - 간단한 클래스 설계
|
||||
|
||||
package "com.unicorn.phonebill.bill" {
|
||||
|
||||
package "controller" {
|
||||
class BillController {
|
||||
-billService: BillService
|
||||
-jwtTokenUtil: JwtTokenUtil
|
||||
}
|
||||
|
||||
note right of BillController : "API 매핑표\n\nGET /bills/menu → getBillMenu()\nPOST /bills/inquiry → inquireBill()\nGET /bills/inquiry/{requestId} → getBillInquiryStatus()\nGET /bills/history → getBillHistory()\n\n모든 메소드는 JWT 인증 필요\nController에는 API로 정의된 메소드만 존재"
|
||||
}
|
||||
|
||||
package "dto" {
|
||||
class BillMenuData
|
||||
class CustomerInfo
|
||||
class BillInquiryRequest
|
||||
class BillInquiryData
|
||||
class BillInquiryAsyncData
|
||||
class BillInquiryStatusData
|
||||
class BillHistoryData
|
||||
class BillHistoryItem
|
||||
class PaginationInfo
|
||||
}
|
||||
|
||||
package "service" {
|
||||
interface BillService
|
||||
class BillServiceImpl
|
||||
interface KosClientService
|
||||
class KosClientServiceImpl
|
||||
interface BillCacheService
|
||||
class BillCacheServiceImpl
|
||||
interface KosAdapterService
|
||||
class KosAdapterServiceImpl
|
||||
interface CircuitBreakerService
|
||||
class CircuitBreakerServiceImpl
|
||||
interface RetryService
|
||||
class RetryServiceImpl
|
||||
interface MvnoApiClient
|
||||
class MvnoApiClientImpl
|
||||
}
|
||||
|
||||
package "domain" {
|
||||
class BillInfo
|
||||
class DiscountInfo
|
||||
class UsageInfo
|
||||
class PaymentInfo
|
||||
class KosRequest
|
||||
class KosResponse
|
||||
class KosData
|
||||
class KosUsage
|
||||
class KosPaymentInfo
|
||||
class MvnoRequest
|
||||
enum CircuitState
|
||||
enum BillInquiryStatus
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface BillHistoryRepository
|
||||
interface KosInquiryHistoryRepository
|
||||
|
||||
package "entity" {
|
||||
class BillHistoryEntity
|
||||
class KosInquiryHistoryEntity
|
||||
}
|
||||
|
||||
package "jpa" {
|
||||
interface BillHistoryJpaRepository
|
||||
interface KosInquiryHistoryJpaRepository
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class RestTemplateConfig
|
||||
class BillCacheConfig
|
||||
class KosConfig
|
||||
class MvnoConfig
|
||||
class CircuitBreakerConfig
|
||||
class AsyncConfig
|
||||
class JwtTokenUtil
|
||||
}
|
||||
}
|
||||
|
||||
' 관계 설정
|
||||
' Controller Layer
|
||||
BillController --> BillService : "uses"
|
||||
BillController --> JwtTokenUtil : "uses"
|
||||
|
||||
' Service Layer Relationships
|
||||
BillServiceImpl ..|> BillService : "implements"
|
||||
BillServiceImpl --> BillCacheService : "uses"
|
||||
BillServiceImpl --> KosClientService : "uses"
|
||||
BillServiceImpl --> BillHistoryRepository : "uses"
|
||||
BillServiceImpl --> MvnoApiClient : "uses"
|
||||
|
||||
KosClientServiceImpl ..|> KosClientService : "implements"
|
||||
KosClientServiceImpl --> KosAdapterService : "uses"
|
||||
KosClientServiceImpl --> CircuitBreakerService : "uses"
|
||||
KosClientServiceImpl --> RetryService : "uses"
|
||||
KosClientServiceImpl --> KosInquiryHistoryRepository : "uses"
|
||||
|
||||
BillCacheServiceImpl ..|> BillCacheService : "implements"
|
||||
BillCacheServiceImpl --> BillHistoryRepository : "uses"
|
||||
|
||||
KosAdapterServiceImpl ..|> KosAdapterService : "implements"
|
||||
KosAdapterServiceImpl --> KosConfig : "uses"
|
||||
|
||||
CircuitBreakerServiceImpl ..|> CircuitBreakerService : "implements"
|
||||
RetryServiceImpl ..|> RetryService : "implements"
|
||||
MvnoApiClientImpl ..|> MvnoApiClient : "implements"
|
||||
|
||||
' Domain Relationships
|
||||
BillInfo --> DiscountInfo : "contains"
|
||||
BillInfo --> UsageInfo : "contains"
|
||||
BillInfo --> PaymentInfo : "contains"
|
||||
KosResponse --> KosData : "contains"
|
||||
KosData --> KosUsage : "contains"
|
||||
KosData --> KosPaymentInfo : "contains"
|
||||
MvnoRequest --> BillInfo : "contains"
|
||||
|
||||
' Repository Relationships
|
||||
BillHistoryRepository --> BillHistoryJpaRepository : "uses"
|
||||
KosInquiryHistoryRepository --> KosInquiryHistoryJpaRepository : "uses"
|
||||
|
||||
' Entity Relationships
|
||||
BillHistoryEntity --|> BaseTimeEntity : "extends"
|
||||
KosInquiryHistoryEntity --|> BaseTimeEntity : "extends"
|
||||
|
||||
' DTO Relationships
|
||||
BillMenuData --> CustomerInfo : "contains"
|
||||
BillInquiryData --> BillInfo : "contains"
|
||||
BillInquiryStatusData --> BillInfo : "contains"
|
||||
BillHistoryData --> BillHistoryItem : "contains"
|
||||
BillHistoryData --> PaginationInfo : "contains"
|
||||
|
||||
@enduml
|
||||
676
design/backend/class/bill-inquiry.puml
Normal file
676
design/backend/class/bill-inquiry.puml
Normal file
@ -0,0 +1,676 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
title Bill-Inquiry Service - 상세 클래스 설계
|
||||
|
||||
' 패키지별 클래스 구조
|
||||
package "com.unicorn.phonebill.bill" {
|
||||
|
||||
package "controller" {
|
||||
class BillController {
|
||||
-billService: BillService
|
||||
-jwtTokenUtil: JwtTokenUtil
|
||||
+getBillMenu(authorization: String): ResponseEntity<ApiResponse<BillMenuData>>
|
||||
+inquireBill(request: BillInquiryRequest, authorization: String): ResponseEntity<ApiResponse<BillInquiryData>>
|
||||
+getBillInquiryStatus(requestId: String, authorization: String): ResponseEntity<ApiResponse<BillInquiryStatusData>>
|
||||
+getBillHistory(lineNumber: String, startDate: String, endDate: String, page: int, size: int, status: String, authorization: String): ResponseEntity<ApiResponse<BillHistoryData>>
|
||||
-extractUserInfoFromToken(authorization: String): JwtTokenVerifyDTO
|
||||
-validateRequestParameters(request: Object): void
|
||||
}
|
||||
}
|
||||
|
||||
package "dto" {
|
||||
' API Request/Response DTOs
|
||||
class BillMenuData {
|
||||
-customerInfo: CustomerInfo
|
||||
-availableMonths: List<String>
|
||||
-currentMonth: String
|
||||
+BillMenuData(customerInfo: CustomerInfo, availableMonths: List<String>, currentMonth: String)
|
||||
+getCustomerInfo(): CustomerInfo
|
||||
+getAvailableMonths(): List<String>
|
||||
+getCurrentMonth(): String
|
||||
}
|
||||
|
||||
class CustomerInfo {
|
||||
-customerId: String
|
||||
-lineNumber: String
|
||||
+CustomerInfo(customerId: String, lineNumber: String)
|
||||
+getCustomerId(): String
|
||||
+getLineNumber(): String
|
||||
}
|
||||
|
||||
class BillInquiryRequest {
|
||||
-lineNumber: String
|
||||
-inquiryMonth: String
|
||||
+BillInquiryRequest()
|
||||
+getLineNumber(): String
|
||||
+setLineNumber(lineNumber: String): void
|
||||
+getInquiryMonth(): String
|
||||
+setInquiryMonth(inquiryMonth: String): void
|
||||
+isValid(): boolean
|
||||
}
|
||||
|
||||
class BillInquiryData {
|
||||
-requestId: String
|
||||
-status: String
|
||||
-billInfo: BillInfo
|
||||
+BillInquiryData(requestId: String, status: String)
|
||||
+BillInquiryData(requestId: String, status: String, billInfo: BillInfo)
|
||||
+getRequestId(): String
|
||||
+getStatus(): String
|
||||
+getBillInfo(): BillInfo
|
||||
+setBillInfo(billInfo: BillInfo): void
|
||||
}
|
||||
|
||||
class BillInquiryAsyncData {
|
||||
-requestId: String
|
||||
-status: String
|
||||
-estimatedTime: String
|
||||
+BillInquiryAsyncData(requestId: String, status: String, estimatedTime: String)
|
||||
+getRequestId(): String
|
||||
+getStatus(): String
|
||||
+getEstimatedTime(): String
|
||||
}
|
||||
|
||||
class BillInquiryStatusData {
|
||||
-requestId: String
|
||||
-status: String
|
||||
-progress: Integer
|
||||
-billInfo: BillInfo
|
||||
-errorMessage: String
|
||||
+BillInquiryStatusData(requestId: String, status: String)
|
||||
+getRequestId(): String
|
||||
+getStatus(): String
|
||||
+getProgress(): Integer
|
||||
+setProgress(progress: Integer): void
|
||||
+getBillInfo(): BillInfo
|
||||
+setBillInfo(billInfo: BillInfo): void
|
||||
+getErrorMessage(): String
|
||||
+setErrorMessage(errorMessage: String): void
|
||||
}
|
||||
|
||||
class BillHistoryData {
|
||||
-items: List<BillHistoryItem>
|
||||
-pagination: PaginationInfo
|
||||
+BillHistoryData(items: List<BillHistoryItem>, pagination: PaginationInfo)
|
||||
+getItems(): List<BillHistoryItem>
|
||||
+getPagination(): PaginationInfo
|
||||
}
|
||||
|
||||
class BillHistoryItem {
|
||||
-requestId: String
|
||||
-lineNumber: String
|
||||
-inquiryMonth: String
|
||||
-requestTime: LocalDateTime
|
||||
-processTime: LocalDateTime
|
||||
-status: String
|
||||
-resultSummary: String
|
||||
+BillHistoryItem()
|
||||
+getRequestId(): String
|
||||
+setRequestId(requestId: String): void
|
||||
+getLineNumber(): String
|
||||
+setLineNumber(lineNumber: String): void
|
||||
+getInquiryMonth(): String
|
||||
+setInquiryMonth(inquiryMonth: String): void
|
||||
+getRequestTime(): LocalDateTime
|
||||
+setRequestTime(requestTime: LocalDateTime): void
|
||||
+getProcessTime(): LocalDateTime
|
||||
+setProcessTime(processTime: LocalDateTime): void
|
||||
+getStatus(): String
|
||||
+setStatus(status: String): void
|
||||
+getResultSummary(): String
|
||||
+setResultSummary(resultSummary: String): void
|
||||
}
|
||||
|
||||
class PaginationInfo {
|
||||
-currentPage: int
|
||||
-totalPages: int
|
||||
-totalItems: long
|
||||
-pageSize: int
|
||||
-hasNext: boolean
|
||||
-hasPrevious: boolean
|
||||
+PaginationInfo(currentPage: int, totalPages: int, totalItems: long, pageSize: int)
|
||||
+getCurrentPage(): int
|
||||
+getTotalPages(): int
|
||||
+getTotalItems(): long
|
||||
+getPageSize(): int
|
||||
+isHasNext(): boolean
|
||||
+isHasPrevious(): boolean
|
||||
}
|
||||
}
|
||||
|
||||
package "service" {
|
||||
interface BillService {
|
||||
+getBillMenuData(userId: String, lineNumber: String): BillMenuData
|
||||
+inquireBill(lineNumber: String, inquiryMonth: String, userId: String): BillInquiryData
|
||||
+getBillInquiryStatus(requestId: String, userId: String): BillInquiryStatusData
|
||||
+getBillHistory(lineNumber: String, startDate: String, endDate: String, page: int, size: int, status: String, userId: String): BillHistoryData
|
||||
}
|
||||
|
||||
class BillServiceImpl {
|
||||
-billCacheService: BillCacheService
|
||||
-kosClientService: KosClientService
|
||||
-billRepository: BillHistoryRepository
|
||||
-mvnoApiClient: MvnoApiClient
|
||||
+getBillMenuData(userId: String, lineNumber: String): BillMenuData
|
||||
+inquireBill(lineNumber: String, inquiryMonth: String, userId: String): BillInquiryData
|
||||
+getBillInquiryStatus(requestId: String, userId: String): BillInquiryStatusData
|
||||
+getBillHistory(lineNumber: String, startDate: String, endDate: String, page: int, size: int, status: String, userId: String): BillHistoryData
|
||||
-generateRequestId(): String
|
||||
-getCurrentMonth(): String
|
||||
-getAvailableMonths(): List<String>
|
||||
-processCurrentMonthInquiry(lineNumber: String, userId: String): BillInquiryData
|
||||
-processSpecificMonthInquiry(lineNumber: String, inquiryMonth: String, userId: String): BillInquiryData
|
||||
-saveBillInquiryHistoryAsync(userId: String, lineNumber: String, inquiryMonth: String, requestId: String, status: String): void
|
||||
-sendResultToMvnoAsync(billInfo: BillInfo): void
|
||||
}
|
||||
|
||||
interface KosClientService {
|
||||
+getBillInfo(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
+isServiceAvailable(): boolean
|
||||
}
|
||||
|
||||
class KosClientServiceImpl {
|
||||
-kosAdapterService: KosAdapterService
|
||||
-circuitBreakerService: CircuitBreakerService
|
||||
-retryService: RetryService
|
||||
-billRepository: KosInquiryHistoryRepository
|
||||
+getBillInfo(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
+isServiceAvailable(): boolean
|
||||
-executeWithCircuitBreaker(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
-executeWithRetry(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
-saveKosInquiryHistory(lineNumber: String, inquiryMonth: String, status: String, errorMessage: String): void
|
||||
}
|
||||
|
||||
interface BillCacheService {
|
||||
+getCachedBillInfo(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
+cacheBillInfo(lineNumber: String, inquiryMonth: String, billInfo: BillInfo): void
|
||||
+getCustomerInfo(userId: String): CustomerInfo
|
||||
+cacheCustomerInfo(userId: String, customerInfo: CustomerInfo): void
|
||||
+evictBillInfoCache(lineNumber: String, inquiryMonth: String): void
|
||||
}
|
||||
|
||||
class BillCacheServiceImpl {
|
||||
-redisTemplate: RedisTemplate<String, Object>
|
||||
-billRepository: BillHistoryRepository
|
||||
+getCachedBillInfo(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
+cacheBillInfo(lineNumber: String, inquiryMonth: String, billInfo: BillInfo): void
|
||||
+getCustomerInfo(userId: String): CustomerInfo
|
||||
+cacheCustomerInfo(userId: String, customerInfo: CustomerInfo): void
|
||||
+evictBillInfoCache(lineNumber: String, inquiryMonth: String): void
|
||||
-buildBillInfoCacheKey(lineNumber: String, inquiryMonth: String): String
|
||||
-buildCustomerInfoCacheKey(userId: String): String
|
||||
-isValidCachedData(cachedData: Object): boolean
|
||||
}
|
||||
|
||||
interface KosAdapterService {
|
||||
+callKosBillInquiry(lineNumber: String, inquiryMonth: String): KosResponse
|
||||
}
|
||||
|
||||
class KosAdapterServiceImpl {
|
||||
-restTemplate: RestTemplate
|
||||
-kosConfig: KosConfig
|
||||
+callKosBillInquiry(lineNumber: String, inquiryMonth: String): KosResponse
|
||||
-buildKosRequest(lineNumber: String, inquiryMonth: String): KosRequest
|
||||
-convertToKosResponse(responseEntity: ResponseEntity<String>): KosResponse
|
||||
-handleKosError(statusCode: HttpStatus, responseBody: String): void
|
||||
}
|
||||
|
||||
interface CircuitBreakerService {
|
||||
+isCallAllowed(): boolean
|
||||
+recordSuccess(): void
|
||||
+recordFailure(): void
|
||||
+getCircuitState(): CircuitState
|
||||
}
|
||||
|
||||
class CircuitBreakerServiceImpl {
|
||||
-failureThreshold: int
|
||||
-recoveryTimeout: long
|
||||
-successThreshold: int
|
||||
-failureCount: AtomicInteger
|
||||
-successCount: AtomicInteger
|
||||
-lastFailureTime: AtomicLong
|
||||
-circuitState: CircuitState
|
||||
+isCallAllowed(): boolean
|
||||
+recordSuccess(): void
|
||||
+recordFailure(): void
|
||||
+getCircuitState(): CircuitState
|
||||
-transitionToOpen(): void
|
||||
-transitionToHalfOpen(): void
|
||||
-transitionToClosed(): void
|
||||
}
|
||||
|
||||
interface RetryService {
|
||||
+executeWithRetry(operation: Supplier<T>): T
|
||||
}
|
||||
|
||||
class RetryServiceImpl {
|
||||
-maxRetries: int
|
||||
-retryDelayMs: long
|
||||
+executeWithRetry(operation: Supplier<T>): T
|
||||
-shouldRetry(exception: Exception, attemptCount: int): boolean
|
||||
-calculateDelay(attemptCount: int): long
|
||||
}
|
||||
|
||||
interface MvnoApiClient {
|
||||
+sendBillResult(billInfo: BillInfo): void
|
||||
}
|
||||
|
||||
class MvnoApiClientImpl {
|
||||
-restTemplate: RestTemplate
|
||||
-mvnoConfig: MvnoConfig
|
||||
+sendBillResult(billInfo: BillInfo): void
|
||||
-buildMvnoRequest(billInfo: BillInfo): MvnoRequest
|
||||
}
|
||||
}
|
||||
|
||||
package "domain" {
|
||||
class BillInfo {
|
||||
-productName: String
|
||||
-contractInfo: String
|
||||
-billingMonth: String
|
||||
-totalAmount: Integer
|
||||
-discountInfo: List<DiscountInfo>
|
||||
-usage: UsageInfo
|
||||
-terminationFee: Integer
|
||||
-deviceInstallment: Integer
|
||||
-paymentInfo: PaymentInfo
|
||||
+BillInfo()
|
||||
+getProductName(): String
|
||||
+setProductName(productName: String): void
|
||||
+getContractInfo(): String
|
||||
+setContractInfo(contractInfo: String): void
|
||||
+getBillingMonth(): String
|
||||
+setBillingMonth(billingMonth: String): void
|
||||
+getTotalAmount(): Integer
|
||||
+setTotalAmount(totalAmount: Integer): void
|
||||
+getDiscountInfo(): List<DiscountInfo>
|
||||
+setDiscountInfo(discountInfo: List<DiscountInfo>): void
|
||||
+getUsage(): UsageInfo
|
||||
+setUsage(usage: UsageInfo): void
|
||||
+getTerminationFee(): Integer
|
||||
+setTerminationFee(terminationFee: Integer): void
|
||||
+getDeviceInstallment(): Integer
|
||||
+setDeviceInstallment(deviceInstallment: Integer): void
|
||||
+getPaymentInfo(): PaymentInfo
|
||||
+setPaymentInfo(paymentInfo: PaymentInfo): void
|
||||
+isComplete(): boolean
|
||||
}
|
||||
|
||||
class DiscountInfo {
|
||||
-name: String
|
||||
-amount: Integer
|
||||
+DiscountInfo()
|
||||
+DiscountInfo(name: String, amount: Integer)
|
||||
+getName(): String
|
||||
+setName(name: String): void
|
||||
+getAmount(): Integer
|
||||
+setAmount(amount: Integer): void
|
||||
}
|
||||
|
||||
class UsageInfo {
|
||||
-voice: String
|
||||
-sms: String
|
||||
-data: String
|
||||
+UsageInfo()
|
||||
+UsageInfo(voice: String, sms: String, data: String)
|
||||
+getVoice(): String
|
||||
+setVoice(voice: String): void
|
||||
+getSms(): String
|
||||
+setSms(sms: String): void
|
||||
+getData(): String
|
||||
+setData(data: String): void
|
||||
}
|
||||
|
||||
class PaymentInfo {
|
||||
-billingDate: String
|
||||
-paymentStatus: String
|
||||
-paymentMethod: String
|
||||
+PaymentInfo()
|
||||
+PaymentInfo(billingDate: String, paymentStatus: String, paymentMethod: String)
|
||||
+getBillingDate(): String
|
||||
+setBillingDate(billingDate: String): void
|
||||
+getPaymentStatus(): String
|
||||
+setPaymentStatus(paymentStatus: String): void
|
||||
+getPaymentMethod(): String
|
||||
+setPaymentMethod(paymentMethod: String): void
|
||||
}
|
||||
|
||||
' KOS 연동 도메인 모델
|
||||
class KosRequest {
|
||||
-lineNumber: String
|
||||
-inquiryMonth: String
|
||||
-requestTime: LocalDateTime
|
||||
+KosRequest(lineNumber: String, inquiryMonth: String)
|
||||
+getLineNumber(): String
|
||||
+getInquiryMonth(): String
|
||||
+getRequestTime(): LocalDateTime
|
||||
+toKosFormat(): Map<String, Object>
|
||||
}
|
||||
|
||||
class KosResponse {
|
||||
-resultCode: String
|
||||
-resultMessage: String
|
||||
-data: KosData
|
||||
-responseTime: LocalDateTime
|
||||
+KosResponse()
|
||||
+getResultCode(): String
|
||||
+setResultCode(resultCode: String): void
|
||||
+getResultMessage(): String
|
||||
+setResultMessage(resultMessage: String): void
|
||||
+getData(): KosData
|
||||
+setData(data: KosData): void
|
||||
+getResponseTime(): LocalDateTime
|
||||
+setResponseTime(responseTime: LocalDateTime): void
|
||||
+isSuccess(): boolean
|
||||
+toBillInfo(): BillInfo
|
||||
}
|
||||
|
||||
class KosData {
|
||||
-productName: String
|
||||
-contractInfo: String
|
||||
-billingMonth: String
|
||||
-charge: Integer
|
||||
-discountInfo: String
|
||||
-usage: KosUsage
|
||||
-estimatedCancellationFee: Integer
|
||||
-deviceInstallment: Integer
|
||||
-billingPaymentInfo: KosPaymentInfo
|
||||
+KosData()
|
||||
+getProductName(): String
|
||||
+setProductName(productName: String): void
|
||||
+getContractInfo(): String
|
||||
+setContractInfo(contractInfo: String): void
|
||||
+getBillingMonth(): String
|
||||
+setBillingMonth(billingMonth: String): void
|
||||
+getCharge(): Integer
|
||||
+setCharge(charge: Integer): void
|
||||
+getDiscountInfo(): String
|
||||
+setDiscountInfo(discountInfo: String): void
|
||||
+getUsage(): KosUsage
|
||||
+setUsage(usage: KosUsage): void
|
||||
+getEstimatedCancellationFee(): Integer
|
||||
+setEstimatedCancellationFee(estimatedCancellationFee: Integer): void
|
||||
+getDeviceInstallment(): Integer
|
||||
+setDeviceInstallment(deviceInstallment: Integer): void
|
||||
+getBillingPaymentInfo(): KosPaymentInfo
|
||||
+setBillingPaymentInfo(billingPaymentInfo: KosPaymentInfo): void
|
||||
}
|
||||
|
||||
class KosUsage {
|
||||
-voice: String
|
||||
-data: String
|
||||
+KosUsage()
|
||||
+getVoice(): String
|
||||
+setVoice(voice: String): void
|
||||
+getData(): String
|
||||
+setData(data: String): void
|
||||
+toUsageInfo(): UsageInfo
|
||||
}
|
||||
|
||||
class KosPaymentInfo {
|
||||
-billingDate: String
|
||||
-paymentStatus: String
|
||||
+KosPaymentInfo()
|
||||
+getBillingDate(): String
|
||||
+setBillingDate(billingDate: String): void
|
||||
+getPaymentStatus(): String
|
||||
+setPaymentStatus(paymentStatus: String): void
|
||||
+toPaymentInfo(): PaymentInfo
|
||||
}
|
||||
|
||||
' MVNO 연동 도메인 모델
|
||||
class MvnoRequest {
|
||||
-billInfo: BillInfo
|
||||
-timestamp: LocalDateTime
|
||||
+MvnoRequest(billInfo: BillInfo)
|
||||
+getBillInfo(): BillInfo
|
||||
+getTimestamp(): LocalDateTime
|
||||
+toRequestBody(): Map<String, Object>
|
||||
}
|
||||
|
||||
enum CircuitState {
|
||||
CLOSED
|
||||
OPEN
|
||||
HALF_OPEN
|
||||
+valueOf(name: String): CircuitState
|
||||
+values(): CircuitState[]
|
||||
}
|
||||
|
||||
enum BillInquiryStatus {
|
||||
PROCESSING("처리중")
|
||||
COMPLETED("완료")
|
||||
FAILED("실패")
|
||||
|
||||
-description: String
|
||||
+BillInquiryStatus(description: String)
|
||||
+getDescription(): String
|
||||
}
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface BillHistoryRepository {
|
||||
+findByUserIdAndLineNumberOrderByRequestTimeDesc(userId: String, lineNumber: String, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+findByUserIdAndRequestTimeBetweenOrderByRequestTimeDesc(userId: String, startDate: LocalDateTime, endDate: LocalDateTime, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+findByUserIdAndLineNumberAndStatusOrderByRequestTimeDesc(userId: String, lineNumber: String, status: String, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+save(entity: BillHistoryEntity): BillHistoryEntity
|
||||
+getCustomerInfo(userId: String): CustomerInfo
|
||||
}
|
||||
|
||||
interface KosInquiryHistoryRepository {
|
||||
+save(entity: KosInquiryHistoryEntity): KosInquiryHistoryEntity
|
||||
+findByLineNumberAndInquiryMonthOrderByRequestTimeDesc(lineNumber: String, inquiryMonth: String): List<KosInquiryHistoryEntity>
|
||||
}
|
||||
|
||||
package "entity" {
|
||||
class BillHistoryEntity {
|
||||
-id: Long
|
||||
-userId: String
|
||||
-lineNumber: String
|
||||
-inquiryMonth: String
|
||||
-requestId: String
|
||||
-requestTime: LocalDateTime
|
||||
-processTime: LocalDateTime
|
||||
-status: String
|
||||
-resultSummary: String
|
||||
-billInfoJson: String
|
||||
+BillHistoryEntity()
|
||||
+getId(): Long
|
||||
+setId(id: Long): void
|
||||
+getUserId(): String
|
||||
+setUserId(userId: String): void
|
||||
+getLineNumber(): String
|
||||
+setLineNumber(lineNumber: String): void
|
||||
+getInquiryMonth(): String
|
||||
+setInquiryMonth(inquiryMonth: String): void
|
||||
+getRequestId(): String
|
||||
+setRequestId(requestId: String): void
|
||||
+getRequestTime(): LocalDateTime
|
||||
+setRequestTime(requestTime: LocalDateTime): void
|
||||
+getProcessTime(): LocalDateTime
|
||||
+setProcessTime(processTime: LocalDateTime): void
|
||||
+getStatus(): String
|
||||
+setStatus(status: String): void
|
||||
+getResultSummary(): String
|
||||
+setResultSummary(resultSummary: String): void
|
||||
+getBillInfoJson(): String
|
||||
+setBillInfoJson(billInfoJson: String): void
|
||||
+toBillHistoryItem(): BillHistoryItem
|
||||
+fromBillInfo(billInfo: BillInfo): void
|
||||
}
|
||||
|
||||
class KosInquiryHistoryEntity {
|
||||
-id: Long
|
||||
-lineNumber: String
|
||||
-inquiryMonth: String
|
||||
-requestTime: LocalDateTime
|
||||
-responseTime: LocalDateTime
|
||||
-resultCode: String
|
||||
-resultMessage: String
|
||||
-errorDetail: String
|
||||
+KosInquiryHistoryEntity()
|
||||
+getId(): Long
|
||||
+setId(id: Long): void
|
||||
+getLineNumber(): String
|
||||
+setLineNumber(lineNumber: String): void
|
||||
+getInquiryMonth(): String
|
||||
+setInquiryMonth(inquiryMonth: String): void
|
||||
+getRequestTime(): LocalDateTime
|
||||
+setRequestTime(requestTime: LocalDateTime): void
|
||||
+getResponseTime(): LocalDateTime
|
||||
+setResponseTime(responseTime: LocalDateTime): void
|
||||
+getResultCode(): String
|
||||
+setResultCode(resultCode: String): void
|
||||
+getResultMessage(): String
|
||||
+setResultMessage(resultMessage: String): void
|
||||
+getErrorDetail(): String
|
||||
+setErrorDetail(errorDetail: String): void
|
||||
}
|
||||
}
|
||||
|
||||
package "jpa" {
|
||||
interface BillHistoryJpaRepository {
|
||||
+findByUserIdAndLineNumberOrderByRequestTimeDesc(userId: String, lineNumber: String, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+findByUserIdAndRequestTimeBetweenOrderByRequestTimeDesc(userId: String, startDate: LocalDateTime, endDate: LocalDateTime, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+findByUserIdAndLineNumberAndStatusOrderByRequestTimeDesc(userId: String, lineNumber: String, status: String, pageable: Pageable): Page<BillHistoryEntity>
|
||||
+countByUserIdAndLineNumber(userId: String, lineNumber: String): long
|
||||
}
|
||||
|
||||
interface KosInquiryHistoryJpaRepository {
|
||||
+findByLineNumberAndInquiryMonthOrderByRequestTimeDesc(lineNumber: String, inquiryMonth: String): List<KosInquiryHistoryEntity>
|
||||
+countByResultCode(resultCode: String): long
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class RestTemplateConfig {
|
||||
+kosRestTemplate(): RestTemplate
|
||||
+mvnoRestTemplate(): RestTemplate
|
||||
+kosHttpMessageConverters(): List<HttpMessageConverter<?>>
|
||||
+kosRequestInterceptors(): List<ClientHttpRequestInterceptor>
|
||||
+kosConnectionPoolConfig(): HttpComponentsClientHttpRequestFactory
|
||||
}
|
||||
|
||||
class BillCacheConfig {
|
||||
+billInfoCacheConfiguration(): RedisCacheConfiguration
|
||||
+customerInfoCacheConfiguration(): RedisCacheConfiguration
|
||||
+billCacheKeyGenerator(): KeyGenerator
|
||||
+cacheErrorHandler(): CacheErrorHandler
|
||||
}
|
||||
|
||||
class KosConfig {
|
||||
-baseUrl: String
|
||||
-connectTimeout: int
|
||||
-readTimeout: int
|
||||
-maxRetries: int
|
||||
-retryDelay: long
|
||||
+getBaseUrl(): String
|
||||
+setBaseUrl(baseUrl: String): void
|
||||
+getConnectTimeout(): int
|
||||
+setConnectTimeout(connectTimeout: int): void
|
||||
+getReadTimeout(): int
|
||||
+setReadTimeout(readTimeout: int): void
|
||||
+getMaxRetries(): int
|
||||
+setMaxRetries(maxRetries: int): void
|
||||
+getRetryDelay(): long
|
||||
+setRetryDelay(retryDelay: long): void
|
||||
+getBillInquiryEndpoint(): String
|
||||
}
|
||||
|
||||
class MvnoConfig {
|
||||
-baseUrl: String
|
||||
-connectTimeout: int
|
||||
-readTimeout: int
|
||||
+getBaseUrl(): String
|
||||
+setBaseUrl(baseUrl: String): void
|
||||
+getConnectTimeout(): int
|
||||
+setConnectTimeout(connectTimeout: int): void
|
||||
+getReadTimeout(): int
|
||||
+setReadTimeout(readTimeout: int): void
|
||||
+getSendResultEndpoint(): String
|
||||
}
|
||||
|
||||
class CircuitBreakerConfig {
|
||||
-failureThreshold: int
|
||||
-recoveryTimeoutMs: long
|
||||
-successThreshold: int
|
||||
+getFailureThreshold(): int
|
||||
+setFailureThreshold(failureThreshold: int): void
|
||||
+getRecoveryTimeoutMs(): long
|
||||
+setRecoveryTimeoutMs(recoveryTimeoutMs: long): void
|
||||
+getSuccessThreshold(): int
|
||||
+setSuccessThreshold(successThreshold: int): void
|
||||
}
|
||||
|
||||
class AsyncConfig {
|
||||
+billTaskExecutor(): TaskExecutor
|
||||
+kosTaskExecutor(): TaskExecutor
|
||||
+asyncExceptionHandler(): AsyncUncaughtExceptionHandler
|
||||
}
|
||||
|
||||
class JwtTokenUtil {
|
||||
-secretKey: String
|
||||
-tokenExpiration: long
|
||||
+extractUserId(token: String): String
|
||||
+extractLineNumber(token: String): String
|
||||
+extractPermissions(token: String): List<String>
|
||||
+validateToken(token: String): boolean
|
||||
+isTokenExpired(token: String): boolean
|
||||
+parseToken(token: String): Claims
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' 관계 설정
|
||||
' Controller Layer
|
||||
BillController --> BillService : "uses"
|
||||
BillController --> JwtTokenUtil : "uses"
|
||||
|
||||
' Service Layer Relationships
|
||||
BillServiceImpl ..|> BillService : "implements"
|
||||
BillServiceImpl --> BillCacheService : "uses"
|
||||
BillServiceImpl --> KosClientService : "uses"
|
||||
BillServiceImpl --> BillHistoryRepository : "uses"
|
||||
BillServiceImpl --> MvnoApiClient : "uses"
|
||||
|
||||
KosClientServiceImpl ..|> KosClientService : "implements"
|
||||
KosClientServiceImpl --> KosAdapterService : "uses"
|
||||
KosClientServiceImpl --> CircuitBreakerService : "uses"
|
||||
KosClientServiceImpl --> RetryService : "uses"
|
||||
KosClientServiceImpl --> KosInquiryHistoryRepository : "uses"
|
||||
|
||||
BillCacheServiceImpl ..|> BillCacheService : "uses"
|
||||
BillCacheServiceImpl --> BillHistoryRepository : "uses"
|
||||
|
||||
KosAdapterServiceImpl ..|> KosAdapterService : "implements"
|
||||
KosAdapterServiceImpl --> KosConfig : "uses"
|
||||
|
||||
CircuitBreakerServiceImpl ..|> CircuitBreakerService : "implements"
|
||||
RetryServiceImpl ..|> RetryService : "implements"
|
||||
MvnoApiClientImpl ..|> MvnoApiClient : "implements"
|
||||
|
||||
' Domain Relationships
|
||||
BillInfo --> DiscountInfo : "contains"
|
||||
BillInfo --> UsageInfo : "contains"
|
||||
BillInfo --> PaymentInfo : "uses"
|
||||
KosResponse --> KosData : "contains"
|
||||
KosData --> KosUsage : "contains"
|
||||
KosData --> KosPaymentInfo : "contains"
|
||||
MvnoRequest --> BillInfo : "contains"
|
||||
|
||||
' Repository Relationships
|
||||
BillHistoryRepository --> BillHistoryJpaRepository : "uses"
|
||||
KosInquiryHistoryRepository --> KosInquiryHistoryJpaRepository : "uses"
|
||||
|
||||
' Entity Relationships
|
||||
BillHistoryEntity --|> BaseTimeEntity : "extends"
|
||||
KosInquiryHistoryEntity --|> BaseTimeEntity : "extends"
|
||||
|
||||
' DTO Relationships
|
||||
BillMenuData --> CustomerInfo : "contains"
|
||||
BillInquiryData --> BillInfo : "contains"
|
||||
BillInquiryStatusData --> BillInfo : "contains"
|
||||
BillHistoryData --> BillHistoryItem : "contains"
|
||||
BillHistoryData --> PaginationInfo : "contains"
|
||||
|
||||
@enduml
|
||||
242
design/backend/class/class.md
Normal file
242
design/backend/class/class.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Product-Change Service 클래스 설계서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 설계 목적
|
||||
Product-Change Service의 상품변경 기능을 구현하기 위한 클래스 구조를 설계합니다.
|
||||
|
||||
### 1.2 설계 원칙
|
||||
- **아키텍처 패턴**: Layered Architecture 적용
|
||||
- **패키지 구조**: com.unicorn.phonebill.product 하위 계층별 구조
|
||||
- **KOS 연동**: Circuit Breaker 패턴으로 외부 시스템 안정성 확보
|
||||
- **캐시 전략**: Redis를 활용한 성능 최적화
|
||||
- **예외 처리**: 계층별 예외 처리 및 비즈니스 예외 정의
|
||||
|
||||
### 1.3 주요 기능
|
||||
- UFR-PROD-010: 상품변경 메뉴 조회
|
||||
- UFR-PROD-020: 상품변경 화면 데이터 조회
|
||||
- UFR-PROD-030: 상품변경 요청 및 사전체크
|
||||
- UFR-PROD-040: KOS 연동 상품변경 처리
|
||||
|
||||
## 2. 패키지 구조도
|
||||
|
||||
```
|
||||
com.unicorn.phonebill.product
|
||||
├── controller/ # 컨트롤러 계층
|
||||
│ └── ProductController # 상품변경 API 컨트롤러
|
||||
├── dto/ # 데이터 전송 객체
|
||||
│ ├── *Request # 요청 DTO 클래스들
|
||||
│ ├── *Response # 응답 DTO 클래스들
|
||||
│ └── *Enum # DTO 관련 열거형
|
||||
├── service/ # 서비스 계층
|
||||
│ ├── ProductService # 상품변경 서비스 인터페이스
|
||||
│ ├── ProductServiceImpl # 상품변경 서비스 구현체
|
||||
│ ├── ProductValidationService # 상품변경 검증 서비스
|
||||
│ ├── ProductCacheService # 상품 캐시 서비스
|
||||
│ ├── KosClientService # KOS 연동 서비스
|
||||
│ ├── CircuitBreakerService # Circuit Breaker 서비스
|
||||
│ └── RetryService # 재시도 서비스
|
||||
├── domain/ # 도메인 계층
|
||||
│ ├── Product # 상품 도메인 모델
|
||||
│ ├── ProductChangeHistory # 상품변경 이력 도메인 모델
|
||||
│ ├── ProductChangeResult # 상품변경 결과 도메인 모델
|
||||
│ └── ProductStatus # 상품 상태 도메인 모델
|
||||
├── repository/ # 저장소 계층
|
||||
│ ├── ProductRepository # 상품 저장소 인터페이스
|
||||
│ ├── ProductChangeHistoryRepository # 상품변경 이력 저장소 인터페이스
|
||||
│ ├── entity/ # JPA 엔티티
|
||||
│ │ └── ProductChangeHistoryEntity
|
||||
│ └── jpa/ # JPA Repository
|
||||
│ └── ProductChangeHistoryJpaRepository
|
||||
├── config/ # 설정 계층
|
||||
│ ├── RestTemplateConfig # REST 통신 설정
|
||||
│ ├── CacheConfig # 캐시 설정
|
||||
│ ├── CircuitBreakerConfig # Circuit Breaker 설정
|
||||
│ └── KosProperties # KOS 연동 설정
|
||||
├── external/ # 외부 연동 계층
|
||||
│ ├── KosRequest # KOS 요청 모델
|
||||
│ ├── KosResponse # KOS 응답 모델
|
||||
│ └── KosAdapterService # KOS 어댑터 서비스
|
||||
└── exception/ # 예외 계층
|
||||
├── ProductChangeException # 상품변경 예외
|
||||
├── ProductValidationException # 상품변경 검증 예외
|
||||
├── KosConnectionException # KOS 연결 예외
|
||||
└── CircuitBreakerException # Circuit Breaker 예외
|
||||
```
|
||||
|
||||
## 3. 계층별 클래스 설계
|
||||
|
||||
### 3.1 Controller Layer
|
||||
|
||||
#### ProductController
|
||||
- **역할**: 상품변경 관련 REST API 엔드포인트 제공
|
||||
- **주요 메소드**:
|
||||
- `getProductMenu()`: 상품변경 메뉴 조회 (GET /products/menu)
|
||||
- `getCustomerInfo(lineNumber)`: 고객 정보 조회 (GET /products/customer/{lineNumber})
|
||||
- `getAvailableProducts()`: 변경 가능한 상품 목록 조회 (GET /products/available)
|
||||
- `validateProductChange(request)`: 상품변경 사전체크 (POST /products/change/validation)
|
||||
- `requestProductChange(request)`: 상품변경 요청 (POST /products/change)
|
||||
- `getProductChangeResult(requestId)`: 상품변경 결과 조회 (GET /products/change/{requestId})
|
||||
- `getProductChangeHistory()`: 상품변경 이력 조회 (GET /products/history)
|
||||
|
||||
### 3.2 Service Layer
|
||||
|
||||
#### ProductService / ProductServiceImpl
|
||||
- **역할**: 상품변경 비즈니스 로직 처리
|
||||
- **의존성**: KosClientService, ProductValidationService, ProductCacheService, ProductChangeHistoryRepository
|
||||
- **주요 기능**: 상품변경 프로세스 전체 조율, 캐시 무효화 처리
|
||||
|
||||
#### ProductValidationService
|
||||
- **역할**: 상품변경 사전체크 로직 처리
|
||||
- **주요 검증**: 판매중인 상품 확인, 사업자 일치 확인, 회선 사용상태 확인
|
||||
- **의존성**: ProductRepository, ProductCacheService, KosClientService
|
||||
|
||||
#### ProductCacheService
|
||||
- **역할**: Redis 캐시를 활용한 성능 최적화
|
||||
- **주요 캐시**: 고객상품정보(4시간), 현재상품정보(2시간), 가용상품목록(24시간), 상품상태(1시간), 회선상태(30분)
|
||||
- **캐시 키 전략**: `{cache_type}:{identifier}` 형식
|
||||
|
||||
#### KosClientService
|
||||
- **역할**: KOS 시스템과의 연동 처리
|
||||
- **의존성**: CircuitBreakerService, RetryService, KosAdapterService
|
||||
- **주요 기능**: KOS API 호출, Circuit Breaker 상태 관리, 재시도 로직
|
||||
|
||||
#### CircuitBreakerService / RetryService
|
||||
- **역할**: 외부 시스템 연동 안정성 보장
|
||||
- **패턴**: Circuit Breaker, Retry 패턴 적용
|
||||
- **설정**: 실패율 임계값, 재시도 횟수, 대기 시간 등
|
||||
|
||||
### 3.3 Domain Layer
|
||||
|
||||
#### Product
|
||||
- **역할**: 상품 정보 도메인 모델
|
||||
- **주요 속성**: productCode, productName, monthlyFee, dataAllowance, voiceAllowance, smsAllowance, status, operatorCode
|
||||
- **비즈니스 메소드**: `canChangeTo()`, `isSameOperator()`
|
||||
|
||||
#### ProductChangeHistory
|
||||
- **역할**: 상품변경 이력 도메인 모델
|
||||
- **주요 속성**: requestId, userId, lineNumber, currentProductCode, targetProductCode, processStatus, requestedAt, processedAt
|
||||
- **상태 관리**: `markAsCompleted()`, `markAsFailed()`
|
||||
|
||||
#### ProductChangeResult
|
||||
- **역할**: 상품변경 처리 결과 도메인 모델
|
||||
- **팩토리 메소드**: `createSuccessResult()`, `createFailureResult()`
|
||||
|
||||
### 3.4 Repository Layer
|
||||
|
||||
#### ProductRepository
|
||||
- **역할**: 상품 데이터 접근 인터페이스
|
||||
- **주요 메소드**: 상품상태 조회, 상품변경 요청 저장, 상태 업데이트
|
||||
|
||||
#### ProductChangeHistoryRepository
|
||||
- **역할**: 상품변경 이력 데이터 접근 인터페이스
|
||||
- **JPA Repository**: ProductChangeHistoryJpaRepository 활용
|
||||
- **Entity**: ProductChangeHistoryEntity (BaseTimeEntity 상속)
|
||||
|
||||
### 3.5 Config Layer
|
||||
|
||||
#### RestTemplateConfig
|
||||
- **역할**: REST 통신 설정
|
||||
- **설정 요소**: Connection Pool, Timeout, HTTP Client 설정
|
||||
|
||||
#### CacheConfig
|
||||
- **역할**: Redis 캐시 설정
|
||||
- **설정 요소**: Redis 연결, Cache Manager, 직렬화 설정
|
||||
|
||||
#### CircuitBreakerConfig
|
||||
- **역할**: Circuit Breaker 및 Retry 설정
|
||||
- **설정 요소**: 실패율 임계값, 최소 호출 수, 대기 시간
|
||||
|
||||
#### KosProperties
|
||||
- **역할**: KOS 연동 설정 프로퍼티
|
||||
- **설정 요소**: baseUrl, connectTimeout, readTimeout, maxRetries, retryDelay
|
||||
|
||||
### 3.6 External Layer
|
||||
|
||||
#### KosAdapterService
|
||||
- **역할**: KOS 시스템 연동 어댑터
|
||||
- **주요 기능**: KOS API 호출, 요청/응답 데이터 변환, HTTP 헤더 설정
|
||||
- **의존성**: KosProperties, RestTemplate
|
||||
|
||||
#### KosRequest / KosResponse
|
||||
- **역할**: KOS 시스템 연동을 위한 요청/응답 모델
|
||||
- **변환**: 내부 도메인 모델 ↔ KOS API 모델
|
||||
|
||||
### 3.7 Exception Layer
|
||||
|
||||
#### ProductChangeException
|
||||
- **역할**: 상품변경 관련 비즈니스 예외
|
||||
- **상속**: BusinessException 상속
|
||||
|
||||
#### ProductValidationException
|
||||
- **역할**: 상품변경 검증 실패 예외
|
||||
- **추가 정보**: 검증 상세 정보 목록 포함
|
||||
|
||||
#### KosConnectionException
|
||||
- **역할**: KOS 연동 관련 예외
|
||||
- **추가 정보**: 연동 서비스명 포함
|
||||
|
||||
#### CircuitBreakerException
|
||||
- **역할**: Circuit Breaker Open 상태 예외
|
||||
- **추가 정보**: 서비스명, 상태 정보 포함
|
||||
|
||||
## 4. 주요 설계 특징
|
||||
|
||||
### 4.1 Layered Architecture 적용
|
||||
- **Controller**: API 엔드포인트 및 HTTP 요청/응답 처리
|
||||
- **Service**: 비즈니스 로직 처리 및 트랜잭션 관리
|
||||
- **Domain**: 핵심 비즈니스 모델 및 도메인 규칙
|
||||
- **Repository**: 데이터 접근 및 영속성 관리
|
||||
|
||||
### 4.2 캐시 전략
|
||||
- **다층 캐시**: Redis를 활용한 성능 최적화
|
||||
- **TTL 차등 적용**: 데이터 특성에 따른 캐시 수명 관리
|
||||
- **캐시 무효화**: 상품변경 완료 시 관련 캐시 제거
|
||||
|
||||
### 4.3 외부 연동 안정성
|
||||
- **Circuit Breaker**: KOS 시스템 장애 시 빠른 실패 처리
|
||||
- **Retry**: 일시적 네트워크 오류에 대한 재시도 로직
|
||||
- **Timeout**: 응답 시간 초과 방지
|
||||
|
||||
### 4.4 예외 처리 전략
|
||||
- **계층별 예외**: 각 계층의 책임에 맞는 예외 정의
|
||||
- **비즈니스 예외**: 도메인 규칙 위반에 대한 명확한 예외
|
||||
- **인프라 예외**: 외부 시스템 연동 실패에 대한 예외
|
||||
|
||||
## 5. API와 클래스 매핑
|
||||
|
||||
| API 엔드포인트 | HTTP Method | Controller 메소드 | 주요 Service |
|
||||
|---|---|---|---|
|
||||
| `/products/menu` | GET | `getProductMenu()` | ProductService |
|
||||
| `/products/customer/{lineNumber}` | GET | `getCustomerInfo()` | ProductService, ProductCacheService |
|
||||
| `/products/available` | GET | `getAvailableProducts()` | ProductService, ProductCacheService |
|
||||
| `/products/change/validation` | POST | `validateProductChange()` | ProductValidationService |
|
||||
| `/products/change` | POST | `requestProductChange()` | ProductService, KosClientService |
|
||||
| `/products/change/{requestId}` | GET | `getProductChangeResult()` | ProductService |
|
||||
| `/products/history` | GET | `getProductChangeHistory()` | ProductService, ProductChangeHistoryRepository |
|
||||
|
||||
## 6. 시퀀스와 클래스 연관관계
|
||||
|
||||
### 6.1 상품변경 요청 시퀀스 매핑
|
||||
- **ProductController** → **ProductServiceImpl** → **ProductValidationService** → **KosClientService** → **KosAdapterService**
|
||||
- **캐시 처리**: ProductCacheService를 통한 Redis 연동
|
||||
- **이력 관리**: ProductChangeHistoryRepository를 통한 DB 저장
|
||||
|
||||
### 6.2 KOS 연동 시퀀스 매핑
|
||||
- **KosClientService** → **CircuitBreakerService** → **RetryService** → **KosAdapterService**
|
||||
- **상태 관리**: ProductChangeHistory 도메인 모델을 통한 상태 추적
|
||||
- **결과 처리**: ProductChangeResult를 통한 성공/실패 처리
|
||||
|
||||
## 7. 설계 파일
|
||||
|
||||
- **상세 클래스 설계**: [product-change.puml](./product-change.puml)
|
||||
- **간단 클래스 설계**: [product-change-simple.puml](./product-change-simple.puml)
|
||||
|
||||
## 8. 관련 문서
|
||||
|
||||
- **API 설계서**: [product-change-service-api.yaml](../api/product-change-service-api.yaml)
|
||||
- **내부 시퀀스 설계서**:
|
||||
- [product-상품변경요청.puml](../sequence/inner/product-상품변경요청.puml)
|
||||
- [product-KOS연동.puml](../sequence/inner/product-KOS연동.puml)
|
||||
- **유저스토리**: [userstory.md](../../userstory.md)
|
||||
- **공통 기반 클래스**: [common-base.puml](./common-base.puml)
|
||||
176
design/backend/class/common-base.puml
Normal file
176
design/backend/class/common-base.puml
Normal file
@ -0,0 +1,176 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
title Common Base Classes - 통신요금 관리 서비스
|
||||
|
||||
package "Common Module" {
|
||||
package "dto" {
|
||||
class ApiResponse<T> {
|
||||
-success: boolean
|
||||
-message: String
|
||||
-data: T
|
||||
-timestamp: LocalDateTime
|
||||
+of(data: T): ApiResponse<T>
|
||||
+success(data: T, message: String): ApiResponse<T>
|
||||
+error(message: String): ApiResponse<T>
|
||||
+getSuccess(): boolean
|
||||
+getMessage(): String
|
||||
+getData(): T
|
||||
+getTimestamp(): LocalDateTime
|
||||
}
|
||||
|
||||
class ErrorResponse {
|
||||
-code: String
|
||||
-message: String
|
||||
-details: String
|
||||
-timestamp: LocalDateTime
|
||||
+ErrorResponse(code: String, message: String, details: String)
|
||||
+getCode(): String
|
||||
+getMessage(): String
|
||||
+getDetails(): String
|
||||
+getTimestamp(): LocalDateTime
|
||||
}
|
||||
|
||||
class JwtTokenDTO {
|
||||
-accessToken: String
|
||||
-refreshToken: String
|
||||
-tokenType: String
|
||||
-expiresIn: long
|
||||
+JwtTokenDTO(accessToken: String, refreshToken: String, expiresIn: long)
|
||||
+getAccessToken(): String
|
||||
+getRefreshToken(): String
|
||||
+getTokenType(): String
|
||||
+getExpiresIn(): long
|
||||
}
|
||||
|
||||
class JwtTokenVerifyDTO {
|
||||
-userId: String
|
||||
-lineNumber: String
|
||||
-permissions: List<String>
|
||||
-expiresAt: LocalDateTime
|
||||
+JwtTokenVerifyDTO(userId: String, lineNumber: String, permissions: List<String>)
|
||||
+getUserId(): String
|
||||
+getLineNumber(): String
|
||||
+getPermissions(): List<String>
|
||||
+getExpiresAt(): LocalDateTime
|
||||
}
|
||||
}
|
||||
|
||||
package "entity" {
|
||||
abstract class BaseTimeEntity {
|
||||
#createdAt: LocalDateTime
|
||||
#updatedAt: LocalDateTime
|
||||
+getCreatedAt(): LocalDateTime
|
||||
+getUpdatedAt(): LocalDateTime
|
||||
+{abstract} getId(): Object
|
||||
}
|
||||
}
|
||||
|
||||
package "exception" {
|
||||
enum ErrorCode {
|
||||
AUTH001("인증 실패")
|
||||
AUTH002("토큰이 유효하지 않음")
|
||||
AUTH003("권한이 부족함")
|
||||
AUTH004("계정이 잠겨있음")
|
||||
AUTH005("토큰이 만료됨")
|
||||
BILL001("요금 조회 실패")
|
||||
BILL002("KOS 연동 실패")
|
||||
BILL003("조회 이력 없음")
|
||||
PROD001("상품변경 실패")
|
||||
PROD002("사전체크 실패")
|
||||
PROD003("상품정보 없음")
|
||||
SYS001("시스템 오류")
|
||||
SYS002("외부 연동 실패")
|
||||
|
||||
-code: String
|
||||
-message: String
|
||||
|
||||
+ErrorCode(code: String, message: String)
|
||||
+getCode(): String
|
||||
+getMessage(): String
|
||||
}
|
||||
|
||||
class BusinessException {
|
||||
-errorCode: ErrorCode
|
||||
-details: String
|
||||
+BusinessException(errorCode: ErrorCode)
|
||||
+BusinessException(errorCode: ErrorCode, details: String)
|
||||
+getErrorCode(): ErrorCode
|
||||
+getDetails(): String
|
||||
}
|
||||
|
||||
class InfraException {
|
||||
-errorCode: ErrorCode
|
||||
-details: String
|
||||
+InfraException(errorCode: ErrorCode)
|
||||
+InfraException(errorCode: ErrorCode, details: String)
|
||||
+getErrorCode(): ErrorCode
|
||||
+getDetails(): String
|
||||
}
|
||||
}
|
||||
|
||||
package "util" {
|
||||
class DateUtil {
|
||||
+{static} getCurrentDateTime(): LocalDateTime
|
||||
+{static} formatDate(date: LocalDateTime, pattern: String): String
|
||||
+{static} parseDate(dateString: String, pattern: String): LocalDateTime
|
||||
+{static} getStartOfMonth(date: LocalDateTime): LocalDateTime
|
||||
+{static} getEndOfMonth(date: LocalDateTime): LocalDateTime
|
||||
+{static} isWithinRange(date: LocalDateTime, start: LocalDateTime, end: LocalDateTime): boolean
|
||||
}
|
||||
|
||||
class SecurityUtil {
|
||||
+{static} encryptPassword(password: String): String
|
||||
+{static} verifyPassword(password: String, encodedPassword: String): boolean
|
||||
+{static} generateSalt(): String
|
||||
+{static} maskPhoneNumber(phoneNumber: String): String
|
||||
+{static} maskUserId(userId: String): String
|
||||
}
|
||||
|
||||
class ValidatorUtil {
|
||||
+{static} isValidPhoneNumber(phoneNumber: String): boolean
|
||||
+{static} isValidUserId(userId: String): boolean
|
||||
+{static} isValidPassword(password: String): boolean
|
||||
+{static} isNotEmpty(value: String): boolean
|
||||
+{static} isValidDateRange(startDate: LocalDateTime, endDate: LocalDateTime): boolean
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class JpaConfig {
|
||||
+auditorProvider(): AuditorAware<String>
|
||||
+entityManagerFactory(): LocalContainerEntityManagerFactoryBean
|
||||
+transactionManager(): PlatformTransactionManager
|
||||
}
|
||||
|
||||
interface CacheConfig {
|
||||
+redisConnectionFactory(): RedisConnectionFactory
|
||||
+redisTemplate(): RedisTemplate<String, Object>
|
||||
+cacheManager(): CacheManager
|
||||
+redisCacheConfiguration(): RedisCacheConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
package "aop" {
|
||||
class LoggingAspect {
|
||||
-logger: Logger
|
||||
+logExecutionTime(joinPoint: ProceedingJoinPoint): Object
|
||||
+logMethodEntry(joinPoint: JoinPoint): void
|
||||
+logMethodExit(joinPoint: JoinPoint, result: Object): void
|
||||
+logException(joinPoint: JoinPoint, exception: Exception): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' 관계 설정
|
||||
ApiResponse --> ErrorResponse : "contains"
|
||||
BusinessException --> ErrorCode : "uses"
|
||||
InfraException --> ErrorCode : "uses"
|
||||
|
||||
' 노트 추가
|
||||
note top of ApiResponse : "모든 API 응답의 표준 구조\n제네릭을 사용한 타입 안전성 보장"
|
||||
note top of BaseTimeEntity : "모든 엔티티의 기본 클래스\nJPA Auditing을 통한 생성/수정 시간 자동 관리"
|
||||
note top of ErrorCode : "시스템 전체의 오류 코드 표준화\n서비스별 오류 코드 체계"
|
||||
note top of LoggingAspect : "AOP를 통한 로깅 처리\n실행 시간 측정 및 예외 로깅"
|
||||
|
||||
@enduml
|
||||
176
design/backend/class/kos-mock-simple.puml
Normal file
176
design/backend/class/kos-mock-simple.puml
Normal file
@ -0,0 +1,176 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
title KOS-Mock Service 클래스 설계 (간단)
|
||||
|
||||
package "com.unicorn.phonebill.kosmock" {
|
||||
|
||||
package "controller" {
|
||||
class KosMockController <<Controller>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "service" {
|
||||
class KosMockService <<Service>> {
|
||||
}
|
||||
|
||||
class BillDataService <<Service>> {
|
||||
}
|
||||
|
||||
class ProductDataService <<Service>> {
|
||||
}
|
||||
|
||||
class ProductValidationService <<Service>> {
|
||||
}
|
||||
|
||||
class MockScenarioService <<Service>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "dto" {
|
||||
class KosBillRequest <<DTO>> {
|
||||
}
|
||||
|
||||
class KosProductChangeRequest <<DTO>> {
|
||||
}
|
||||
|
||||
class MockBillResponse <<DTO>> {
|
||||
}
|
||||
|
||||
class MockProductChangeResponse <<DTO>> {
|
||||
}
|
||||
|
||||
class KosCustomerResponse <<DTO>> {
|
||||
}
|
||||
|
||||
class KosProductResponse <<DTO>> {
|
||||
}
|
||||
|
||||
class BillInfo <<Model>> {
|
||||
}
|
||||
|
||||
class ProductChangeResult <<Model>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface MockDataRepository <<Repository>> {
|
||||
}
|
||||
|
||||
class MockDataRepositoryImpl <<Repository>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "repository.entity" {
|
||||
class KosCustomerEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosProductEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosBillEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosUsageEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosContractEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosInstallmentEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class KosProductChangeHistoryEntity <<Entity>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "repository.jpa" {
|
||||
interface KosCustomerJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosProductJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosBillJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosUsageJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosContractJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosInstallmentJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
|
||||
interface KosProductChangeHistoryJpaRepository <<JPA Repository>> {
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class MockProperties <<Configuration>> {
|
||||
}
|
||||
|
||||
class KosMockConfig <<Configuration>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package "Common Module" {
|
||||
class ApiResponse<T> <<DTO>> {
|
||||
}
|
||||
|
||||
class BaseTimeEntity <<Entity>> {
|
||||
}
|
||||
|
||||
class BusinessException <<Exception>> {
|
||||
}
|
||||
}
|
||||
|
||||
' 관계 설정
|
||||
KosMockController --> KosMockService
|
||||
KosMockService --> BillDataService
|
||||
KosMockService --> ProductDataService
|
||||
KosMockService --> MockScenarioService
|
||||
BillDataService --> MockDataRepository
|
||||
ProductDataService --> MockDataRepository
|
||||
ProductDataService --> ProductValidationService
|
||||
ProductValidationService --> MockDataRepository
|
||||
MockScenarioService --> MockProperties
|
||||
|
||||
MockDataRepositoryImpl ..|> MockDataRepository
|
||||
MockDataRepositoryImpl --> KosCustomerJpaRepository
|
||||
MockDataRepositoryImpl --> KosProductJpaRepository
|
||||
MockDataRepositoryImpl --> KosBillJpaRepository
|
||||
MockDataRepositoryImpl --> KosUsageJpaRepository
|
||||
MockDataRepositoryImpl --> KosContractJpaRepository
|
||||
MockDataRepositoryImpl --> KosInstallmentJpaRepository
|
||||
MockDataRepositoryImpl --> KosProductChangeHistoryJpaRepository
|
||||
|
||||
KosCustomerJpaRepository --> KosCustomerEntity
|
||||
KosProductJpaRepository --> KosProductEntity
|
||||
KosBillJpaRepository --> KosBillEntity
|
||||
KosUsageJpaRepository --> KosUsageEntity
|
||||
KosContractJpaRepository --> KosContractEntity
|
||||
KosInstallmentJpaRepository --> KosInstallmentEntity
|
||||
KosProductChangeHistoryJpaRepository --> KosProductChangeHistoryEntity
|
||||
|
||||
KosCustomerEntity --|> BaseTimeEntity
|
||||
KosProductEntity --|> BaseTimeEntity
|
||||
KosBillEntity --|> BaseTimeEntity
|
||||
KosUsageEntity --|> BaseTimeEntity
|
||||
KosContractEntity --|> BaseTimeEntity
|
||||
KosInstallmentEntity --|> BaseTimeEntity
|
||||
KosProductChangeHistoryEntity --|> BaseTimeEntity
|
||||
|
||||
KosMockController --> ApiResponse
|
||||
|
||||
note top of KosMockController : **API 매핑표**\n\nPOST /kos/bill/inquiry\n- getBillInfo()\n- 요금조회 시뮬레이션\n\nPOST /kos/product/change\n- processProductChange()\n- 상품변경 시뮬레이션\n\nGET /kos/customer/{customerId}\n- getCustomerInfo()\n- 고객정보 조회\n\nGET /kos/products/available\n- getAvailableProducts()\n- 변경가능 상품목록\n\nGET /kos/line/{lineNumber}/status\n- getLineStatus()\n- 회선상태 조회
|
||||
|
||||
note right of MockScenarioService : **Mock 시나리오 규칙**\n\n요금조회:\n- 01012345678: 정상응답\n- 01012345679: 데이터없음\n- 01012345680: 시스템오류\n- 01012345681: 타임아웃\n\n상품변경:\n- 01012345678: 정상변경\n- 01012345679: 변경불가\n- 01012345680: 시스템오류\n- 01012345681: 잔액부족\n- PROD001→PROD999: 호환불가
|
||||
|
||||
note right of MockDataRepository : **데이터 접근 인터페이스**\n\n주요 메소드:\n- getMockBillTemplate()\n- getProductInfo()\n- getCustomerInfo()\n- saveProductChangeResult()\n- checkProductCompatibility()\n- getCustomerBalance()\n- getContractInfo()
|
||||
|
||||
note bottom of KosMockConfig : **Mock 설정**\n\n환경별 시나리오 설정:\n- mock.scenario.success.delay=500ms\n- mock.scenario.error.rate=5%\n- mock.scenario.timeout.enabled=true\n\n스레드풀 설정:\n- 비동기 로깅 및 메트릭 처리
|
||||
|
||||
@enduml
|
||||
588
design/backend/class/kos-mock.puml
Normal file
588
design/backend/class/kos-mock.puml
Normal file
@ -0,0 +1,588 @@
|
||||
@startuml
|
||||
!theme mono
|
||||
|
||||
title KOS-Mock Service 클래스 설계 (상세)
|
||||
|
||||
package "com.unicorn.phonebill.kosmock" {
|
||||
|
||||
package "controller" {
|
||||
class KosMockController {
|
||||
-kosMockService: KosMockService
|
||||
+getBillInfo(lineNumber: String, inquiryMonth: String): ResponseEntity<ApiResponse<MockBillResponse>>
|
||||
+processProductChange(changeRequest: KosProductChangeRequest): ResponseEntity<ApiResponse<MockProductChangeResponse>>
|
||||
+getCustomerInfo(customerId: String): ResponseEntity<ApiResponse<KosCustomerResponse>>
|
||||
+getAvailableProducts(): ResponseEntity<ApiResponse<List<KosProductResponse>>>
|
||||
+getLineStatus(lineNumber: String): ResponseEntity<ApiResponse<KosLineStatusResponse>>
|
||||
-validateBillRequest(lineNumber: String, inquiryMonth: String): void
|
||||
-validateProductChangeRequest(request: KosProductChangeRequest): void
|
||||
}
|
||||
}
|
||||
|
||||
package "service" {
|
||||
class KosMockService {
|
||||
-billDataService: BillDataService
|
||||
-productDataService: ProductDataService
|
||||
-mockScenarioService: MockScenarioService
|
||||
+getBillInfo(lineNumber: String, inquiryMonth: String): MockBillResponse
|
||||
+processProductChange(changeRequest: KosProductChangeRequest): MockProductChangeResponse
|
||||
+getCustomerInfo(customerId: String): KosCustomerResponse
|
||||
+getAvailableProducts(): List<KosProductResponse>
|
||||
+getLineStatus(lineNumber: String): KosLineStatusResponse
|
||||
-logMockRequest(requestType: String, requestData: Object): void
|
||||
-updateMetrics(requestType: String, scenario: String, responseTime: long): void
|
||||
}
|
||||
|
||||
class BillDataService {
|
||||
-mockDataRepository: MockDataRepository
|
||||
+generateBillData(lineNumber: String, inquiryMonth: String): BillInfo
|
||||
-calculateDynamicCharges(lineNumber: String, inquiryMonth: String): BillAmount
|
||||
-generateUsageData(lineNumber: String, inquiryMonth: String): UsageInfo
|
||||
-applyDiscounts(billAmount: BillAmount, lineNumber: String): List<DiscountInfo>
|
||||
}
|
||||
|
||||
class ProductDataService {
|
||||
-mockDataRepository: MockDataRepository
|
||||
-productValidationService: ProductValidationService
|
||||
+executeProductChange(changeRequest: KosProductChangeRequest): ProductChangeResult
|
||||
+getProductInfo(productCode: String): KosProduct
|
||||
+getCustomerProducts(customerId: String): List<KosProduct>
|
||||
-calculateNewMonthlyFee(newProductCode: String): Integer
|
||||
}
|
||||
|
||||
class ProductValidationService {
|
||||
-mockDataRepository: MockDataRepository
|
||||
+validateProductChange(changeRequest: KosProductChangeRequest): ValidationResult
|
||||
+checkProductCompatibility(currentProduct: String, newProduct: String): Boolean
|
||||
+checkCustomerEligibility(customerId: String, newProductCode: String): Boolean
|
||||
-validateContractConstraints(customerId: String): Boolean
|
||||
-validateBalance(customerId: String): Boolean
|
||||
}
|
||||
|
||||
class MockScenarioService {
|
||||
-properties: MockProperties
|
||||
+determineScenario(lineNumber: String, inquiryMonth: String): MockScenario
|
||||
+determineProductChangeScenario(lineNumber: String, changeRequest: KosProductChangeRequest): MockScenario
|
||||
+simulateDelay(scenario: MockScenario): void
|
||||
-getScenarioByLineNumber(lineNumber: String): String
|
||||
-getScenarioByProductCodes(currentCode: String, newCode: String): String
|
||||
}
|
||||
}
|
||||
|
||||
package "dto.request" {
|
||||
class KosBillRequest {
|
||||
+lineNumber: String
|
||||
+inquiryMonth: String
|
||||
+validate(): void
|
||||
}
|
||||
|
||||
class KosProductChangeRequest {
|
||||
+transactionId: String
|
||||
+lineNumber: String
|
||||
+currentProductCode: String
|
||||
+newProductCode: String
|
||||
+changeReason: String
|
||||
+effectiveDate: String
|
||||
+validate(): void
|
||||
}
|
||||
}
|
||||
|
||||
package "dto.response" {
|
||||
class MockBillResponse {
|
||||
+resultCode: String
|
||||
+resultMessage: String
|
||||
+billInfo: BillInfo
|
||||
}
|
||||
|
||||
class MockProductChangeResponse {
|
||||
+resultCode: String
|
||||
+resultMessage: String
|
||||
+transactionId: String
|
||||
+changeInfo: ProductChangeResult
|
||||
}
|
||||
|
||||
class KosCustomerResponse {
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+customerName: String
|
||||
+customerType: String
|
||||
+status: String
|
||||
+currentProduct: KosProduct
|
||||
}
|
||||
|
||||
class KosProductResponse {
|
||||
+productCode: String
|
||||
+productName: String
|
||||
+monthlyFee: Integer
|
||||
+dataLimit: Integer
|
||||
+voiceLimit: Integer
|
||||
+saleStatus: String
|
||||
}
|
||||
|
||||
class KosLineStatusResponse {
|
||||
+lineNumber: String
|
||||
+status: String
|
||||
+activationDate: LocalDate
|
||||
+contractInfo: ContractInfo
|
||||
}
|
||||
}
|
||||
|
||||
package "dto.model" {
|
||||
class BillInfo {
|
||||
+phoneNumber: String
|
||||
+billMonth: String
|
||||
+productName: String
|
||||
+contractInfo: ContractInfo
|
||||
+billAmount: BillAmount
|
||||
+discountInfo: List<DiscountInfo>
|
||||
+usage: UsageInfo
|
||||
+installment: InstallmentInfo
|
||||
+terminationFee: TerminationFeeInfo
|
||||
+billingPaymentInfo: BillingPaymentInfo
|
||||
}
|
||||
|
||||
class ProductChangeResult {
|
||||
+lineNumber: String
|
||||
+newProductCode: String
|
||||
+newProductName: String
|
||||
+changeDate: String
|
||||
+effectiveDate: String
|
||||
+monthlyFee: Integer
|
||||
+processResult: String
|
||||
+resultMessage: String
|
||||
}
|
||||
|
||||
class ContractInfo {
|
||||
+contractType: String
|
||||
+contractStartDate: LocalDate
|
||||
+contractEndDate: LocalDate
|
||||
+remainingMonths: Integer
|
||||
+penaltyAmount: Integer
|
||||
}
|
||||
|
||||
class BillAmount {
|
||||
+basicFee: Integer
|
||||
+callFee: Integer
|
||||
+dataFee: Integer
|
||||
+smsFee: Integer
|
||||
+additionalFee: Integer
|
||||
+discountAmount: Integer
|
||||
+totalAmount: Integer
|
||||
}
|
||||
|
||||
class UsageInfo {
|
||||
+voiceUsage: Integer
|
||||
+dataUsage: Integer
|
||||
+smsUsage: Integer
|
||||
+voiceLimit: Integer
|
||||
+dataLimit: Integer
|
||||
+smsLimit: Integer
|
||||
}
|
||||
|
||||
class DiscountInfo {
|
||||
+discountType: String
|
||||
+discountName: String
|
||||
+discountAmount: Integer
|
||||
+discountRate: BigDecimal
|
||||
}
|
||||
|
||||
class InstallmentInfo {
|
||||
+deviceModel: String
|
||||
+totalAmount: Integer
|
||||
+monthlyAmount: Integer
|
||||
+paidAmount: Integer
|
||||
+remainingAmount: Integer
|
||||
+remainingMonths: Integer
|
||||
}
|
||||
|
||||
class TerminationFeeInfo {
|
||||
+contractPenalty: Integer
|
||||
+installmentRemaining: Integer
|
||||
+otherFees: Integer
|
||||
+totalFee: Integer
|
||||
}
|
||||
|
||||
class BillingPaymentInfo {
|
||||
+dueDate: LocalDate
|
||||
+paymentDate: LocalDate
|
||||
+paymentStatus: String
|
||||
}
|
||||
|
||||
class ValidationResult {
|
||||
+valid: Boolean
|
||||
+errorCode: String
|
||||
+errorMessage: String
|
||||
+errorDetails: String
|
||||
}
|
||||
|
||||
class MockScenario {
|
||||
+type: String
|
||||
+delay: Long
|
||||
+errorCode: String
|
||||
+errorMessage: String
|
||||
}
|
||||
|
||||
class KosProduct {
|
||||
+productCode: String
|
||||
+productName: String
|
||||
+productType: String
|
||||
+monthlyFee: Integer
|
||||
+dataLimit: Integer
|
||||
+voiceLimit: Integer
|
||||
+smsLimit: Integer
|
||||
+saleStatus: String
|
||||
}
|
||||
}
|
||||
|
||||
package "repository" {
|
||||
interface MockDataRepository {
|
||||
+getMockBillTemplate(lineNumber: String): Optional<KosCustomerEntity>
|
||||
+getProductInfo(productCode: String): Optional<KosProductEntity>
|
||||
+getAvailableProducts(): List<KosProductEntity>
|
||||
+getCustomerInfo(customerId: String): Optional<KosCustomerEntity>
|
||||
+saveProductChangeResult(changeRequest: KosProductChangeRequest, result: ProductChangeResult): KosProductChangeHistoryEntity
|
||||
+checkProductCompatibility(currentProductCode: String, newProductCode: String): Boolean
|
||||
+getCustomerBalance(customerId: String): Integer
|
||||
+getContractInfo(customerId: String): Optional<KosContractEntity>
|
||||
}
|
||||
|
||||
class MockDataRepositoryImpl {
|
||||
-customerJpaRepository: KosCustomerJpaRepository
|
||||
-productJpaRepository: KosProductJpaRepository
|
||||
-billJpaRepository: KosBillJpaRepository
|
||||
-usageJpaRepository: KosUsageJpaRepository
|
||||
-discountJpaRepository: KosDiscountJpaRepository
|
||||
-contractJpaRepository: KosContractJpaRepository
|
||||
-installmentJpaRepository: KosInstallmentJpaRepository
|
||||
-terminationFeeJpaRepository: KosTerminationFeeJpaRepository
|
||||
-changeHistoryJpaRepository: KosProductChangeHistoryJpaRepository
|
||||
+getMockBillTemplate(lineNumber: String): Optional<KosCustomerEntity>
|
||||
+getProductInfo(productCode: String): Optional<KosProductEntity>
|
||||
+getAvailableProducts(): List<KosProductEntity>
|
||||
+getCustomerInfo(customerId: String): Optional<KosCustomerEntity>
|
||||
+saveProductChangeResult(changeRequest: KosProductChangeRequest, result: ProductChangeResult): KosProductChangeHistoryEntity
|
||||
+checkProductCompatibility(currentProductCode: String, newProductCode: String): Boolean
|
||||
+getCustomerBalance(customerId: String): Integer
|
||||
+getContractInfo(customerId: String): Optional<KosContractEntity>
|
||||
-buildBillInfo(customer: KosCustomerEntity, inquiryMonth: String): BillInfo
|
||||
-calculateUsage(customer: KosCustomerEntity, inquiryMonth: String): UsageInfo
|
||||
}
|
||||
}
|
||||
|
||||
package "repository.entity" {
|
||||
class KosCustomerEntity {
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+customerName: String
|
||||
+customerType: String
|
||||
+status: String
|
||||
+regDate: LocalDate
|
||||
+currentProductCode: String
|
||||
+createdAt: LocalDateTime
|
||||
+updatedAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosProductEntity {
|
||||
+productCode: String
|
||||
+productName: String
|
||||
+productType: String
|
||||
+monthlyFee: Integer
|
||||
+dataLimit: Integer
|
||||
+voiceLimit: Integer
|
||||
+smsLimit: Integer
|
||||
+saleStatus: String
|
||||
+saleStartDate: LocalDate
|
||||
+saleEndDate: LocalDate
|
||||
+createdAt: LocalDateTime
|
||||
+updatedAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosBillEntity {
|
||||
+billId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+billMonth: String
|
||||
+productCode: String
|
||||
+productName: String
|
||||
+basicFee: Integer
|
||||
+callFee: Integer
|
||||
+dataFee: Integer
|
||||
+smsFee: Integer
|
||||
+additionalFee: Integer
|
||||
+discountAmount: Integer
|
||||
+totalAmount: Integer
|
||||
+paymentStatus: String
|
||||
+dueDate: LocalDate
|
||||
+paymentDate: LocalDate
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosUsageEntity {
|
||||
+usageId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+usageMonth: String
|
||||
+voiceUsage: Integer
|
||||
+dataUsage: Integer
|
||||
+smsUsage: Integer
|
||||
+voiceLimit: Integer
|
||||
+dataLimit: Integer
|
||||
+smsLimit: Integer
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosDiscountEntity {
|
||||
+discountId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+billMonth: String
|
||||
+discountType: String
|
||||
+discountName: String
|
||||
+discountAmount: Integer
|
||||
+discountRate: BigDecimal
|
||||
+applyStartDate: LocalDate
|
||||
+applyEndDate: LocalDate
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosContractEntity {
|
||||
+contractId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+contractType: String
|
||||
+contractStartDate: LocalDate
|
||||
+contractEndDate: LocalDate
|
||||
+contractStatus: String
|
||||
+penaltyAmount: Integer
|
||||
+remainingMonths: Integer
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosInstallmentEntity {
|
||||
+installmentId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+deviceModel: String
|
||||
+totalAmount: Integer
|
||||
+monthlyAmount: Integer
|
||||
+paidAmount: Integer
|
||||
+remainingAmount: Integer
|
||||
+installmentMonths: Integer
|
||||
+remainingMonths: Integer
|
||||
+startDate: LocalDate
|
||||
+endDate: LocalDate
|
||||
+status: String
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosTerminationFeeEntity {
|
||||
+feeId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+contractPenalty: Integer
|
||||
+installmentRemaining: Integer
|
||||
+otherFees: Integer
|
||||
+totalFee: Integer
|
||||
+calculatedDate: LocalDate
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
|
||||
class KosProductChangeHistoryEntity {
|
||||
+historyId: Long
|
||||
+customerId: String
|
||||
+phoneNumber: String
|
||||
+requestId: String
|
||||
+beforeProductCode: String
|
||||
+afterProductCode: String
|
||||
+changeStatus: String
|
||||
+changeDate: LocalDate
|
||||
+processResult: String
|
||||
+resultMessage: String
|
||||
+requestDatetime: LocalDateTime
|
||||
+processDatetime: LocalDateTime
|
||||
+createdAt: LocalDateTime
|
||||
}
|
||||
}
|
||||
|
||||
package "repository.jpa" {
|
||||
interface KosCustomerJpaRepository {
|
||||
+findByPhoneNumber(phoneNumber: String): Optional<KosCustomerEntity>
|
||||
+findByCustomerId(customerId: String): Optional<KosCustomerEntity>
|
||||
}
|
||||
|
||||
interface KosProductJpaRepository {
|
||||
+findByProductCode(productCode: String): Optional<KosProductEntity>
|
||||
+findBySaleStatus(saleStatus: String): List<KosProductEntity>
|
||||
}
|
||||
|
||||
interface KosBillJpaRepository {
|
||||
+findByPhoneNumberAndBillMonth(phoneNumber: String, billMonth: String): Optional<KosBillEntity>
|
||||
+findByCustomerIdAndBillMonth(customerId: String, billMonth: String): Optional<KosBillEntity>
|
||||
}
|
||||
|
||||
interface KosUsageJpaRepository {
|
||||
+findByPhoneNumberAndUsageMonth(phoneNumber: String, usageMonth: String): Optional<KosUsageEntity>
|
||||
}
|
||||
|
||||
interface KosDiscountJpaRepository {
|
||||
+findByPhoneNumberAndBillMonth(phoneNumber: String, billMonth: String): List<KosDiscountEntity>
|
||||
}
|
||||
|
||||
interface KosContractJpaRepository {
|
||||
+findByCustomerId(customerId: String): Optional<KosContractEntity>
|
||||
+findByPhoneNumber(phoneNumber: String): Optional<KosContractEntity>
|
||||
}
|
||||
|
||||
interface KosInstallmentJpaRepository {
|
||||
+findByCustomerIdAndStatus(customerId: String, status: String): List<KosInstallmentEntity>
|
||||
}
|
||||
|
||||
interface KosTerminationFeeJpaRepository {
|
||||
+findByCustomerId(customerId: String): Optional<KosTerminationFeeEntity>
|
||||
}
|
||||
|
||||
interface KosProductChangeHistoryJpaRepository {
|
||||
+findByRequestId(requestId: String): Optional<KosProductChangeHistoryEntity>
|
||||
+findByPhoneNumberOrderByRequestDatetimeDesc(phoneNumber: String): List<KosProductChangeHistoryEntity>
|
||||
}
|
||||
}
|
||||
|
||||
package "config" {
|
||||
class MockProperties {
|
||||
+scenario: MockScenarioProperties
|
||||
+delay: MockDelayProperties
|
||||
+error: MockErrorProperties
|
||||
}
|
||||
|
||||
class MockScenarioProperties {
|
||||
+successLineNumbers: List<String>
|
||||
+noDataLineNumbers: List<String>
|
||||
+systemErrorLineNumbers: List<String>
|
||||
+timeoutLineNumbers: List<String>
|
||||
}
|
||||
|
||||
class MockDelayProperties {
|
||||
+billInquiry: Long
|
||||
+productChange: Long
|
||||
+timeout: Long
|
||||
}
|
||||
|
||||
class MockErrorProperties {
|
||||
+rate: Double
|
||||
+enabled: Boolean
|
||||
}
|
||||
|
||||
class KosMockConfig {
|
||||
+mockProperties(): MockProperties
|
||||
+mockScenarioService(properties: MockProperties): MockScenarioService
|
||||
+taskExecutor(): ThreadPoolTaskExecutor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
package "Common Module" {
|
||||
package "dto" {
|
||||
class ApiResponse<T> {
|
||||
-success: boolean
|
||||
-message: String
|
||||
-data: T
|
||||
-timestamp: LocalDateTime
|
||||
}
|
||||
|
||||
class ErrorResponse {
|
||||
-code: String
|
||||
-message: String
|
||||
-details: String
|
||||
-timestamp: LocalDateTime
|
||||
}
|
||||
}
|
||||
|
||||
package "entity" {
|
||||
abstract class BaseTimeEntity {
|
||||
#createdAt: LocalDateTime
|
||||
#updatedAt: LocalDateTime
|
||||
}
|
||||
}
|
||||
|
||||
package "exception" {
|
||||
enum ErrorCode {
|
||||
BILL002("KOS 연동 실패")
|
||||
PROD001("상품변경 실패")
|
||||
SYS002("외부 연동 실패")
|
||||
}
|
||||
|
||||
class BusinessException {
|
||||
-errorCode: ErrorCode
|
||||
-details: String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
' 관계 설정
|
||||
KosMockController --> KosMockService : uses
|
||||
KosMockService --> BillDataService : uses
|
||||
KosMockService --> ProductDataService : uses
|
||||
KosMockService --> MockScenarioService : uses
|
||||
BillDataService --> MockDataRepository : uses
|
||||
ProductDataService --> MockDataRepository : uses
|
||||
ProductDataService --> ProductValidationService : uses
|
||||
ProductValidationService --> MockDataRepository : uses
|
||||
MockScenarioService --> MockProperties : uses
|
||||
|
||||
MockDataRepositoryImpl ..|> MockDataRepository : implements
|
||||
MockDataRepositoryImpl --> KosCustomerJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosProductJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosBillJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosUsageJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosDiscountJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosContractJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosInstallmentJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosTerminationFeeJpaRepository : uses
|
||||
MockDataRepositoryImpl --> KosProductChangeHistoryJpaRepository : uses
|
||||
|
||||
KosCustomerJpaRepository --> KosCustomerEntity : manages
|
||||
KosProductJpaRepository --> KosProductEntity : manages
|
||||
KosBillJpaRepository --> KosBillEntity : manages
|
||||
KosUsageJpaRepository --> KosUsageEntity : manages
|
||||
KosDiscountJpaRepository --> KosDiscountEntity : manages
|
||||
KosContractJpaRepository --> KosContractEntity : manages
|
||||
KosInstallmentJpaRepository --> KosInstallmentEntity : manages
|
||||
KosTerminationFeeJpaRepository --> KosTerminationFeeEntity : manages
|
||||
KosProductChangeHistoryJpaRepository --> KosProductChangeHistoryEntity : manages
|
||||
|
||||
' BaseTimeEntity 상속
|
||||
KosCustomerEntity --|> BaseTimeEntity
|
||||
KosProductEntity --|> BaseTimeEntity
|
||||
KosBillEntity --|> BaseTimeEntity
|
||||
KosUsageEntity --|> BaseTimeEntity
|
||||
KosDiscountEntity --|> BaseTimeEntity
|
||||
KosContractEntity --|> BaseTimeEntity
|
||||
KosInstallmentEntity --|> BaseTimeEntity
|
||||
KosTerminationFeeEntity --|> BaseTimeEntity
|
||||
KosProductChangeHistoryEntity --|> BaseTimeEntity
|
||||
|
||||
' DTO 관계
|
||||
KosMockController --> KosBillRequest : uses
|
||||
KosMockController --> KosProductChangeRequest : uses
|
||||
KosMockController --> MockBillResponse : creates
|
||||
KosMockController --> MockProductChangeResponse : creates
|
||||
KosMockController --> KosCustomerResponse : creates
|
||||
KosMockController --> KosProductResponse : creates
|
||||
KosMockController --> KosLineStatusResponse : creates
|
||||
|
||||
MockBillResponse --> BillInfo : contains
|
||||
MockProductChangeResponse --> ProductChangeResult : contains
|
||||
BillInfo --> ContractInfo : contains
|
||||
BillInfo --> BillAmount : contains
|
||||
BillInfo --> UsageInfo : contains
|
||||
BillInfo --> InstallmentInfo : contains
|
||||
BillInfo --> TerminationFeeInfo : contains
|
||||
BillInfo --> BillingPaymentInfo : contains
|
||||
BillInfo --> DiscountInfo : contains
|
||||
|
||||
' 공통 모듈 사용
|
||||
KosMockController --> ApiResponse : uses
|
||||
KosMockService --> BusinessException : throws
|
||||
ProductValidationService --> ValidationResult : creates
|
||||
MockScenarioService --> MockScenario : creates
|
||||
|
||||
@enduml
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user