diff --git a/README.md b/README.md index c92662e..7129e1b 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,28 @@ -# 카드 매입 승인 경로 - Spring Boot 3 전환 +# 카드 매입 승인 경로 Spring Boot 3 전환 -## 전환 개요 +## 전환 범위 | 항목 | 기존 | 전환 후 | |------|------|----------| -| 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** | +| 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 | -## 주요 전환 사항 +## 검증 절차 -### 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 +```bash +./mvnw clean verify ``` ## API 엔드포인트 -| 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 | \ No newline at end of file +| 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 | 전체 매입 요청 목록 | diff --git a/pom.xml b/pom.xml index a61409e..c6cf793 100644 --- a/pom.xml +++ b/pom.xml @@ -12,16 +12,13 @@ com.acquirex - card-acquisition-approval + proj-acquirex 1.0.0-SNAPSHOT - card-acquisition-approval - 카드 매입 승인 경로 - Spring Boot 3 Migration + proj-acquirex + 카드 매입 승인 경로 - Spring Boot 3 전환 17 - 17 - 17 - UTF-8 @@ -37,27 +34,11 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-actuator - - - jakarta.platform - jakarta.jakartaee-bom - 10.0.0 - pom - import - com.h2database h2 runtime - - org.projectlombok - lombok - true - org.springframework.boot spring-boot-starter-test @@ -70,20 +51,7 @@ org.springframework.boot spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - \ No newline at end of file + diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java new file mode 100644 index 0000000..cbf43e9 --- /dev/null +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -0,0 +1,11 @@ +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); + } +} diff --git a/src/main/java/com/acquirex/approval/CardAcquisition.java b/src/main/java/com/acquirex/approval/CardAcquisition.java new file mode 100644 index 0000000..798e8e7 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisition.java @@ -0,0 +1,94 @@ +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(); + } +} diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionController.java b/src/main/java/com/acquirex/approval/CardAcquisitionController.java new file mode 100644 index 0000000..8d2e92b --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionController.java @@ -0,0 +1,95 @@ +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 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 getAcquisition(@PathVariable Long id) { + return service.findById(id) + .map(CardAcquisitionResponse::from) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping + public ResponseEntity> getAllAcquisitions( + @RequestParam(required = false) CardAcquisition.ApprovalStatus status) { + List acquisitions = (status != null) + ? service.findByStatus(status) : service.findAll(); + return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList()); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approveAcquisition( + @PathVariable Long id, @RequestBody ApproveRequest request) { + return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver()))); + } + + @PostMapping("/{id}/reject") + public ResponseEntity 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); + } + } +} diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionService.java b/src/main/java/com/acquirex/approval/CardAcquisitionService.java new file mode 100644 index 0000000..fac8b83 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionService.java @@ -0,0 +1,86 @@ +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 { + List findByStatus(CardAcquisition.ApprovalStatus status); + List 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 findById(Long id) { + return repository.findById(id); + } + + @Transactional(readOnly = true) + public List findAll() { + return repository.findAll(); + } + + @Transactional(readOnly = true) + public List 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보다 커야 합니다."); + } + } +} diff --git a/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java b/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java deleted file mode 100644 index 2c661cb..0000000 --- a/src/main/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -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); - } -} \ No newline at end of file diff --git a/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java b/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java deleted file mode 100644 index 89fb498..0000000 --- a/src/main/java/com/acquirex/cardapproval/controller/AcquisitionApprovalController.java +++ /dev/null @@ -1,67 +0,0 @@ -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 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 getAcquisition(@PathVariable Long id) { - log.info("매입 승인 조회: ID={}", id); - return ResponseEntity.ok(service.getAcquisition(id)); - } - - @GetMapping("/merchant/{merchantId}") - public ResponseEntity> getAcquisitionsByMerchant( - @PathVariable String merchantId) { - log.info("가맹점별 매입 승인 목록 조회: 가맹점={}", merchantId); - return ResponseEntity.ok(service.getAcquisitionsByMerchant(merchantId)); - } - - @GetMapping("/period") - public ResponseEntity> 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 cancelAcquisition( - @PathVariable Long id, - @RequestBody Map cancelRequest) { - log.info("매입 취소 요청: ID={}", id); - String reason = cancelRequest.getOrDefault("reason", "고객 요청"); - return ResponseEntity.ok(service.cancelAcquisition(id, reason)); - } -} \ No newline at end of file diff --git a/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java b/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java deleted file mode 100644 index 69ff5e5..0000000 --- a/src/main/java/com/acquirex/cardapproval/domain/AcquisitionApproval.java +++ /dev/null @@ -1,114 +0,0 @@ -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 { - - List findByMerchantIdAndCancelYn(String merchantId, String cancelYn); - - Optional findByApprovalNumber(String approvalNumber); - - @Query("SELECT a FROM AcquisitionApproval a WHERE a.acquisitionDatetime BETWEEN :startDate AND :endDate") - List findByAcquisitionDatetimeBetween( - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); -} \ No newline at end of file diff --git a/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java b/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java deleted file mode 100644 index edc8503..0000000 --- a/src/main/java/com/acquirex/cardapproval/service/AcquisitionApprovalService.java +++ /dev/null @@ -1,215 +0,0 @@ -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 getAcquisitionsByMerchant(String merchantId) { - return repository.findByMerchantIdAndCancelYn(merchantId, "N") - .stream().map(this::mapToResponse).collect(Collectors.toList()); - } - - @Transactional(readOnly = true) - public List 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> 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 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> handleValidationException(MethodArgumentNotValidException ex) { - Map fieldErrors = new HashMap<>(); - for (FieldError error : ex.getBindingResult().getFieldErrors()) { - fieldErrors.put(error.getField(), error.getDefaultMessage()); - } - Map 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> handleGenericException(Exception ex) { - log.error("예상치 못한 예외 발생", ex); - Map 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); - } - } -} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0466f56..4a5e9a4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,95 +1,20 @@ -# Spring Boot 3.2.5 Configuration - 카드 매입 승인 경로 - spring: application: - name: card-acquisition-approval - - # Jakarta EE 9+ DataSource (jakarta.*) + name: proj-acquirex datasource: - url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1 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: update + ddl-auto: create-drop 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 \ No newline at end of file diff --git a/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java new file mode 100644 index 0000000..b0098a5 --- /dev/null +++ b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java @@ -0,0 +1,102 @@ +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 result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING); + assertThat(result).hasSize(1); + } +} diff --git a/src/test/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplicationTests.java b/src/test/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplicationTests.java deleted file mode 100644 index c42210f..0000000 --- a/src/test/java/com/acquirex/cardapproval/CardAcquisitionApprovalApplicationTests.java +++ /dev/null @@ -1,145 +0,0 @@ -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 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); - } -} \ No newline at end of file