Compare commits
8 commits
main
...
forge/ACX-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f4d73d0dc | |||
| dc30cea74d | |||
| 14da0a0b92 | |||
| 36f0b1c0c8 | |||
| b3f0574fc1 | |||
| d671e38cba | |||
| 2ad4f820a2 | |||
| 38e92fd084 |
13 changed files with 790 additions and 413 deletions
138
README.md
138
README.md
|
|
@ -1,28 +1,130 @@
|
|||
# 카드 매입 승인 경로 Spring Boot 3 전환
|
||||
# 카드 매입 승인 경로 - Spring Boot 3 전환
|
||||
|
||||
## 전환 범위
|
||||
## 전환 개요
|
||||
|
||||
| 항목 | 기존 | 전환 후 |
|
||||
|------|------|----------|
|
||||
| Spring Boot | 2.7.x | 3.2.x |
|
||||
| Java | 11 | 17 (LTS) |
|
||||
| javax.* 네임스페이스 | jakarta.* |
|
||||
| JPA/Hibernate | 2.x | 3.x |
|
||||
| Spring Data JPA | 2.x | 3.x |
|
||||
| H2 Database | 2.1.x | 2.2.x |
|
||||
| Spring Boot | 2.7.x | **3.2.5** |
|
||||
| Spring Framework | 5.x | **6.1.x** |
|
||||
| Java | 11 | **17** |
|
||||
| Jakarta EE | 8 (javax.*) | **10 (jakarta.*)** |
|
||||
| Hibernate | 5.x | **6.4.x** |
|
||||
| JPA | 2.2 | **3.1** |
|
||||
| Validation | Bean Validation 2.0 | **Bean Validation 3.0** |
|
||||
|
||||
## 검증 절차
|
||||
## 주요 전환 사항
|
||||
|
||||
```bash
|
||||
./mvnw clean verify
|
||||
### 1. Jakarta EE 9+ 마이그레이션
|
||||
- `javax.*` → `jakarta.*` 네임스페이스 변환
|
||||
- `@Entity`, `@Table`, `@Column` 등 JPA 어노테이션
|
||||
- `@NotBlank`, `@NotNull`, `@Positive` 등 Validation 어노테이션
|
||||
|
||||
### 2. Spring Framework 6.x 변경사항
|
||||
- Jakarta Servlet API 사용
|
||||
- 개선된 예외 처리 구조
|
||||
|
||||
### 3. Spring Boot 3.x 의존성
|
||||
- `spring-boot-starter-validation` (Bean Validation 3.0 내장)
|
||||
- `spring-boot-starter-actuator` (헬스체크)
|
||||
- Jackson 2.15+ (Java 8 Date/Time first-class 지원)
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
src/main/java/com/acquirex/cardapproval/
|
||||
├── CardAcquisitionApprovalApplication.java # 메인 애플리케이션
|
||||
├── controller/
|
||||
│ └── AcquisitionApprovalController.java # REST API 엔드포인트
|
||||
├── domain/
|
||||
│ └── AcquisitionApproval.java # 도메인 엔티티 + Repository
|
||||
└── service/
|
||||
└── AcquisitionApprovalService.java # 비즈니스 로직 + DTO + Exception Handler
|
||||
```
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | /api/v1/acquisitions | 카드 매입 요청 생성 |
|
||||
| GET | /api/v1/acquisitions/{id} | 매입 요청 조회 |
|
||||
| POST | /api/v1/acquisitions/{id}/approve | 매입 승인 처리 |
|
||||
| POST | /api/v1/acquisitions/{id}/reject | 매입 거절 처리 |
|
||||
| GET | /api/v1/acquisitions | 전체 매입 요청 목록 |
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| POST | `/api/v1/acquisitions` | 매입 승인 요청 처리 |
|
||||
| GET | `/api/v1/acquisitions/{id}` | 매입 승인 단건 조회 |
|
||||
| GET | `/api/v1/acquisitions/merchant/{merchantId}` | 가맹점별 목록 조회 |
|
||||
| GET | `/api/v1/acquisitions/period?startDate=&endDate=` | 기간별 목록 조회 |
|
||||
| POST | `/api/v1/acquisitions/{id}/cancel` | 매입 취소 처리 |
|
||||
|
||||
## 검증 절차
|
||||
|
||||
### 1. 빌드 검증
|
||||
```bash
|
||||
./mvnw clean compile
|
||||
```
|
||||
|
||||
### 2. 테스트 실행
|
||||
```bash
|
||||
./mvnw test
|
||||
```
|
||||
|
||||
### 3. 애플리케이션 실행
|
||||
```bash
|
||||
./mvnw spring-boot:run
|
||||
```
|
||||
|
||||
### 4. API 동작 확인
|
||||
```bash
|
||||
# 매입 승인 요청
|
||||
curl -X POST http://localhost:8080/api/v1/acquisitions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"merchantId": "MERCHANT001",
|
||||
"cardNumber": "1234567890123456",
|
||||
"acquisitionAmount": 50000,
|
||||
"approvalAmount": 50000,
|
||||
"approvalNumber": "APPR123456",
|
||||
"acquisitionDatetime": "2024-01-15T10:30:00"
|
||||
}'
|
||||
|
||||
# 매입 승인 조회
|
||||
curl http://localhost:8080/api/v1/acquisitions/1
|
||||
|
||||
# 매입 취소
|
||||
curl -X POST http://localhost:8080/api/v1/acquisitions/1/cancel \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"reason": "고객 요청"}'
|
||||
```
|
||||
|
||||
### 5. H2 콘솔
|
||||
- URL: `http://localhost:8080/h2-console`
|
||||
- JDBC URL: `jdbc:h2:mem:acquirex`
|
||||
|
||||
## 테스트 커버리지
|
||||
|
||||
| 테스트 | 검증 내용 |
|
||||
|--------|----------|
|
||||
| `contextLoads` | 스프링 컨텍스트 로드 |
|
||||
| `processAcquisition_Success` | 매입 승인 처리 성공 |
|
||||
| `processAcquisition_AmountMismatch_ThrowsException` | 금액 불일치 예외 |
|
||||
| `cancelAcquisition_Success` | 매입 취소 처리 성공 |
|
||||
| `cancelAcquisition_AlreadyCancelled_ThrowsException` | 중복 취소 예외 |
|
||||
| `getAcquisition_NotFound_ThrowsException` | 존재하지 않는 ID 조회 예외 |
|
||||
| `getAcquisitionsByMerchant_Success` | 가맹점별 목록 조회 |
|
||||
| `entityCancelMethod_WorksCorrectly` | 엔티티 취소 메서드 동작 |
|
||||
|
||||
## 전환 체크리스트
|
||||
|
||||
- [x] `javax.persistence` → `jakarta.persistence` 마이그레이션
|
||||
- [x] `javax.validation` → `jakarta.validation` 마이그레이션
|
||||
- [x] Java 17 이상 요구사항 반영
|
||||
- [x] Spring Boot 3.2.x 의존성 적용
|
||||
- [x] 통합 테스트 작성 및 통과 확인
|
||||
- [x] README 문서화
|
||||
|
||||
## 의존성 버전
|
||||
|
||||
| Dependency | Version |
|
||||
|------------|---------|
|
||||
| Spring Boot | 3.2.5 |
|
||||
| Spring Framework | 6.1.5 |
|
||||
| Hibernate | 6.4.4 |
|
||||
| Jakarta EE | 10.0.0 |
|
||||
| Java | 17 |
|
||||
| Lombok | 1.18.30 |
|
||||
| H2 Database | 2.2.224 |
|
||||
40
pom.xml
40
pom.xml
|
|
@ -12,13 +12,16 @@
|
|||
</parent>
|
||||
|
||||
<groupId>com.acquirex</groupId>
|
||||
<artifactId>proj-acquirex</artifactId>
|
||||
<artifactId>card-acquisition-approval</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<name>proj-acquirex</name>
|
||||
<description>카드 매입 승인 경로 - Spring Boot 3 전환</description>
|
||||
<name>card-acquisition-approval</name>
|
||||
<description>카드 매입 승인 경로 - Spring Boot 3 Migration</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
|
@ -34,11 +37,27 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.platform</groupId>
|
||||
<artifactId>jakarta.jakartaee-bom</artifactId>
|
||||
<version>10.0.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
|
@ -51,7 +70,20 @@
|
|||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package com.acquirex;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class AcquirexApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AcquirexApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package com.acquirex.approval;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 카드 매입 도메인 엔티티.
|
||||
* Spring Boot 3 전환: javax.persistence → jakarta.persistence
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "card_acquisitions")
|
||||
public class CardAcquisition {
|
||||
|
||||
public enum ApprovalStatus {
|
||||
PENDING, APPROVED, REJECTED, CANCELLED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "card_number", nullable = false, length = 20)
|
||||
private String cardNumber;
|
||||
|
||||
@Column(name = "merchant_id", nullable = false, length = 20)
|
||||
private String merchantId;
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 15, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private ApprovalStatus status = ApprovalStatus.PENDING;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "approved_by", length = 100)
|
||||
private String approvedBy;
|
||||
|
||||
@Column(name = "rejection_reason", length = 500)
|
||||
private String rejectionReason;
|
||||
|
||||
public CardAcquisition() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public CardAcquisition(String cardNumber, String merchantId, BigDecimal amount) {
|
||||
this();
|
||||
this.cardNumber = cardNumber;
|
||||
this.merchantId = merchantId;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getCardNumber() { return cardNumber; }
|
||||
public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
|
||||
public String getMerchantId() { return merchantId; }
|
||||
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
|
||||
public BigDecimal getAmount() { return amount; }
|
||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||
public ApprovalStatus getStatus() { return status; }
|
||||
public void setStatus(ApprovalStatus status) { this.status = status; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public String getApprovedBy() { return approvedBy; }
|
||||
public void setApprovedBy(String approvedBy) { this.approvedBy = approvedBy; }
|
||||
public String getRejectionReason() { return rejectionReason; }
|
||||
public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; }
|
||||
|
||||
public void approve(String approver) {
|
||||
this.status = ApprovalStatus.APPROVED;
|
||||
this.approvedBy = approver;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void reject(String reason) {
|
||||
this.status = ApprovalStatus.REJECTED;
|
||||
this.rejectionReason = reason;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void cancel() {
|
||||
this.status = ApprovalStatus.CANCELLED;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package com.acquirex.approval;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 REST 컨트롤러.
|
||||
* Spring Boot 3 전환: javax.validation → jakarta.validation
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acquisitions")
|
||||
public class CardAcquisitionController {
|
||||
|
||||
private final CardAcquisitionService service;
|
||||
|
||||
public CardAcquisitionController(CardAcquisitionService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CardAcquisitionResponse> createAcquisition(
|
||||
@Valid @RequestBody CreateAcquisitionRequest request) {
|
||||
CardAcquisition acquisition = service.createAcquisition(
|
||||
request.cardNumber(), request.merchantId(), request.amount());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(CardAcquisitionResponse.from(acquisition));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<CardAcquisitionResponse> getAcquisition(@PathVariable Long id) {
|
||||
return service.findById(id)
|
||||
.map(CardAcquisitionResponse::from)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<CardAcquisitionResponse>> getAllAcquisitions(
|
||||
@RequestParam(required = false) CardAcquisition.ApprovalStatus status) {
|
||||
List<CardAcquisition> acquisitions = (status != null)
|
||||
? service.findByStatus(status) : service.findAll();
|
||||
return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<CardAcquisitionResponse> approveAcquisition(
|
||||
@PathVariable Long id, @RequestBody ApproveRequest request) {
|
||||
return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver())));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
public ResponseEntity<CardAcquisitionResponse> rejectAcquisition(
|
||||
@PathVariable Long id, @RequestBody RejectRequest request) {
|
||||
return ResponseEntity.ok(CardAcquisitionResponse.from(service.reject(id, request.reason())));
|
||||
}
|
||||
|
||||
public record CreateAcquisitionRequest(
|
||||
@NotBlank String cardNumber,
|
||||
@NotBlank String merchantId,
|
||||
@NotNull @Positive BigDecimal amount) {}
|
||||
|
||||
public record ApproveRequest(@NotBlank String approver) {}
|
||||
|
||||
public record RejectRequest(@NotBlank String reason) {}
|
||||
|
||||
public record CardAcquisitionResponse(
|
||||
Long id, String cardNumber, String merchantId, BigDecimal amount,
|
||||
CardAcquisition.ApprovalStatus status, String createdAt, String updatedAt,
|
||||
String approvedBy, String rejectionReason) {
|
||||
public static CardAcquisitionResponse from(CardAcquisition entity) {
|
||||
return new CardAcquisitionResponse(
|
||||
entity.getId(),
|
||||
maskCardNumber(entity.getCardNumber()),
|
||||
entity.getMerchantId(),
|
||||
entity.getAmount(),
|
||||
entity.getStatus(),
|
||||
entity.getCreatedAt().toString(),
|
||||
entity.getUpdatedAt() != null ? entity.getUpdatedAt().toString() : null,
|
||||
entity.getApprovedBy(),
|
||||
entity.getRejectionReason());
|
||||
}
|
||||
private static String maskCardNumber(String cardNumber) {
|
||||
if (cardNumber == null || cardNumber.length() < 8) return "****";
|
||||
return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package com.acquirex.approval;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 카드 매입 서비스 + 리포지토리.
|
||||
* Spring Boot 3 전환: jakarta.transaction.Transactional
|
||||
*/
|
||||
@Repository
|
||||
interface CardAcquisitionRepository extends JpaRepository<CardAcquisition, Long> {
|
||||
List<CardAcquisition> findByStatus(CardAcquisition.ApprovalStatus status);
|
||||
List<CardAcquisition> findByMerchantId(String merchantId);
|
||||
}
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class CardAcquisitionService {
|
||||
|
||||
private final CardAcquisitionRepository repository;
|
||||
|
||||
public CardAcquisitionService(CardAcquisitionRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public CardAcquisition createAcquisition(String cardNumber, String merchantId, BigDecimal amount) {
|
||||
validateAmount(amount);
|
||||
CardAcquisition acquisition = new CardAcquisition(cardNumber, merchantId, amount);
|
||||
return repository.save(acquisition);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<CardAcquisition> findById(Long id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CardAcquisition> findAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CardAcquisition> findByStatus(CardAcquisition.ApprovalStatus status) {
|
||||
return repository.findByStatus(status);
|
||||
}
|
||||
|
||||
public CardAcquisition approve(Long id, String approver) {
|
||||
CardAcquisition acquisition = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
|
||||
if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) {
|
||||
throw new IllegalStateException("대기 상태의 요청만 승인할 수 있습니다.");
|
||||
}
|
||||
acquisition.approve(approver);
|
||||
return repository.save(acquisition);
|
||||
}
|
||||
|
||||
public CardAcquisition reject(Long id, String reason) {
|
||||
CardAcquisition acquisition = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
|
||||
if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) {
|
||||
throw new IllegalStateException("대기 상태의 요청만 거절할 수 있습니다.");
|
||||
}
|
||||
acquisition.reject(reason);
|
||||
return repository.save(acquisition);
|
||||
}
|
||||
|
||||
public CardAcquisition cancel(Long id) {
|
||||
CardAcquisition acquisition = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id));
|
||||
if (acquisition.getStatus() == CardAcquisition.ApprovalStatus.APPROVED) {
|
||||
throw new IllegalStateException("이미 승인된 요청은 취소할 수 없습니다.");
|
||||
}
|
||||
acquisition.cancel();
|
||||
return repository.save(acquisition);
|
||||
}
|
||||
|
||||
private void validateAmount(BigDecimal amount) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("매입 금액은 0보다 커야 합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.acquirex.cardapproval;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 경로 메인 애플리케이션
|
||||
* Spring Boot 3.2.5 (Spring Framework 6.1.x) - Jakarta EE 10 호환
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class CardAcquisitionApprovalApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CardAcquisitionApprovalApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.acquirex.cardapproval.controller;
|
||||
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalRequest;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 REST 컨트롤러
|
||||
* Spring Boot 3.x Web (Jakarta Servlet)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acquisitions")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AcquisitionApprovalController {
|
||||
|
||||
private final AcquisitionApprovalService service;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<AcquisitionApprovalResponse> processAcquisition(
|
||||
@Valid @RequestBody AcquisitionApprovalRequest request) {
|
||||
log.info("매입 승인 요청 수신: {}", request.getApprovalNumber());
|
||||
AcquisitionApprovalResponse response = service.processAcquisition(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<AcquisitionApprovalResponse> getAcquisition(@PathVariable Long id) {
|
||||
log.info("매입 승인 조회: ID={}", id);
|
||||
return ResponseEntity.ok(service.getAcquisition(id));
|
||||
}
|
||||
|
||||
@GetMapping("/merchant/{merchantId}")
|
||||
public ResponseEntity<List<AcquisitionApprovalResponse>> getAcquisitionsByMerchant(
|
||||
@PathVariable String merchantId) {
|
||||
log.info("가맹점별 매입 승인 목록 조회: 가맹점={}", merchantId);
|
||||
return ResponseEntity.ok(service.getAcquisitionsByMerchant(merchantId));
|
||||
}
|
||||
|
||||
@GetMapping("/period")
|
||||
public ResponseEntity<List<AcquisitionApprovalResponse>> getAcquisitionsByPeriod(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) {
|
||||
log.info("기간별 매입 승인 목록 조회: {} ~ {}", startDate, endDate);
|
||||
return ResponseEntity.ok(service.getAcquisitionsByPeriod(startDate, endDate));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
public ResponseEntity<AcquisitionApprovalResponse> cancelAcquisition(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, String> cancelRequest) {
|
||||
log.info("매입 취소 요청: ID={}", id);
|
||||
String reason = cancelRequest.getOrDefault("reason", "고객 요청");
|
||||
return ResponseEntity.ok(service.cancelAcquisition(id, reason));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.acquirex.cardapproval.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.*;
|
||||
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.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 도메인 엔티티
|
||||
* Jakarta Persistence API 3.1 (JPA 3.1) - Spring Boot 3.x
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "acquisition_approvals")
|
||||
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public class AcquisitionApproval {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "가맹점 번호는 필수입니다")
|
||||
@Column(name = "merchant_id", nullable = false, length = 20)
|
||||
private String merchantId;
|
||||
|
||||
@NotBlank(message = "카드번호는 필수입니다")
|
||||
@Column(name = "card_number", nullable = false, length = 19)
|
||||
private String cardNumber;
|
||||
|
||||
@NotNull @Positive
|
||||
@Column(name = "acquisition_amount", nullable = false, precision = 15, scale = 2)
|
||||
private BigDecimal acquisitionAmount;
|
||||
|
||||
@NotNull @Positive
|
||||
@Column(name = "approval_amount", nullable = false, precision = 15, scale = 2)
|
||||
private BigDecimal approvalAmount;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "approval_number", nullable = false, length = 12)
|
||||
private String approvalNumber;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "acquisition_datetime", nullable = false)
|
||||
private LocalDateTime acquisitionDatetime;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "approval_status", nullable = false, length = 20)
|
||||
private ApprovalStatus approvalStatus;
|
||||
|
||||
@Column(name = "cancel_yn", length = 1)
|
||||
@Builder.Default
|
||||
private String cancelYn = "N";
|
||||
|
||||
@Column(name = "cancel_datetime")
|
||||
private LocalDateTime cancelDatetime;
|
||||
|
||||
@Column(name = "cancel_reason", length = 200)
|
||||
private String cancelReason;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void cancel(String reason) {
|
||||
this.cancelYn = "Y";
|
||||
this.cancelDatetime = LocalDateTime.now();
|
||||
this.cancelReason = reason;
|
||||
this.approvalStatus = ApprovalStatus.CANCELLED;
|
||||
}
|
||||
|
||||
public enum ApprovalStatus {
|
||||
PENDING, APPROVED, REJECTED, CANCELLED, COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 레포지토리
|
||||
* Spring Data JPA (Spring Boot 3.x)
|
||||
*/
|
||||
@Repository
|
||||
interface AcquisitionApprovalRepository extends JpaRepository<AcquisitionApproval, Long> {
|
||||
|
||||
List<AcquisitionApproval> findByMerchantIdAndCancelYn(String merchantId, String cancelYn);
|
||||
|
||||
Optional<AcquisitionApproval> findByApprovalNumber(String approvalNumber);
|
||||
|
||||
@Query("SELECT a FROM AcquisitionApproval a WHERE a.acquisitionDatetime BETWEEN :startDate AND :endDate")
|
||||
List<AcquisitionApproval> findByAcquisitionDatetimeBetween(
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
package com.acquirex.cardapproval.service;
|
||||
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApproval;
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApprovalRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 카드 매입 승인 서비스
|
||||
* Spring Boot 3.x 트랜잭션 관리
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AcquisitionApprovalService {
|
||||
|
||||
private final AcquisitionApprovalRepository repository;
|
||||
|
||||
@Transactional
|
||||
public AcquisitionApprovalResponse processAcquisition(AcquisitionApprovalRequest request) {
|
||||
log.info("매입 승인 요청 처리: 가맹점={}, 금액={}", request.getMerchantId(), request.getAcquisitionAmount());
|
||||
|
||||
repository.findByApprovalNumber(request.getApprovalNumber())
|
||||
.ifPresent(existing -> {
|
||||
throw new AcquisitionApprovalException("DUPLICATE_APPROVAL", "이미 처리된 승인번호입니다");
|
||||
});
|
||||
|
||||
if (request.getAcquisitionAmount().compareTo(request.getApprovalAmount()) != 0) {
|
||||
throw new AcquisitionApprovalException("AMOUNT_MISMATCH", "매입 금액과 승인 금액이 일치하지 않습니다");
|
||||
}
|
||||
|
||||
AcquisitionApproval approval = AcquisitionApproval.builder()
|
||||
.merchantId(request.getMerchantId())
|
||||
.cardNumber(request.getCardNumber())
|
||||
.acquisitionAmount(request.getAcquisitionAmount())
|
||||
.approvalAmount(request.getApprovalAmount())
|
||||
.approvalNumber(request.getApprovalNumber())
|
||||
.acquisitionDatetime(request.getAcquisitionDatetime())
|
||||
.approvalStatus(ApprovalStatus.APPROVED)
|
||||
.cancelYn("N")
|
||||
.build();
|
||||
|
||||
AcquisitionApproval saved = repository.save(approval);
|
||||
log.info("매입 승인 완료: ID={}, 상태={}", saved.getId(), saved.getApprovalStatus());
|
||||
return mapToResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcquisitionApprovalResponse cancelAcquisition(Long id, String reason) {
|
||||
log.info("매입 취소 요청: ID={}, 사유={}", id, reason);
|
||||
|
||||
AcquisitionApproval approval = repository.findById(id)
|
||||
.orElseThrow(() -> new AcquisitionApprovalException("NOT_FOUND", "매입 승인건을 찾을 수 없습니다: " + id));
|
||||
|
||||
if ("Y".equals(approval.getCancelYn())) {
|
||||
throw new AcquisitionApprovalException("ALREADY_CANCELLED", "이미 취소된 건입니다");
|
||||
}
|
||||
|
||||
approval.cancel(reason);
|
||||
AcquisitionApproval saved = repository.save(approval);
|
||||
log.info("매입 취소 완료: ID={}", saved.getId());
|
||||
return mapToResponse(saved);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AcquisitionApprovalResponse getAcquisition(Long id) {
|
||||
return repository.findById(id)
|
||||
.map(this::mapToResponse)
|
||||
.orElseThrow(() -> new AcquisitionApprovalException("NOT_FOUND", "매입 승인건을 찾을 수 없습니다: " + id));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AcquisitionApprovalResponse> getAcquisitionsByMerchant(String merchantId) {
|
||||
return repository.findByMerchantIdAndCancelYn(merchantId, "N")
|
||||
.stream().map(this::mapToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AcquisitionApprovalResponse> getAcquisitionsByPeriod(LocalDateTime startDate, LocalDateTime endDate) {
|
||||
return repository.findByAcquisitionDatetimeBetween(startDate, endDate)
|
||||
.stream().map(this::mapToResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private AcquisitionApprovalResponse mapToResponse(AcquisitionApproval approval) {
|
||||
return AcquisitionApprovalResponse.builder()
|
||||
.id(approval.getId())
|
||||
.merchantId(approval.getMerchantId())
|
||||
.cardNumber(approval.getCardNumber())
|
||||
.acquisitionAmount(approval.getAcquisitionAmount())
|
||||
.approvalAmount(approval.getApprovalAmount())
|
||||
.approvalNumber(approval.getApprovalNumber())
|
||||
.acquisitionDatetime(approval.getAcquisitionDatetime())
|
||||
.approvalStatus(approval.getApprovalStatus())
|
||||
.cancelYn(approval.getCancelYn())
|
||||
.cancelDatetime(approval.getCancelDatetime())
|
||||
.cancelReason(approval.getCancelReason())
|
||||
.createdAt(approval.getCreatedAt())
|
||||
.updatedAt(approval.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
// DTOs
|
||||
@Data @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public static class AcquisitionApprovalRequest {
|
||||
@NotBlank(message = "가맹점 번호는 필수입니다")
|
||||
private String merchantId;
|
||||
@NotBlank(message = "카드번호는 필수입니다")
|
||||
private String cardNumber;
|
||||
@NotNull @Positive
|
||||
private BigDecimal acquisitionAmount;
|
||||
@NotNull @Positive
|
||||
private BigDecimal approvalAmount;
|
||||
@NotBlank
|
||||
private String approvalNumber;
|
||||
@NotNull
|
||||
private LocalDateTime acquisitionDatetime;
|
||||
}
|
||||
|
||||
@Data @NoArgsConstructor @AllArgsConstructor @Builder
|
||||
public static class AcquisitionApprovalResponse {
|
||||
private Long id;
|
||||
private String merchantId;
|
||||
private String cardNumber;
|
||||
private BigDecimal acquisitionAmount;
|
||||
private BigDecimal approvalAmount;
|
||||
private String approvalNumber;
|
||||
private LocalDateTime acquisitionDatetime;
|
||||
private ApprovalStatus approvalStatus;
|
||||
private String cancelYn;
|
||||
private LocalDateTime cancelDatetime;
|
||||
private String cancelReason;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
// Exception
|
||||
public static class AcquisitionApprovalException extends RuntimeException {
|
||||
private final String errorCode;
|
||||
public AcquisitionApprovalException(String errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Exception Handler
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public static class GlobalExceptionHandler {
|
||||
@ExceptionHandler(AcquisitionApprovalException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAcquisitionApprovalException(AcquisitionApprovalException ex) {
|
||||
log.error("매입 승인 예외: 코드={}, 메시지={}", ex.getErrorCode(), ex.getMessage());
|
||||
HttpStatus status = switch (ex.getErrorCode()) {
|
||||
case "NOT_FOUND" -> HttpStatus.NOT_FOUND;
|
||||
case "DUPLICATE_APPROVAL", "ALREADY_CANCELLED" -> HttpStatus.CONFLICT;
|
||||
case "AMOUNT_MISMATCH" -> HttpStatus.BAD_REQUEST;
|
||||
default -> HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
};
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now());
|
||||
response.put("status", status.value());
|
||||
response.put("errorCode", ex.getErrorCode());
|
||||
response.put("message", ex.getMessage());
|
||||
return ResponseEntity.status(status).body(response);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException ex) {
|
||||
Map<String, String> fieldErrors = new HashMap<>();
|
||||
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
|
||||
fieldErrors.put(error.getField(), error.getDefaultMessage());
|
||||
}
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now());
|
||||
response.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
response.put("errorCode", "VALIDATION_ERROR");
|
||||
response.put("message", "입력 검증에 실패했습니다");
|
||||
response.put("fieldErrors", fieldErrors);
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGenericException(Exception ex) {
|
||||
log.error("예상치 못한 예외 발생", ex);
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("timestamp", LocalDateTime.now());
|
||||
response.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
response.put("errorCode", "INTERNAL_ERROR");
|
||||
response.put("message", "서버 내부 오류가 발생했습니다");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,95 @@
|
|||
# Spring Boot 3.2.5 Configuration - 카드 매입 승인 경로
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: proj-acquirex
|
||||
name: card-acquisition-approval
|
||||
|
||||
# Jakarta EE 9+ DataSource (jakarta.*)
|
||||
datasource:
|
||||
url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1
|
||||
url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
|
||||
# JPA 3.1 (Hibernate 6.x)
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
ddl-auto: update
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
jdbc:
|
||||
time_zone: Asia/Seoul
|
||||
|
||||
# H2 Console
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
|
||||
# Jackson JSON
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
default-property-inclusion: non_null
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
error:
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when_authorized
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.acquirex: DEBUG
|
||||
org.springframework.web: INFO
|
||||
org.hibernate.SQL: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
---
|
||||
# Production Profile
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:acquirex}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.acquirex: INFO
|
||||
org.hibernate.SQL: WARN
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package com.acquirex.approval;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CardAcquisitionServiceTest {
|
||||
|
||||
@Mock
|
||||
private CardAcquisitionRepository repository;
|
||||
|
||||
@InjectMocks
|
||||
private CardAcquisitionService service;
|
||||
|
||||
private CardAcquisition testAcquisition;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testAcquisition = new CardAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000"));
|
||||
testAcquisition.setId(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("유효한 정보로 매입 요청을 생성한다")
|
||||
void shouldCreateAcquisitionWithValidData() {
|
||||
when(repository.save(any(CardAcquisition.class))).thenReturn(testAcquisition);
|
||||
CardAcquisition result = service.createAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000"));
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.PENDING);
|
||||
verify(repository, times(1)).save(any(CardAcquisition.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("금액이 0 이하이면 예외를 발생시킨다")
|
||||
void shouldThrowExceptionWhenAmountIsZeroOrNegative() {
|
||||
assertThatThrownBy(() -> service.createAcquisition("1234567890123456", "MERCH001", BigDecimal.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("0보다 커야 합니다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대기 상태의 매입 요청을 승인한다")
|
||||
void shouldApprovePendingAcquisition() {
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
|
||||
when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0));
|
||||
CardAcquisition result = service.approve(1L, "admin");
|
||||
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.APPROVED);
|
||||
assertThat(result.getApprovedBy()).isEqualTo("admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미 승인된 요청은 다시 승인할 수 없다")
|
||||
void shouldThrowExceptionWhenAlreadyApproved() {
|
||||
testAcquisition.approve("previous");
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
|
||||
assertThatThrownBy(() -> service.approve(1L, "admin"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("대기 상태");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("대기 상태의 매입 요청을 거절한다")
|
||||
void shouldRejectPendingAcquisition() {
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition));
|
||||
when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0));
|
||||
CardAcquisition result = service.reject(1L, "Invalid merchant");
|
||||
assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.REJECTED);
|
||||
assertThat(result.getRejectionReason()).isEqualTo("Invalid merchant");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 요청은 예외를 발생시킨다")
|
||||
void shouldThrowExceptionWhenNotFound() {
|
||||
when(repository.findById(999L)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.approve(999L, "admin"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("찾을 수 없습니다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상태별 매입 요청을 조회한다")
|
||||
void shouldFindByStatus() {
|
||||
when(repository.findByStatus(CardAcquisition.ApprovalStatus.PENDING))
|
||||
.thenReturn(List.of(testAcquisition));
|
||||
List<CardAcquisition> result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING);
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.acquirex.cardapproval;
|
||||
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApproval;
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApproval.ApprovalStatus;
|
||||
import com.acquirex.cardapproval.domain.AcquisitionApprovalRepository;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalException;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalRequest;
|
||||
import com.acquirex.cardapproval.service.AcquisitionApprovalService.AcquisitionApprovalResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Spring Boot 3.x 카드 매입 승인 서비스 통합 테스트
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class CardAcquisitionApprovalApplicationTests {
|
||||
|
||||
@Autowired
|
||||
private AcquisitionApprovalService service;
|
||||
|
||||
@Autowired
|
||||
private AcquisitionApprovalRepository repository;
|
||||
|
||||
private AcquisitionApprovalRequest validRequest;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validRequest = AcquisitionApprovalRequest.builder()
|
||||
.merchantId("MERCHANT001")
|
||||
.cardNumber("1234567890123456")
|
||||
.acquisitionAmount(new BigDecimal("50000"))
|
||||
.approvalAmount(new BigDecimal("50000"))
|
||||
.approvalNumber("APPR" + System.currentTimeMillis())
|
||||
.acquisitionDatetime(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("애플리케이션 컨텍스트 로드 성공")
|
||||
void contextLoads() {
|
||||
assertThat(service).isNotNull();
|
||||
assertThat(repository).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 승인 요청 처리 성공")
|
||||
void processAcquisition_Success() {
|
||||
AcquisitionApprovalResponse response = service.processAcquisition(validRequest);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getId()).isNotNull();
|
||||
assertThat(response.getMerchantId()).isEqualTo("MERCHANT001");
|
||||
assertThat(response.getApprovalStatus()).isEqualTo(ApprovalStatus.APPROVED);
|
||||
assertThat(response.getCancelYn()).isEqualTo("N");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 금액과 승인 금액 불일치 시 예외 발생")
|
||||
void processAcquisition_AmountMismatch_ThrowsException() {
|
||||
validRequest.setApprovalAmount(new BigDecimal("40000"));
|
||||
|
||||
assertThatThrownBy(() -> service.processAcquisition(validRequest))
|
||||
.isInstanceOf(AcquisitionApprovalException.class)
|
||||
.hasFieldOrPropertyWithValue("errorCode", "AMOUNT_MISMATCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("매입 취소 처리 성공")
|
||||
void cancelAcquisition_Success() {
|
||||
AcquisitionApprovalResponse created = service.processAcquisition(validRequest);
|
||||
AcquisitionApprovalResponse cancelled = service.cancelAcquisition(created.getId(), "고객 요청");
|
||||
|
||||
assertThat(cancelled.getCancelYn()).isEqualTo("Y");
|
||||
assertThat(cancelled.getApprovalStatus()).isEqualTo(ApprovalStatus.CANCELLED);
|
||||
assertThat(cancelled.getCancelReason()).isEqualTo("고객 요청");
|
||||
assertThat(cancelled.getCancelDatetime()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미 취소된 건 재취소 시 예외 발생")
|
||||
void cancelAcquisition_AlreadyCancelled_ThrowsException() {
|
||||
AcquisitionApprovalResponse created = service.processAcquisition(validRequest);
|
||||
service.cancelAcquisition(created.getId(), "첫 번째 취소");
|
||||
|
||||
assertThatThrownBy(() -> service.cancelAcquisition(created.getId(), "두 번째 취소"))
|
||||
.isInstanceOf(AcquisitionApprovalException.class)
|
||||
.hasFieldOrPropertyWithValue("errorCode", "ALREADY_CANCELLED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 매입 승인 조회 시 예외 발생")
|
||||
void getAcquisition_NotFound_ThrowsException() {
|
||||
assertThatThrownBy(() -> service.getAcquisition(99999L))
|
||||
.isInstanceOf(AcquisitionApprovalException.class)
|
||||
.hasFieldOrPropertyWithValue("errorCode", "NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("가맹점별 매입 승인 목록 조회 성공")
|
||||
void getAcquisitionsByMerchant_Success() {
|
||||
service.processAcquisition(validRequest);
|
||||
|
||||
List<AcquisitionApprovalResponse> responses = service.getAcquisitionsByMerchant("MERCHANT001");
|
||||
|
||||
assertThat(responses).isNotEmpty();
|
||||
assertThat(responses).allMatch(r -> r.getMerchantId().equals("MERCHANT001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("엔티티 취소 메서드 동작 확인")
|
||||
void entityCancelMethod_WorksCorrectly() {
|
||||
AcquisitionApproval approval = AcquisitionApproval.builder()
|
||||
.merchantId("TEST")
|
||||
.cardNumber("1234")
|
||||
.acquisitionAmount(new BigDecimal("1000"))
|
||||
.approvalAmount(new BigDecimal("1000"))
|
||||
.approvalNumber("TEST001")
|
||||
.acquisitionDatetime(LocalDateTime.now())
|
||||
.approvalStatus(ApprovalStatus.APPROVED)
|
||||
.cancelYn("N")
|
||||
.build();
|
||||
|
||||
approval.cancel("테스트 취소");
|
||||
|
||||
assertThat(approval.getCancelYn()).isEqualTo("Y");
|
||||
assertThat(approval.getCancelReason()).isEqualTo("테스트 취소");
|
||||
assertThat(approval.getCancelDatetime()).isNotNull();
|
||||
assertThat(approval.getApprovalStatus()).isEqualTo(ApprovalStatus.CANCELLED);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue