This commit is contained in:
hiondal
2025-09-09 01:12:14 +09:00
parent 7ec8a682c6
commit b489c73201
276 changed files with 43859 additions and 98 deletions
+50
View File
@@ -0,0 +1,50 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="kos-mock" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8084" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/kos-mock.log" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<!-- Redis Configuration (Optional - KOS Mock에서는 캐시 기능 비활성화 상태) -->
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_DATABASE" value="4" />
<!-- KOS Mock Specific Settings -->
<entry key="KOS_MOCK_DELAY_MIN" value="100" />
<entry key="KOS_MOCK_DELAY_MAX" value="500" />
<entry key="KOS_MOCK_ERROR_RATE" value="0.05" />
<!-- Development Settings -->
<entry key="CORS_ALLOWED_ORIGINS" value="*" />
</map>
</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="kos-mock:bootRun" />
</list>
</option>
<option name="vmOptions" value="-Dspring.profiles.active=dev -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+165
View File
@@ -0,0 +1,165 @@
# KOS Mock Service
KT 통신사 시스템(KOS-Order)을 모방한 Mock 서비스입니다.
## 개요
KOS Mock Service는 통신요금 관리 서비스의 다른 마이크로서비스들이 외부 시스템과의 연동을 테스트할 수 있도록 하는 내부 Mock 서비스입니다.
## 주요 기능
### 1. 요금 조회 Mock API
- 고객의 통신요금 정보 조회
- 회선번호 기반 요금 데이터 제공
- 다양한 오류 상황 시뮬레이션
### 2. 상품 변경 Mock API
- 고객의 통신상품 변경 처리
- 상품 변경 가능성 검증
- KOS 주문 번호 생성
### 3. Mock 데이터 관리
- 테스트용 고객 데이터 제공
- 요금제별 Mock 상품 데이터
- 청구월별 요금 이력 데이터
## 기술 스택
- **Framework**: Spring Boot 3.2
- **Language**: Java 17
- **Documentation**: Swagger/OpenAPI 3.0
- **Cache**: Redis (선택적)
- **Test**: JUnit 5, MockMvc
## API 엔드포인트
### 기본 정보
- **Base URL**: `http://localhost:8080/kos-mock`
- **API Version**: v1
- **Content-Type**: `application/json`
### 주요 API
#### 1. 요금 조회 API
```http
POST /api/v1/kos/bill/inquiry
```
**요청 예시:**
```json
{
"lineNumber": "01012345678",
"billingMonth": "202501",
"requestId": "REQ_20250108_001",
"requestorId": "BILL_SERVICE"
}
```
#### 2. 상품 변경 API
```http
POST /api/v1/kos/product/change
```
**요청 예시:**
```json
{
"lineNumber": "01012345678",
"currentProductCode": "LTE-BASIC-001",
"targetProductCode": "5G-PREMIUM-001",
"requestId": "REQ_20250108_002",
"requestorId": "PRODUCT_SERVICE",
"changeReason": "고객 요청에 의한 상품 변경"
}
```
#### 3. 서비스 상태 체크 API
```http
GET /api/v1/kos/health
```
## Mock 데이터
### 테스트용 회선번호
- `01012345678` - 김테스트 (5G 프리미엄)
- `01087654321` - 이샘플 (5G 스탠다드)
- `01055554444` - 박데모 (LTE 프리미엄)
- `01099998888` - 최모의 (LTE 베이직)
- `01000000000` - 비활성사용자 (정지 상태)
### 상품 코드
- `5G-PREMIUM-001` - 5G 프리미엄 플랜 (89,000원)
- `5G-STANDARD-001` - 5G 스탠다드 플랜 (69,000원)
- `LTE-PREMIUM-001` - LTE 프리미엄 플랜 (59,000원)
- `LTE-BASIC-001` - LTE 베이직 플랜 (39,000원)
- `3G-OLD-001` - 3G 레거시 플랜 (판매 중단)
## 실행 방법
### 1. 개발 환경에서 실행
```bash
./gradlew bootRun
```
### 2. JAR 파일로 실행
```bash
./gradlew build
java -jar build/libs/kos-mock-service-1.0.0.jar
```
### 3. 특정 프로파일로 실행
```bash
java -jar kos-mock-service-1.0.0.jar --spring.profiles.active=prod
```
## 설정
### Mock 응답 지연 설정
```yaml
kos:
mock:
response-delay: 1000 # 밀리초
failure-rate: 0.05 # 5% 실패율
```
### Redis 설정 (선택적)
```yaml
spring:
data:
redis:
host: localhost
port: 6379
```
## 테스트
### 단위 테스트 실행
```bash
./gradlew test
```
### API 테스트
Swagger UI를 통해 API를 직접 테스트할 수 있습니다:
- URL: http://localhost:8080/kos-mock/swagger-ui.html
## 모니터링
### Health Check
- URL: http://localhost:8080/kos-mock/actuator/health
### Metrics
- URL: http://localhost:8080/kos-mock/actuator/metrics
## 주의사항
1. **내부 시스템 전용**: 이 서비스는 내부 테스트 목적으로만 사용하세요.
2. **보안 설정 간소화**: Mock 서비스이므로 보안 설정이 간소화되어 있습니다.
3. **데이터 지속성**: Mock 데이터는 메모리에만 저장되며, 재시작 시 초기화됩니다.
4. **성능 제한**: 실제 부하 테스트 용도로는 적합하지 않습니다.
## 문의
KOS Mock Service 관련 문의사항이 있으시면 개발팀으로 연락해 주세요.
- 개발팀: dev@phonebill.com
- 문서 버전: v1.0.0
- 최종 업데이트: 2025-01-08
+54
View File
@@ -0,0 +1,54 @@
// kos-mock 모듈
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Database (Mock 서비스용 H2)
runtimeOnly 'com.h2database:h2'
// Swagger/OpenAPI
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
// JSON Processing
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// Commons
implementation project(':common')
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:testcontainers:1.19.3'
// Configuration Processor
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.named('test') {
useJUnitPlatform()
}
// JAR 파일 이름 설정
jar {
archiveBaseName = 'kos-mock-service'
archiveVersion = version
}
@@ -0,0 +1,40 @@
package com.phonebill.kosmock;
import com.phonebill.kosmock.data.MockDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
/**
* KOS Mock Service 메인 애플리케이션 클래스
*/
@SpringBootApplication(exclude = {
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class,
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class
})
@EnableCaching
@RequiredArgsConstructor
@Slf4j
public class KosMockApplication implements CommandLineRunner {
private final MockDataService mockDataService;
public static void main(String[] args) {
SpringApplication.run(KosMockApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
log.info("=== KOS Mock Service 시작 ===");
log.info("Mock 데이터 초기화를 시작합니다...");
mockDataService.initializeMockData();
log.info("KOS Mock Service가 성공적으로 시작되었습니다.");
log.info("Swagger UI: http://localhost:8080/kos-mock/swagger-ui.html");
log.info("Health Check: http://localhost:8080/kos-mock/actuator/health");
}
}
@@ -0,0 +1,39 @@
package com.phonebill.kosmock.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* KOS Mock 설정
*/
@Configuration
@ConfigurationProperties(prefix = "kos.mock")
@Data
public class MockConfig {
/**
* Mock 응답 지연 시간 (밀리초)
*/
private long responseDelay = 500;
/**
* Mock 실패율 (0.0 ~ 1.0)
*/
private double failureRate = 0.0;
/**
* 최대 재시도 횟수
*/
private int maxRetryCount = 3;
/**
* 타임아웃 시간 (밀리초)
*/
private long timeoutMs = 30000;
/**
* 디버그 모드 활성화 여부
*/
private boolean debugMode = false;
}
@@ -0,0 +1,40 @@
package com.phonebill.kosmock.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.SecurityFilterChain;
/**
* 보안 설정
* Mock 서비스이므로 간단한 설정만 적용합니다.
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* 보안 필터 체인 설정
* 내부 시스템용 Mock 서비스이므로 모든 요청을 허용합니다.
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 보호 비활성화 (Mock 서비스)
.csrf(AbstractHttpConfigurer::disable)
// 프레임 옵션 비활성화 (Swagger UI 사용)
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.disable())
)
// 모든 요청 허용
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}
@@ -0,0 +1,45 @@
package com.phonebill.kosmock.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Swagger/OpenAPI 설정
*/
@Configuration
public class SwaggerConfig {
@Value("${server.servlet.context-path:/}")
private String contextPath;
@Bean
public OpenAPI kosMockOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("KOS Mock Service API")
.description("KT 통신사 시스템(KOS-Order)을 모방한 Mock 서비스 API")
.version("v1.0.0")
.contact(new Contact()
.name("개발팀")
.email("dev@phonebill.com"))
.license(new License()
.name("Internal Use Only")
.url("http://www.phonebill.com/license")))
.servers(List.of(
new Server()
.url("http://localhost:8080" + contextPath)
.description("개발 환경"),
new Server()
.url("https://kos-mock.phonebill.com" + contextPath)
.description("운영 환경")
));
}
}
@@ -0,0 +1,171 @@
package com.phonebill.kosmock.controller;
import com.phonebill.kosmock.dto.*;
import com.phonebill.kosmock.service.KosMockService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* KOS Mock API 컨트롤러
* KT 통신사 시스템(KOS-Order)의 API를 모방합니다.
*/
@RestController
@RequestMapping("/api/v1/kos")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "KOS Mock API", description = "KT 통신사 시스템 Mock API")
public class KosMockController {
private final KosMockService kosMockService;
/**
* 요금 조회 API
*/
@PostMapping("/bill/inquiry")
@Operation(summary = "요금 조회", description = "고객의 통신요금 정보를 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<KosBillInquiryResponse>> inquireBill(
@Valid @RequestBody KosBillInquiryRequest request) {
log.info("요금 조회 요청 수신 - RequestId: {}, LineNumber: {}",
request.getRequestId(), request.getLineNumber());
try {
KosBillInquiryResponse response = kosMockService.processBillInquiry(request);
if ("0000".equals(response.getResultCode())) {
return ResponseEntity.ok(KosCommonResponse.success(response, "요금 조회가 완료되었습니다"));
} else {
return ResponseEntity.ok(KosCommonResponse.failure(
response.getResultCode(), response.getResultMessage()));
}
} catch (Exception e) {
log.error("요금 조회 처리 중 오류 발생 - RequestId: {}", request.getRequestId(), e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* 상품 변경 API
*/
@PostMapping("/product/change")
@Operation(summary = "상품 변경", description = "고객의 통신상품을 변경합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "변경 처리 성공",
content = @Content(schema = @Schema(implementation = KosCommonResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<KosProductChangeResponse>> changeProduct(
@Valid @RequestBody KosProductChangeRequest request) {
log.info("상품 변경 요청 수신 - RequestId: {}, LineNumber: {}, Target: {}",
request.getRequestId(), request.getLineNumber(), request.getTargetProductCode());
try {
KosProductChangeResponse response = kosMockService.processProductChange(request);
if ("0000".equals(response.getResultCode())) {
return ResponseEntity.ok(KosCommonResponse.success(response, "상품 변경이 완료되었습니다"));
} else {
return ResponseEntity.ok(KosCommonResponse.failure(
response.getResultCode(), response.getResultMessage()));
}
} catch (Exception e) {
log.error("상품 변경 처리 중 오류 발생 - RequestId: {}", request.getRequestId(), e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* 처리 상태 조회 API
*/
@GetMapping("/status/{requestId}")
@Operation(summary = "처리 상태 조회", description = "요청의 처리 상태를 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "요청 ID를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<KosCommonResponse<Object>> getProcessingStatus(
@Parameter(description = "요청 ID", example = "REQ_20250108_001")
@PathVariable String requestId) {
log.info("처리 상태 조회 요청 - RequestId: {}", requestId);
try {
// Mock 데이터에서 처리 결과 조회 로직은 간단하게 구현
// 실제로는 mockDataService.getProcessingResult(requestId) 사용
return ResponseEntity.ok(KosCommonResponse.success(
"PROCESSING 상태 - 처리 중입니다.",
"처리 상태 조회가 완료되었습니다"));
} catch (Exception e) {
log.error("처리 상태 조회 중 오류 발생 - RequestId: {}", requestId, e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* 서비스 상태 체크 API
*/
@GetMapping("/health")
@Operation(summary = "서비스 상태 체크", description = "KOS Mock 서비스의 상태를 확인합니다.")
public ResponseEntity<KosCommonResponse<Object>> healthCheck() {
log.debug("KOS Mock 서비스 상태 체크 요청");
try {
return ResponseEntity.ok(KosCommonResponse.success(
"KOS Mock Service is running normally",
"서비스가 정상 동작 중입니다"));
} catch (Exception e) {
log.error("서비스 상태 체크 중 오류 발생", e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
/**
* Mock 설정 조회 API (개발/테스트용)
*/
@GetMapping("/mock/config")
@Operation(summary = "Mock 설정 조회", description = "현재 Mock 서비스의 설정을 조회합니다. (개발/테스트용)")
public ResponseEntity<KosCommonResponse<Object>> getMockConfig() {
log.info("Mock 설정 조회 요청");
try {
// Mock 설정 정보를 간단히 반환
String configInfo = String.format(
"Response Delay: %dms, Failure Rate: %.2f%%, Service Status: ACTIVE",
500, 1.0); // 하드코딩된 값 (실제로는 MockConfig에서 가져올 수 있음)
return ResponseEntity.ok(KosCommonResponse.success(
configInfo,
"Mock 설정 조회가 완료되었습니다"));
} catch (Exception e) {
log.error("Mock 설정 조회 중 오류 발생", e);
return ResponseEntity.ok(KosCommonResponse.systemError());
}
}
}
@@ -0,0 +1,93 @@
package com.phonebill.kosmock.data;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
/**
* Mock 요금 데이터 모델
* KOS 시스템의 요금 정보를 모방합니다.
*/
@Data
@Builder
public class MockBillData {
/**
* 회선번호
*/
private String lineNumber;
/**
* 청구월 (YYYYMM)
*/
private String billingMonth;
/**
* 상품 코드
*/
private String productCode;
/**
* 상품명
*/
private String productName;
/**
* 월 기본료
*/
private BigDecimal monthlyFee;
/**
* 사용료
*/
private BigDecimal usageFee;
/**
* 총 요금
*/
private BigDecimal totalFee;
/**
* 데이터 사용량
*/
private String dataUsage;
/**
* 음성 사용량
*/
private String voiceUsage;
/**
* SMS 사용량
*/
private String smsUsage;
/**
* 청구 상태 (PENDING, CONFIRMED, PAID)
*/
private String billStatus;
/**
* 납부 기한 (YYYYMMDD)
*/
private String dueDate;
/**
* 할인 금액
*/
@Builder.Default
private BigDecimal discountAmount = BigDecimal.ZERO;
/**
* 부가세
*/
@Builder.Default
private BigDecimal vat = BigDecimal.ZERO;
/**
* 미납 금액
*/
@Builder.Default
private BigDecimal unpaidAmount = BigDecimal.ZERO;
}
@@ -0,0 +1,67 @@
package com.phonebill.kosmock.data;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Mock 고객 데이터 모델
* KOS 시스템의 고객 정보를 모방합니다.
*/
@Data
@Builder
public class MockCustomerData {
/**
* 회선번호 (Primary Key)
*/
private String lineNumber;
/**
* 고객명
*/
private String customerName;
/**
* 고객 ID
*/
private String customerId;
/**
* 통신사업자 코드 (KT, SKT, LGU+ 등)
*/
private String operatorCode;
/**
* 현재 상품 코드
*/
private String currentProductCode;
/**
* 회선 상태 (ACTIVE, SUSPENDED, TERMINATED)
*/
private String lineStatus;
/**
* 계약일시
*/
private LocalDateTime contractDate;
/**
* 최종 수정일시
*/
private LocalDateTime lastModified;
/**
* 고객 등급 (VIP, GOLD, SILVER, BRONZE)
*/
@Builder.Default
private String customerGrade = "SILVER";
/**
* 가입 유형 (INDIVIDUAL, CORPORATE)
*/
@Builder.Default
private String subscriptionType = "INDIVIDUAL";
}
@@ -0,0 +1,265 @@
package com.phonebill.kosmock.data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* KOS Mock 데이터 서비스
* 통신요금 조회 및 상품변경에 필요한 Mock 데이터를 제공합니다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MockDataService {
// Mock 사용자 데이터 (회선번호 기반)
private final Map<String, MockCustomerData> mockCustomers = new ConcurrentHashMap<>();
// Mock 상품 데이터
private final Map<String, MockProductData> mockProducts = new ConcurrentHashMap<>();
// Mock 요금 데이터
private final Map<String, MockBillData> mockBills = new ConcurrentHashMap<>();
// 요청 처리 이력
private final Map<String, MockProcessingResult> processingResults = new ConcurrentHashMap<>();
/**
* 초기 Mock 데이터 생성
*/
public void initializeMockData() {
log.info("KOS Mock 데이터 초기화 시작");
initializeMockProducts();
initializeMockCustomers();
initializeMockBills();
log.info("KOS Mock 데이터 초기화 완료 - 고객: {}, 상품: {}, 요금: {}",
mockCustomers.size(), mockProducts.size(), mockBills.size());
}
/**
* Mock 상품 데이터 초기화
*/
private void initializeMockProducts() {
// 5G 상품
mockProducts.put("5G-PREMIUM-001", MockProductData.builder()
.productCode("5G-PREMIUM-001")
.productName("5G 프리미엄 플랜")
.monthlyFee(new BigDecimal("89000"))
.dataAllowance("무제한")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 무제한 프리미엄 요금제")
.build());
mockProducts.put("5G-STANDARD-001", MockProductData.builder()
.productCode("5G-STANDARD-001")
.productName("5G 스탠다드 플랜")
.monthlyFee(new BigDecimal("69000"))
.dataAllowance("100GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("5G")
.status("ACTIVE")
.description("5G 네트워크 스탠다드 요금제")
.build());
// LTE 상품
mockProducts.put("LTE-PREMIUM-001", MockProductData.builder()
.productCode("LTE-PREMIUM-001")
.productName("LTE 프리미엄 플랜")
.monthlyFee(new BigDecimal("59000"))
.dataAllowance("50GB")
.voiceAllowance("무제한")
.smsAllowance("무제한")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 프리미엄 요금제")
.build());
mockProducts.put("LTE-BASIC-001", MockProductData.builder()
.productCode("LTE-BASIC-001")
.productName("LTE 베이직 플랜")
.monthlyFee(new BigDecimal("39000"))
.dataAllowance("20GB")
.voiceAllowance("무제한")
.smsAllowance("기본 제공")
.operatorCode("KT")
.networkType("LTE")
.status("ACTIVE")
.description("LTE 네트워크 베이직 요금제")
.build());
// 종료된 상품 (변경 불가)
mockProducts.put("3G-OLD-001", MockProductData.builder()
.productCode("3G-OLD-001")
.productName("3G 레거시 플랜")
.monthlyFee(new BigDecimal("29000"))
.dataAllowance("5GB")
.voiceAllowance("500분")
.smsAllowance("100건")
.operatorCode("KT")
.networkType("3G")
.status("DISCONTINUED")
.description("3G 네트워크 레거시 요금제 (신규 가입 불가)")
.build());
}
/**
* Mock 고객 데이터 초기화
*/
private void initializeMockCustomers() {
// 테스트용 고객 데이터
String[] testNumbers = {
"01012345678", "01087654321", "01055554444",
"01099998888", "01077776666", "01033332222"
};
String[] testNames = {
"김테스트", "이샘플", "박데모", "최모의", "정시험", "한실험"
};
String[] currentProducts = {
"5G-PREMIUM-001", "5G-STANDARD-001", "LTE-PREMIUM-001",
"LTE-BASIC-001", "3G-OLD-001", "5G-PREMIUM-001"
};
for (int i = 0; i < testNumbers.length; i++) {
mockCustomers.put(testNumbers[i], MockCustomerData.builder()
.lineNumber(testNumbers[i])
.customerName(testNames[i])
.customerId("CUST" + String.format("%06d", i + 1))
.operatorCode("KT")
.currentProductCode(currentProducts[i])
.lineStatus("ACTIVE")
.contractDate(LocalDateTime.now().minusMonths(12 + i))
.lastModified(LocalDateTime.now().minusDays(i))
.build());
}
// 비활성 회선 테스트용
mockCustomers.put("01000000000", MockCustomerData.builder()
.lineNumber("01000000000")
.customerName("비활성사용자")
.customerId("CUST999999")
.operatorCode("KT")
.currentProductCode("LTE-BASIC-001")
.lineStatus("SUSPENDED")
.contractDate(LocalDateTime.now().minusMonths(6))
.lastModified(LocalDateTime.now().minusDays(30))
.build());
}
/**
* Mock 요금 데이터 초기화
*/
private void initializeMockBills() {
for (MockCustomerData customer : mockCustomers.values()) {
MockProductData product = mockProducts.get(customer.getCurrentProductCode());
if (product != null) {
// 최근 3개월 요금 데이터 생성
for (int month = 0; month < 3; month++) {
LocalDateTime billDate = LocalDateTime.now().minusMonths(month);
String billKey = customer.getLineNumber() + "_" + billDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
BigDecimal usageFee = calculateUsageFee(product, month);
BigDecimal totalFee = product.getMonthlyFee().add(usageFee);
mockBills.put(billKey, MockBillData.builder()
.lineNumber(customer.getLineNumber())
.billingMonth(billDate.format(DateTimeFormatter.ofPattern("yyyyMM")))
.productCode(product.getProductCode())
.productName(product.getProductName())
.monthlyFee(product.getMonthlyFee())
.usageFee(usageFee)
.totalFee(totalFee)
.dataUsage(generateRandomDataUsage(product))
.voiceUsage(generateRandomVoiceUsage(product))
.smsUsage(generateRandomSmsUsage())
.billStatus("CONFIRMED")
.dueDate(billDate.plusDays(25).format(DateTimeFormatter.ofPattern("yyyyMMdd")))
.build());
}
}
}
}
private BigDecimal calculateUsageFee(MockProductData product, int month) {
// 간단한 사용료 계산 로직 (랜덤하게 0~30000원)
Random random = new Random();
return new BigDecimal(random.nextInt(30000));
}
private String generateRandomDataUsage(MockProductData product) {
Random random = new Random();
if ("무제한".equals(product.getDataAllowance())) {
return random.nextInt(200) + "GB";
} else {
int allowance = Integer.parseInt(product.getDataAllowance().replace("GB", ""));
return random.nextInt(allowance) + "GB";
}
}
private String generateRandomVoiceUsage(MockProductData product) {
Random random = new Random();
if ("무제한".equals(product.getVoiceAllowance())) {
return random.nextInt(500) + "";
} else {
int allowance = Integer.parseInt(product.getVoiceAllowance().replace("", ""));
return random.nextInt(allowance) + "";
}
}
private String generateRandomSmsUsage() {
Random random = new Random();
return random.nextInt(100) + "";
}
// Getter methods
public MockCustomerData getCustomerData(String lineNumber) {
return mockCustomers.get(lineNumber);
}
public MockProductData getProductData(String productCode) {
return mockProducts.get(productCode);
}
public MockBillData getBillData(String lineNumber, String billingMonth) {
return mockBills.get(lineNumber + "_" + billingMonth);
}
public List<MockProductData> getAllAvailableProducts() {
return mockProducts.values().stream()
.filter(product -> "ACTIVE".equals(product.getStatus()))
.sorted(Comparator.comparing(MockProductData::getMonthlyFee).reversed())
.toList();
}
public void saveProcessingResult(String requestId, MockProcessingResult result) {
processingResults.put(requestId, result);
}
public MockProcessingResult getProcessingResult(String requestId) {
return processingResults.get(requestId);
}
public List<MockBillData> getBillHistory(String lineNumber) {
return mockBills.values().stream()
.filter(bill -> lineNumber.equals(bill.getLineNumber()))
.sorted(Comparator.comparing(MockBillData::getBillingMonth).reversed())
.toList();
}
}
@@ -0,0 +1,71 @@
package com.phonebill.kosmock.data;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Mock 처리 결과 데이터 모델
* KOS 시스템의 비동기 처리 결과를 모방합니다.
*/
@Data
@Builder
public class MockProcessingResult {
/**
* 요청 ID
*/
private String requestId;
/**
* 처리 유형 (BILL_INQUIRY, PRODUCT_CHANGE)
*/
private String processingType;
/**
* 처리 상태 (PROCESSING, SUCCESS, FAILURE)
*/
private String status;
/**
* 처리 결과 메시지
*/
private String message;
/**
* 처리 결과 데이터 (JSON String)
*/
private String resultData;
/**
* 요청 일시
*/
private LocalDateTime requestedAt;
/**
* 처리 완료 일시
*/
private LocalDateTime completedAt;
/**
* 오류 코드 (실패 시)
*/
private String errorCode;
/**
* 오류 상세 메시지 (실패 시)
*/
private String errorDetails;
/**
* 재시도 횟수
*/
@Builder.Default
private Integer retryCount = 0;
/**
* 처리 소요 시간 (밀리초)
*/
private Long processingTimeMs;
}
@@ -0,0 +1,83 @@
package com.phonebill.kosmock.data;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
/**
* Mock 상품 데이터 모델
* KOS 시스템의 상품 정보를 모방합니다.
*/
@Data
@Builder
public class MockProductData {
/**
* 상품 코드 (Primary Key)
*/
private String productCode;
/**
* 상품명
*/
private String productName;
/**
* 월 기본료
*/
private BigDecimal monthlyFee;
/**
* 데이터 제공량 (예: "100GB", "무제한")
*/
private String dataAllowance;
/**
* 음성 제공량 (예: "300분", "무제한")
*/
private String voiceAllowance;
/**
* SMS 제공량 (예: "100건", "기본 제공")
*/
private String smsAllowance;
/**
* 통신사업자 코드 (KT, SKT, LGU+ 등)
*/
private String operatorCode;
/**
* 네트워크 타입 (5G, LTE, 3G)
*/
private String networkType;
/**
* 상품 상태 (ACTIVE, DISCONTINUED)
*/
private String status;
/**
* 상품 설명
*/
private String description;
/**
* 최소 이용기간 (개월)
*/
@Builder.Default
private Integer minimumUsagePeriod = 12;
/**
* 약정 할인 가능 여부
*/
@Builder.Default
private Boolean discountAvailable = true;
/**
* 요금제 유형 (POSTPAID, PREPAID)
*/
@Builder.Default
private String planType = "POSTPAID";
}
@@ -0,0 +1,30 @@
package com.phonebill.kosmock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* KOS 요금 조회 요청 DTO
*/
@Data
@Schema(description = "KOS 요금 조회 요청")
public class KosBillInquiryRequest {
@Schema(description = "회선번호", example = "01012345678", required = true)
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
private String lineNumber;
@Schema(description = "청구월 (YYYYMM)", example = "202501")
@Pattern(regexp = "^\\d{6}$", message = "청구월은 YYYYMM 형식이어야 합니다")
private String billingMonth;
@Schema(description = "요청 ID", example = "REQ_20250108_001", required = true)
@NotBlank(message = "요청 ID는 필수입니다")
private String requestId;
@Schema(description = "요청자 ID", example = "BILL_SERVICE")
private String requestorId;
}
@@ -0,0 +1,94 @@
package com.phonebill.kosmock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
/**
* KOS 요금 조회 응답 DTO
*/
@Data
@Builder
@Schema(description = "KOS 요금 조회 응답")
public class KosBillInquiryResponse {
@Schema(description = "요청 ID", example = "REQ_20250108_001")
private String requestId;
@Schema(description = "처리 결과 코드", example = "0000")
private String resultCode;
@Schema(description = "처리 결과 메시지", example = "정상 처리되었습니다")
private String resultMessage;
@Schema(description = "요금 정보")
private BillInfo billInfo;
@Schema(description = "고객 정보")
private CustomerInfo customerInfo;
@Data
@Builder
@Schema(description = "요금 정보")
public static class BillInfo {
@Schema(description = "회선번호", example = "01012345678")
private String lineNumber;
@Schema(description = "청구월", example = "202501")
private String billingMonth;
@Schema(description = "상품 코드", example = "5G-PREMIUM-001")
private String productCode;
@Schema(description = "상품명", example = "5G 프리미엄 플랜")
private String productName;
@Schema(description = "월 기본료", example = "89000")
private BigDecimal monthlyFee;
@Schema(description = "사용료", example = "15000")
private BigDecimal usageFee;
@Schema(description = "할인 금액", example = "5000")
private BigDecimal discountAmount;
@Schema(description = "총 요금", example = "99000")
private BigDecimal totalFee;
@Schema(description = "데이터 사용량", example = "150GB")
private String dataUsage;
@Schema(description = "음성 사용량", example = "250분")
private String voiceUsage;
@Schema(description = "SMS 사용량", example = "50건")
private String smsUsage;
@Schema(description = "청구 상태", example = "CONFIRMED")
private String billStatus;
@Schema(description = "납부 기한", example = "20250125")
private String dueDate;
}
@Data
@Builder
@Schema(description = "고객 정보")
public static class CustomerInfo {
@Schema(description = "고객명", example = "김테스트")
private String customerName;
@Schema(description = "고객 ID", example = "CUST000001")
private String customerId;
@Schema(description = "통신사업자 코드", example = "KT")
private String operatorCode;
@Schema(description = "회선 상태", example = "ACTIVE")
private String lineStatus;
}
}
@@ -0,0 +1,84 @@
package com.phonebill.kosmock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* KOS 공통 응답 DTO
*/
@Data
@Builder
@Schema(description = "KOS 공통 응답")
public class KosCommonResponse<T> {
@Schema(description = "성공 여부", example = "true")
private Boolean success;
@Schema(description = "처리 결과 코드", example = "0000")
private String resultCode;
@Schema(description = "처리 결과 메시지", example = "정상 처리되었습니다")
private String resultMessage;
@Schema(description = "응답 데이터")
private T data;
@Schema(description = "처리 시간", example = "2025-01-08T14:30:00")
private LocalDateTime timestamp;
@Schema(description = "요청 추적 ID", example = "TRACE_20250108_001")
private String traceId;
/**
* 성공 응답 생성
*/
public static <T> KosCommonResponse<T> success(T data) {
return KosCommonResponse.<T>builder()
.success(true)
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 성공 응답 생성 (메시지 포함)
*/
public static <T> KosCommonResponse<T> success(T data, String message) {
return KosCommonResponse.<T>builder()
.success(true)
.resultCode("0000")
.resultMessage(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 실패 응답 생성
*/
public static <T> KosCommonResponse<T> failure(String errorCode, String errorMessage) {
return KosCommonResponse.<T>builder()
.success(false)
.resultCode(errorCode)
.resultMessage(errorMessage)
.timestamp(LocalDateTime.now())
.build();
}
/**
* 시스템 오류 응답 생성
*/
public static <T> KosCommonResponse<T> systemError() {
return KosCommonResponse.<T>builder()
.success(false)
.resultCode("9999")
.resultMessage("시스템 오류가 발생했습니다")
.timestamp(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,41 @@
package com.phonebill.kosmock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* KOS 상품 변경 요청 DTO
*/
@Data
@Schema(description = "KOS 상품 변경 요청")
public class KosProductChangeRequest {
@Schema(description = "회선번호", example = "01012345678", required = true)
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010\\d{8}$", message = "올바른 회선번호 형식이 아닙니다")
private String lineNumber;
@Schema(description = "현재 상품 코드", example = "LTE-BASIC-001", required = true)
@NotBlank(message = "현재 상품 코드는 필수입니다")
private String currentProductCode;
@Schema(description = "변경할 상품 코드", example = "5G-PREMIUM-001", required = true)
@NotBlank(message = "변경할 상품 코드는 필수입니다")
private String targetProductCode;
@Schema(description = "요청 ID", example = "REQ_20250108_002", required = true)
@NotBlank(message = "요청 ID는 필수입니다")
private String requestId;
@Schema(description = "요청자 ID", example = "PRODUCT_SERVICE")
private String requestorId;
@Schema(description = "변경 사유", example = "고객 요청에 의한 상품 변경")
private String changeReason;
@Schema(description = "적용 일자 (YYYYMMDD)", example = "20250115")
@Pattern(regexp = "^\\d{8}$", message = "적용 일자는 YYYYMMDD 형식이어야 합니다")
private String effectiveDate;
}
@@ -0,0 +1,59 @@
package com.phonebill.kosmock.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* KOS 상품 변경 응답 DTO
*/
@Data
@Builder
@Schema(description = "KOS 상품 변경 응답")
public class KosProductChangeResponse {
@Schema(description = "요청 ID", example = "REQ_20250108_002")
private String requestId;
@Schema(description = "처리 결과 코드", example = "0000")
private String resultCode;
@Schema(description = "처리 결과 메시지", example = "정상 처리되었습니다")
private String resultMessage;
@Schema(description = "변경 처리 정보")
private ChangeInfo changeInfo;
@Data
@Builder
@Schema(description = "변경 처리 정보")
public static class ChangeInfo {
@Schema(description = "회선번호", example = "01012345678")
private String lineNumber;
@Schema(description = "이전 상품 코드", example = "LTE-BASIC-001")
private String previousProductCode;
@Schema(description = "이전 상품명", example = "LTE 베이직 플랜")
private String previousProductName;
@Schema(description = "새로운 상품 코드", example = "5G-PREMIUM-001")
private String newProductCode;
@Schema(description = "새로운 상품명", example = "5G 프리미엄 플랜")
private String newProductName;
@Schema(description = "변경 적용 일자", example = "20250115")
private String effectiveDate;
@Schema(description = "변경 처리 상태", example = "SUCCESS")
private String changeStatus;
@Schema(description = "KOS 주문 번호", example = "KOS20250108001")
private String kosOrderNumber;
@Schema(description = "예상 처리 완료 시간", example = "2025-01-08T15:30:00")
private String estimatedCompletionTime;
}
}
@@ -0,0 +1,138 @@
package com.phonebill.kosmock.exception;
import com.phonebill.kosmock.dto.KosCommonResponse;
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.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import java.util.stream.Collectors;
/**
* 전역 예외 처리 핸들러
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* Bean Validation 실패 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<KosCommonResponse<Object>> handleValidationException(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.warn("입력값 검증 실패: {}", errorMessage);
return ResponseEntity.badRequest()
.body(KosCommonResponse.failure("9001", "입력값이 올바르지 않습니다: " + errorMessage));
}
/**
* Bean Binding 실패 처리
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<KosCommonResponse<Object>> handleBindException(BindException e) {
String errorMessage = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.warn("데이터 바인딩 실패: {}", errorMessage);
return ResponseEntity.badRequest()
.body(KosCommonResponse.failure("9002", "데이터 바인딩에 실패했습니다: " + errorMessage));
}
/**
* HTTP 메시지 읽기 실패 처리
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<KosCommonResponse<Object>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.warn("HTTP 메시지 읽기 실패", e);
return ResponseEntity.badRequest()
.body(KosCommonResponse.failure("9003", "요청 데이터 형식이 올바르지 않습니다"));
}
/**
* 메서드 인자 타입 불일치 처리
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<KosCommonResponse<Object>> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
log.warn("메서드 인자 타입 불일치: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(KosCommonResponse.failure("9004", "요청 파라미터 타입이 올바르지 않습니다"));
}
/**
* 지원하지 않는 HTTP 메서드 처리
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<KosCommonResponse<Object>> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.warn("지원하지 않는 HTTP 메서드: {}", e.getMethod());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(KosCommonResponse.failure("9005", "지원하지 않는 HTTP 메서드입니다"));
}
/**
* 핸들러를 찾을 수 없음 처리
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<KosCommonResponse<Object>> handleNoHandlerFoundException(NoHandlerFoundException e) {
log.warn("핸들러를 찾을 수 없음: {}", e.getRequestURL());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(KosCommonResponse.failure("9006", "요청한 API를 찾을 수 없습니다"));
}
/**
* KOS Mock 특화 예외 처리
*/
@ExceptionHandler(KosMockException.class)
public ResponseEntity<KosCommonResponse<Object>> handleKosMockException(KosMockException e) {
log.warn("KOS Mock 예외 발생: {}", e.getMessage());
return ResponseEntity.ok()
.body(KosCommonResponse.failure(e.getErrorCode(), e.getMessage()));
}
/**
* 런타임 예외 처리
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<KosCommonResponse<Object>> handleRuntimeException(RuntimeException e) {
log.error("런타임 예외 발생", e);
// Mock 환경에서는 특정 에러 메시지들을 그대로 반환
if (e.getMessage() != null && e.getMessage().contains("KOS 시스템")) {
return ResponseEntity.ok()
.body(KosCommonResponse.failure("8888", e.getMessage()));
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(KosCommonResponse.failure("9998", "처리 중 오류가 발생했습니다"));
}
/**
* 모든 예외 처리 (최종 catch)
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<KosCommonResponse<Object>> handleException(Exception e) {
log.error("예상하지 못한 예외 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(KosCommonResponse.failure("9999", "시스템 오류가 발생했습니다"));
}
}
@@ -0,0 +1,23 @@
package com.phonebill.kosmock.exception;
/**
* KOS Mock 서비스 전용 예외
*/
public class KosMockException extends RuntimeException {
private final String errorCode;
public KosMockException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public KosMockException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,253 @@
package com.phonebill.kosmock.service;
import com.phonebill.kosmock.config.MockConfig;
import com.phonebill.kosmock.data.*;
import com.phonebill.kosmock.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Random;
import java.util.UUID;
/**
* KOS Mock 서비스
* 실제 KOS 시스템의 동작을 모방합니다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KosMockService {
private final MockDataService mockDataService;
private final MockConfig mockConfig;
private final Random random = new Random();
/**
* 요금 조회 처리 (Mock)
*/
public KosBillInquiryResponse processBillInquiry(KosBillInquiryRequest request) {
log.info("KOS Mock 요금 조회 요청 처리 시작 - RequestId: {}, LineNumber: {}",
request.getRequestId(), request.getLineNumber());
// Mock 응답 지연 시뮬레이션
simulateProcessingDelay();
// Mock 실패 시뮬레이션
if (shouldSimulateFailure()) {
log.warn("KOS Mock 요금 조회 실패 시뮬레이션 - RequestId: {}", request.getRequestId());
throw new RuntimeException("KOS 시스템 일시적 오류");
}
// 고객 데이터 조회
MockCustomerData customerData = mockDataService.getCustomerData(request.getLineNumber());
if (customerData == null) {
log.warn("존재하지 않는 회선번호 - LineNumber: {}", request.getLineNumber());
return createBillInquiryErrorResponse(request.getRequestId(), "1001", "존재하지 않는 회선번호입니다");
}
// 회선 상태 확인
if (!"ACTIVE".equals(customerData.getLineStatus())) {
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
request.getLineNumber(), customerData.getLineStatus());
return createBillInquiryErrorResponse(request.getRequestId(), "1002", "비활성 상태의 회선입니다");
}
// 청구월 설정 (없으면 현재월 사용)
String billingMonth = request.getBillingMonth();
if (billingMonth == null || billingMonth.isEmpty()) {
billingMonth = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
// 요금 데이터 조회
MockBillData billData = mockDataService.getBillData(request.getLineNumber(), billingMonth);
if (billData == null) {
log.warn("해당 청구월 요금 정보 없음 - LineNumber: {}, BillingMonth: {}",
request.getLineNumber(), billingMonth);
return createBillInquiryErrorResponse(request.getRequestId(), "1003", "해당 월 요금 정보가 없습니다");
}
// 성공 응답 생성
KosBillInquiryResponse response = KosBillInquiryResponse.builder()
.requestId(request.getRequestId())
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.billInfo(KosBillInquiryResponse.BillInfo.builder()
.lineNumber(billData.getLineNumber())
.billingMonth(billData.getBillingMonth())
.productCode(billData.getProductCode())
.productName(billData.getProductName())
.monthlyFee(billData.getMonthlyFee())
.usageFee(billData.getUsageFee())
.discountAmount(billData.getDiscountAmount())
.totalFee(billData.getTotalFee())
.dataUsage(billData.getDataUsage())
.voiceUsage(billData.getVoiceUsage())
.smsUsage(billData.getSmsUsage())
.billStatus(billData.getBillStatus())
.dueDate(billData.getDueDate())
.build())
.customerInfo(KosBillInquiryResponse.CustomerInfo.builder()
.customerName(customerData.getCustomerName())
.customerId(customerData.getCustomerId())
.operatorCode(customerData.getOperatorCode())
.lineStatus(customerData.getLineStatus())
.build())
.build();
log.info("KOS Mock 요금 조회 처리 완료 - RequestId: {}", request.getRequestId());
return response;
}
/**
* 상품 변경 처리 (Mock)
*/
public KosProductChangeResponse processProductChange(KosProductChangeRequest request) {
log.info("KOS Mock 상품 변경 요청 처리 시작 - RequestId: {}, LineNumber: {}, Target: {}",
request.getRequestId(), request.getLineNumber(), request.getTargetProductCode());
// Mock 응답 지연 시뮬레이션
simulateProcessingDelay();
// Mock 실패 시뮬레이션
if (shouldSimulateFailure()) {
log.warn("KOS Mock 상품 변경 실패 시뮬레이션 - RequestId: {}", request.getRequestId());
throw new RuntimeException("KOS 시스템 일시적 오류");
}
// 고객 데이터 조회
MockCustomerData customerData = mockDataService.getCustomerData(request.getLineNumber());
if (customerData == null) {
log.warn("존재하지 않는 회선번호 - LineNumber: {}", request.getLineNumber());
return createProductChangeErrorResponse(request.getRequestId(), "2001", "존재하지 않는 회선번호입니다");
}
// 회선 상태 확인
if (!"ACTIVE".equals(customerData.getLineStatus())) {
log.warn("비활성 회선 - LineNumber: {}, Status: {}",
request.getLineNumber(), customerData.getLineStatus());
return createProductChangeErrorResponse(request.getRequestId(), "2002", "비활성 상태의 회선입니다");
}
// 현재 상품과 타겟 상품 조회
MockProductData currentProduct = mockDataService.getProductData(request.getCurrentProductCode());
MockProductData targetProduct = mockDataService.getProductData(request.getTargetProductCode());
if (currentProduct == null || targetProduct == null) {
log.warn("존재하지 않는 상품 코드 - Current: {}, Target: {}",
request.getCurrentProductCode(), request.getTargetProductCode());
return createProductChangeErrorResponse(request.getRequestId(), "2003", "존재하지 않는 상품 코드입니다");
}
// 타겟 상품 판매 상태 확인
if (!"ACTIVE".equals(targetProduct.getStatus())) {
log.warn("판매 중단된 상품 - ProductCode: {}, Status: {}",
request.getTargetProductCode(), targetProduct.getStatus());
return createProductChangeErrorResponse(request.getRequestId(), "2004", "판매가 중단된 상품입니다");
}
// 통신사업자 일치 확인
if (!currentProduct.getOperatorCode().equals(targetProduct.getOperatorCode())) {
log.warn("다른 통신사업자 상품으로 변경 시도 - Current: {}, Target: {}",
currentProduct.getOperatorCode(), targetProduct.getOperatorCode());
return createProductChangeErrorResponse(request.getRequestId(), "2005", "다른 통신사업자 상품으로는 변경할 수 없습니다");
}
// KOS 주문 번호 생성
String kosOrderNumber = generateKosOrderNumber();
// 적용 일자 설정 (없으면 내일 사용)
String effectiveDate = request.getEffectiveDate();
if (effectiveDate == null || effectiveDate.isEmpty()) {
effectiveDate = LocalDateTime.now().plusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
}
// 성공 응답 생성
KosProductChangeResponse response = KosProductChangeResponse.builder()
.requestId(request.getRequestId())
.resultCode("0000")
.resultMessage("정상 처리되었습니다")
.changeInfo(KosProductChangeResponse.ChangeInfo.builder()
.lineNumber(request.getLineNumber())
.previousProductCode(currentProduct.getProductCode())
.previousProductName(currentProduct.getProductName())
.newProductCode(targetProduct.getProductCode())
.newProductName(targetProduct.getProductName())
.effectiveDate(effectiveDate)
.changeStatus("SUCCESS")
.kosOrderNumber(kosOrderNumber)
.estimatedCompletionTime(LocalDateTime.now().plusMinutes(30)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")))
.build())
.build();
// 처리 결과 저장
MockProcessingResult processingResult = MockProcessingResult.builder()
.requestId(request.getRequestId())
.processingType("PRODUCT_CHANGE")
.status("SUCCESS")
.message("상품 변경이 성공적으로 처리되었습니다")
.requestedAt(LocalDateTime.now())
.completedAt(LocalDateTime.now())
.processingTimeMs(mockConfig.getResponseDelay())
.build();
mockDataService.saveProcessingResult(request.getRequestId(), processingResult);
log.info("KOS Mock 상품 변경 처리 완료 - RequestId: {}, KosOrderNumber: {}",
request.getRequestId(), kosOrderNumber);
return response;
}
/**
* 처리 지연 시뮬레이션
*/
private void simulateProcessingDelay() {
try {
Thread.sleep(mockConfig.getResponseDelay());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("처리 지연 시뮬레이션 중단", e);
}
}
/**
* 실패 시뮬레이션 여부 결정
*/
private boolean shouldSimulateFailure() {
return random.nextDouble() < mockConfig.getFailureRate();
}
/**
* KOS 주문 번호 생성
*/
private String generateKosOrderNumber() {
return "KOS" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
+ String.format("%03d", random.nextInt(1000));
}
/**
* 요금 조회 오류 응답 생성
*/
private KosBillInquiryResponse createBillInquiryErrorResponse(String requestId, String errorCode, String errorMessage) {
return KosBillInquiryResponse.builder()
.requestId(requestId)
.resultCode(errorCode)
.resultMessage(errorMessage)
.build();
}
/**
* 상품 변경 오류 응답 생성
*/
private KosProductChangeResponse createProductChangeErrorResponse(String requestId, String errorCode, String errorMessage) {
return KosProductChangeResponse.builder()
.requestId(requestId)
.resultCode(errorCode)
.resultMessage(errorMessage)
.build();
}
}
@@ -0,0 +1,51 @@
spring:
# H2 데이터베이스 설정 (Mock 서비스용)
datasource:
url: jdbc:h2:mem:kosmock;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
driver-class-name: org.h2.Driver
# JPA 설정
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
# H2 Console (개발환경에서만)
h2:
console:
enabled: true
path: /h2-console
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:4}
# Mock 응답 시간 (개발 환경에서는 빠른 응답)
kos:
mock:
response-delay: 100 # milliseconds
failure-rate: 0.01 # 1% 실패율
# 로깅 레벨 (개발환경)
logging:
level:
com.phonebill.kosmock: DEBUG
org.springframework.web: DEBUG
org.springframework.data.redis: DEBUG
@@ -0,0 +1,27 @@
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# Mock 응답 시간 (실제 KOS 시스템을 모방)
kos:
mock:
response-delay: 1000 # milliseconds (1초)
failure-rate: 0.05 # 5% 실패율
# 로깅 레벨 (운영환경)
logging:
level:
com.phonebill.kosmock: INFO
org.springframework.web: WARN
org.springframework.data.redis: WARN
file:
name: /var/log/kos-mock-service.log
@@ -0,0 +1,43 @@
spring:
application:
name: kos-mock-service
profiles:
active: dev
server:
port: ${SERVER_PORT:8080}
servlet:
context-path: /kos-mock
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
export:
prometheus:
enabled: true
logging:
level:
com.phonebill.kosmock: INFO
org.springframework.web: INFO
pattern:
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
file: '%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n'
file:
name: logs/kos-mock-service.log
# Swagger/OpenAPI
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: true
@@ -0,0 +1,18 @@
package com.phonebill.kosmock;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* KOS Mock Application 통합 테스트
*/
@SpringBootTest
@ActiveProfiles("test")
class KosMockApplicationTest {
@Test
void contextLoads() {
// Spring Context가 정상적으로 로드되는지 확인
}
}
@@ -0,0 +1,98 @@
package com.phonebill.kosmock.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.phonebill.kosmock.dto.KosBillInquiryRequest;
import com.phonebill.kosmock.dto.KosProductChangeRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* KOS Mock Controller 테스트
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class KosMockControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("서비스 상태 체크 API 테스트")
void healthCheck() throws Exception {
mockMvc.perform(get("/api/v1/kos/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.resultCode").value("0000"));
}
@Test
@DisplayName("요금 조회 API 성공 테스트")
void inquireBill_Success() throws Exception {
KosBillInquiryRequest request = new KosBillInquiryRequest();
request.setLineNumber("01012345678");
request.setBillingMonth("202501");
request.setRequestId("TEST_REQ_001");
request.setRequestorId("TEST_SERVICE");
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("요금 조회 API 입력값 검증 실패 테스트")
void inquireBill_ValidationFailure() throws Exception {
KosBillInquiryRequest request = new KosBillInquiryRequest();
// 필수값 누락
request.setBillingMonth("202501");
mockMvc.perform(post("/api/v1/kos/bill/inquiry")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("상품 변경 API 성공 테스트")
void changeProduct_Success() throws Exception {
KosProductChangeRequest request = new KosProductChangeRequest();
request.setLineNumber("01012345678");
request.setCurrentProductCode("LTE-BASIC-001");
request.setTargetProductCode("5G-PREMIUM-001");
request.setRequestId("TEST_REQ_002");
request.setRequestorId("TEST_SERVICE");
request.setChangeReason("테스트 상품 변경");
mockMvc.perform(post("/api/v1/kos/product/change")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("Mock 설정 조회 API 테스트")
void getMockConfig() throws Exception {
mockMvc.perform(get("/api/v1/kos/mock/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.resultCode").value("0000"));
}
}
@@ -0,0 +1,20 @@
spring:
data:
redis:
host: localhost
port: 6379
timeout: 1000ms
# 테스트용 Mock 설정
kos:
mock:
response-delay: 0 # 테스트에서는 지연 없음
failure-rate: 0.0 # 테스트에서는 실패 시뮬레이션 없음
debug-mode: true
# 로깅 레벨 (테스트환경)
logging:
level:
com.phonebill.kosmock: DEBUG
org.springframework.web: INFO
org.springframework.test: INFO