카드 매입 승인 경로 Spring Boot 3 전환 #12

Closed
forge-bot wants to merge 8 commits from forge/ACX-E2E-1785394564819-attempt-13-run-dada4efd8b46 into main
8 changed files with 551 additions and 0 deletions

79
proj-acquirex/pom.xml Normal file
View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.acquirex</groupId>
<artifactId>proj-acquirex</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>proj-acquirex</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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</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>
</dependencies>
<build>
<plugins>
<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>

View file

@ -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);
}
}

View file

@ -0,0 +1,61 @@
package com.acquirex.approval.controller;
import com.acquirex.approval.domain.AcquisitionApproval.ApprovalStatus;
import com.acquirex.approval.service.AcquisitionApprovalService;
import com.acquirex.approval.service.AcquisitionApprovalService.ApprovalRequest;
import com.acquirex.approval.service.AcquisitionApprovalService.ApprovalResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/** 카드 매입 승인 REST 컨트롤러 - Spring Boot 3: jakarta.* 네임스페이스 적용 */
@RestController
@RequestMapping("/api/v1/acquisitions")
public class AcquisitionApprovalController {
private final AcquisitionApprovalService service;
public AcquisitionApprovalController(AcquisitionApprovalService service) {
this.service = service;
}
@PostMapping("/approvals")
public ResponseEntity<ApprovalResponse> createApproval(@Valid @RequestBody ApprovalRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.processApproval(request));
}
@GetMapping("/approvals/{approvalNumber}")
public ResponseEntity<ApprovalResponse> getApproval(@PathVariable String approvalNumber) {
return ResponseEntity.ok(service.findByApprovalNumber(approvalNumber));
}
@GetMapping("/merchants/{merchantId}/approvals")
public ResponseEntity<List<ApprovalResponse>> getApprovalsByMerchant(
@PathVariable String merchantId, @RequestParam(required = false) ApprovalStatus status) {
return ResponseEntity.ok(service.findByMerchantId(merchantId, status));
}
@PostMapping("/approvals/{id}/cancel")
public ResponseEntity<ApprovalResponse> cancelApproval(@PathVariable Long id) {
return ResponseEntity.ok(service.cancelApproval(id));
}
@ExceptionHandler(jakarta.validation.ConstraintViolationException.class)
public ResponseEntity<Map<String, String>> handleConstraintViolation(jakarta.validation.ConstraintViolationException ex) {
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<Map<String, String>> handleIllegalState(IllegalStateException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", ex.getMessage()));
}
}

View file

@ -0,0 +1,86 @@
package com.acquirex.approval.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 카드 매입 승인 도메인 엔티티
* Spring Boot 3: javax.* jakarta.* 마이그레이션
*/
@Entity
@Table(name = "acquisition_approval")
public class AcquisitionApproval {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "merchant_id", nullable = false, length = 20)
private String merchantId;
@Column(name = "card_number", nullable = false, length = 19)
private String cardNumber;
@Column(name = "approval_amount", nullable = false, precision = 15, scale = 2)
private BigDecimal approvalAmount;
@Column(name = "approval_number", nullable = false, length = 12)
private String approvalNumber;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ApprovalStatus status;
@Column(name = "approval_datetime", nullable = false)
private LocalDateTime approvalDatetime;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public AcquisitionApproval() {}
public AcquisitionApproval(String merchantId, String cardNumber, BigDecimal approvalAmount,
String approvalNumber, ApprovalStatus status, LocalDateTime approvalDatetime) {
this.merchantId = merchantId;
this.cardNumber = cardNumber;
this.approvalAmount = approvalAmount;
this.approvalNumber = approvalNumber;
this.status = status;
this.approvalDatetime = approvalDatetime;
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getMerchantId() { return merchantId; }
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
public String getCardNumber() { return cardNumber; }
public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
public BigDecimal getApprovalAmount() { return approvalAmount; }
public void setApprovalAmount(BigDecimal approvalAmount) { this.approvalAmount = approvalAmount; }
public String getApprovalNumber() { return approvalNumber; }
public void setApprovalNumber(String approvalNumber) { this.approvalNumber = approvalNumber; }
public ApprovalStatus getStatus() { return status; }
public void setStatus(ApprovalStatus status) { this.status = status; }
public LocalDateTime getApprovalDatetime() { return approvalDatetime; }
public void setApprovalDatetime(LocalDateTime approvalDatetime) { this.approvalDatetime = approvalDatetime; }
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; }
@PreUpdate
protected void onUpdate() { this.updatedAt = LocalDateTime.now(); }
/** 매입 승인 상태 열거형 */
public enum ApprovalStatus {
PENDING("대기"), APPROVED("승인"), REJECTED("거절"), CANCELLED("취소");
private final String description;
ApprovalStatus(String description) { this.description = description; }
public String getDescription() { return description; }
}
}

View file

@ -0,0 +1,18 @@
package com.acquirex.approval.repository;
import com.acquirex.approval.domain.AcquisitionApproval;
import com.acquirex.approval.domain.AcquisitionApproval.ApprovalStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/** 카드 매입 승인 레포지토리 - Spring Boot 3: Spring Data JPA 3.x 호환 */
@Repository
public interface AcquisitionApprovalRepository extends JpaRepository<AcquisitionApproval, Long> {
Optional<AcquisitionApproval> findByApprovalNumber(String approvalNumber);
List<AcquisitionApproval> findByMerchantIdAndStatus(String merchantId, ApprovalStatus status);
List<AcquisitionApproval> findByApprovalDatetimeBetween(LocalDateTime start, LocalDateTime end);
}

View file

@ -0,0 +1,115 @@
package com.acquirex.approval.service;
import com.acquirex.approval.domain.AcquisitionApproval;
import com.acquirex.approval.domain.AcquisitionApproval.ApprovalStatus;
import com.acquirex.approval.repository.AcquisitionApprovalRepository;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/** 카드 매입 승인 서비스 - Spring Boot 3 전환 적용 */
@Service
@Transactional(readOnly = true)
public class AcquisitionApprovalService {
private final AcquisitionApprovalRepository repository;
public AcquisitionApprovalService(AcquisitionApprovalRepository repository) {
this.repository = repository;
}
/** 매입 승인 요청 처리 */
@Transactional
public ApprovalResponse processApproval(ApprovalRequest request) {
String approvalNumber = generateApprovalNumber();
AcquisitionApproval approval = new AcquisitionApproval(
request.merchantId, maskCardNumber(request.cardNumber),
request.approvalAmount, approvalNumber, ApprovalStatus.APPROVED, LocalDateTime.now());
return toResponse(repository.save(approval));
}
/** 승인 번호로 조회 */
public ApprovalResponse findByApprovalNumber(String approvalNumber) {
return repository.findByApprovalNumber(approvalNumber)
.map(this::toResponse)
.orElseThrow(() -> new IllegalArgumentException("승인 번호를 찾을 수 없습니다: " + approvalNumber));
}
/** 가맹점별 승인 목록 조회 */
public List<ApprovalResponse> findByMerchantId(String merchantId, ApprovalStatus status) {
return repository.findByMerchantIdAndStatus(merchantId, status)
.stream().map(this::toResponse).collect(Collectors.toList());
}
/** 승인 취소 처리 */
@Transactional
public ApprovalResponse cancelApproval(Long id) {
AcquisitionApproval approval = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("승인 정보를 찾을 수 없습니다: " + id));
if (approval.getStatus() == ApprovalStatus.CANCELLED) {
throw new IllegalStateException("이미 취소된 승인입니다");
}
approval.setStatus(ApprovalStatus.CANCELLED);
return toResponse(repository.save(approval));
}
private String generateApprovalNumber() {
return "AP" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 4).toUpperCase();
}
private String maskCardNumber(String cardNumber) {
if (cardNumber == null || cardNumber.length() < 10) return cardNumber;
return cardNumber.substring(0, 6) + "****" + cardNumber.substring(cardNumber.length() - 4);
}
private ApprovalResponse toResponse(AcquisitionApproval a) {
return new ApprovalResponse(a.getId(), a.getMerchantId(), a.getCardNumber(),
a.getApprovalAmount(), a.getApprovalNumber(), a.getStatus(), a.getApprovalDatetime());
}
/** 카드 매입 승인 요청 DTO - Spring Boot 3: jakarta.validation 적용 */
public static class ApprovalRequest {
@NotBlank(message = "가맹점 ID는 필수입니다")
@Size(max = 20)
public String merchantId;
@NotBlank(message = "카드번호는 필수입니다")
@Pattern(regexp = "\\d{13,19}", message = "카드번호는 13~19자리 숫자입니다")
public String cardNumber;
@NotNull(message = "승인 금액은 필수입니다")
@DecimalMin(value = "0.01", message = "승인 금액은 0보다 커야 합니다")
@DecimalMax(value = "999999999.99")
public BigDecimal approvalAmount;
public ApprovalRequest() {}
public ApprovalRequest(String merchantId, String cardNumber, BigDecimal approvalAmount) {
this.merchantId = merchantId; this.cardNumber = cardNumber; this.approvalAmount = approvalAmount;
}
}
/** 카드 매입 승인 응답 DTO */
public static class ApprovalResponse {
public Long id;
public String merchantId;
public String cardNumber;
public BigDecimal approvalAmount;
public String approvalNumber;
public ApprovalStatus status;
public LocalDateTime approvalDatetime;
public ApprovalResponse() {}
public ApprovalResponse(Long id, String merchantId, String cardNumber, BigDecimal approvalAmount,
String approvalNumber, ApprovalStatus status, LocalDateTime approvalDatetime) {
this.id = id; this.merchantId = merchantId; this.cardNumber = cardNumber;
this.approvalAmount = approvalAmount; this.approvalNumber = approvalNumber;
this.status = status; this.approvalDatetime = approvalDatetime;
}
}
}

View file

@ -0,0 +1,23 @@
# Spring Boot 3.x Configuration
spring.application.name=proj-acquirex
server.port=8080
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate Configuration
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# Jackson Configuration
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.time-zone=Asia/Seoul

View file

@ -0,0 +1,158 @@
package com.acquirex.approval;
import com.acquirex.approval.domain.AcquisitionApproval;
import com.acquirex.approval.domain.AcquisitionApproval.ApprovalStatus;
import com.acquirex.approval.repository.AcquisitionApprovalRepository;
import com.acquirex.approval.service.AcquisitionApprovalService;
import com.acquirex.approval.service.AcquisitionApprovalService.ApprovalRequest;
import com.acquirex.approval.service.AcquisitionApprovalService.ApprovalResponse;
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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** 카드 매입 승인 통합 테스트 - Spring Boot 3 전환 검증 */
@SpringBootTest
@AutoConfigureMockMvc
class AcquisitionApprovalTest {
@Autowired private MockMvc mockMvc;
@Autowired private AcquisitionApprovalRepository repository;
@Autowired private AcquisitionApprovalService service;
@BeforeEach
void setUp() { repository.deleteAll(); }
// === Service Tests ===
@Test
@DisplayName("매입 승인 요청 처리 성공")
void processApproval_Success() {
ApprovalRequest request = new ApprovalRequest("MERCH001", "1234567890123456", new BigDecimal("50000.00"));
ApprovalResponse response = service.processApproval(request);
assertNotNull(response);
assertEquals("MERCH001", response.merchantId);
assertEquals(ApprovalStatus.APPROVED, response.status);
assertTrue(response.approvalNumber.startsWith("AP"));
assertTrue(response.cardNumber.matches("\\d{6}\*\*\*\*\\d{4}"));
}
@Test
@DisplayName("승인 번호로 조회 성공")
void findByApprovalNumber_Success() {
ApprovalRequest request = new ApprovalRequest("MERCH001", "1234567890123456", new BigDecimal("50000.00"));
ApprovalResponse created = service.processApproval(request);
ApprovalResponse found = service.findByApprovalNumber(created.approvalNumber);
assertNotNull(found);
assertEquals(created.approvalNumber, found.approvalNumber);
}
@Test
@DisplayName("승인 번호로 조회 - 존재하지 않는 경우 예외 발생")
void findByApprovalNumber_NotFound() {
assertThrows(IllegalArgumentException.class, () -> service.findByApprovalNumber("NOTEXIST"));
}
@Test
@DisplayName("승인 취소 성공")
void cancelApproval_Success() {
ApprovalRequest request = new ApprovalRequest("MERCH001", "1234567890123456", new BigDecimal("50000.00"));
ApprovalResponse created = service.processApproval(request);
ApprovalResponse cancelled = service.cancelApproval(created.id);
assertEquals(ApprovalStatus.CANCELLED, cancelled.status);
}
@Test
@DisplayName("승인 취소 - 이미 취소된 경우 예외 발생")
void cancelApproval_AlreadyCancelled() {
ApprovalRequest request = new ApprovalRequest("MERCH001", "1234567890123456", new BigDecimal("50000.00"));
ApprovalResponse created = service.processApproval(request);
service.cancelApproval(created.id);
assertThrows(IllegalStateException.class, () -> service.cancelApproval(created.id));
}
// === Controller Tests ===
@Test
@DisplayName("POST /api/v1/acquisitions/approvals - 승인 요청 성공")
void createApproval_Success() throws Exception {
String json = "{\"merchantId\":\"MERCH001\",\"cardNumber\":\"1234567890123456\",\"approvalAmount\":50000.00}";
mockMvc.perform(post("/api/v1/acquisitions/approvals")
.contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.merchantId").value("MERCH001"))
.andExpect(jsonPath("$.status").value("APPROVED"));
}
@Test
@DisplayName("POST /api/v1/acquisitions/approvals - 유효성 검증 실패")
void createApproval_ValidationError() throws Exception {
String json = "{\"merchantId\":\"\",\"cardNumber\":\"123\",\"approvalAmount\":-100}";
mockMvc.perform(post("/api/v1/acquisitions/approvals")
.contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("GET /api/v1/acquisitions/approvals/{approvalNumber} - 승인 조회 성공")
void getApproval_Success() throws Exception {
String json = "{\"merchantId\":\"MERCH001\",\"cardNumber\":\"1234567890123456\",\"approvalAmount\":50000.00}";
MvcResult result = mockMvc.perform(post("/api/v1/acquisitions/approvals")
.contentType(MediaType.APPLICATION_JSON).content(json)).andReturn();
String approvalNumber = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.approvalNumber");
mockMvc.perform(get("/api/v1/acquisitions/approvals/{approvalNumber}", approvalNumber))
.andExpect(status().isOk())
.andExpect(jsonPath("$.approvalNumber").value(approvalNumber));
}
@Test
@DisplayName("POST /api/v1/acquisitions/approvals/{id}/cancel - 승인 취소 성공")
void cancelApproval_Controller_Success() throws Exception {
String json = "{\"merchantId\":\"MERCH001\",\"cardNumber\":\"1234567890123456\",\"approvalAmount\":50000.00}";
MvcResult result = mockMvc.perform(post("/api/v1/acquisitions/approvals")
.contentType(MediaType.APPLICATION_JSON).content(json)).andReturn();
Long id = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.id");
mockMvc.perform(post("/api/v1/acquisitions/approvals/{id}/cancel", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CANCELLED"));
}
// === Domain Tests ===
@Test
@DisplayName("ApprovalStatus 모든 상태 확인")
void testAllStatuses() {
assertEquals(4, ApprovalStatus.values().length);
assertNotNull(ApprovalStatus.PENDING);
assertNotNull(ApprovalStatus.APPROVED);
assertNotNull(ApprovalStatus.REJECTED);
assertNotNull(ApprovalStatus.CANCELLED);
}
@Test
@DisplayName("ApprovalStatus 설명 확인")
void testStatusDescriptions() {
assertEquals("대기", ApprovalStatus.PENDING.getDescription());
assertEquals("승인", ApprovalStatus.APPROVED.getDescription());
assertEquals("거절", ApprovalStatus.REJECTED.getDescription());
assertEquals("취소", ApprovalStatus.CANCELLED.getDescription());
}
}