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": "개발실행프롬프트 내용을 터미널에 출력"
|
- "@develop-help": "개발실행프롬프트 내용을 터미널에 출력"
|
||||||
- "@deploy-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