mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
release
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+239
@@ -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);
|
||||
}
|
||||
+246
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user