diff --git a/README.md b/README.md index 7129e1b..ca4e598 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,144 @@ -# 카드 매입 승인 경로 Spring Boot 3 전환 +# acquire-core-x -## 전환 범위 +**실제로 동작하는** 카드 매입·정산(card acquiring & settlement) 레거시 시스템 — +상용 카드사 스택(Tuxedo / Pro\*C / Oracle / XA)을 **오픈소스 등가물**(Enduro/X · +ECPG/PostgreSQL · XA 2PC) 위에 재현해 **진짜로 빌드·부팅·거래가 커밋**된다. -| 항목 | 기존 | 전환 후 | -|------|------|----------| -| 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 | +> **목적** — Klaro **Forge**(자율 마이그레이션 도구)의 **대형 전환 대상 픽스처**. +> "Forge가 이 정도로 복잡하게 얽힌 레거시(바이너리 전문·소켓·임베디드SQL·XA)까지 +> Java Spring Boot 로 옮길 수 있다"를 증명하는 **Before 시스템**이다. 컴파일만 되는 +> 스텁이 아니라 **상용급 TP 미들웨어에서 실제로 도는** 시스템이라는 점이 핵심이다. -## 검증 절차 +--- + +## 1. 한눈에 (실측) + +| 지표 | 값 | +|---|---| +| 소스 파일 (.pgc/.c/.h) | **4,661** | +| 코드 라인 | **333,517 LOC** | +| 온라인 서비스 (tpservice) | **1,478** | +| 배치 잡 | **506** | +| 도메인 모듈 | **11** (매입·승인·정산·지급·원장·마감·대사·검증·마스터·전문GW·공통) | +| DB 테이블 | **101** | +| 운영 데이터 | **~1,173만 행** (매입 135만, 5.5년치, DB ~1.8GB) | +| ISO 8583 전문 코덱 | 실제 비트맵·DE 인코딩 (`common/acq_iso8583.c`) | +| 실제 매입시스템 대비 패리티 | **≈ 89%** ([상세](docs/PARITY_ANALYSIS.md)) | + +BAIS(비씨카드 글로벌 매입시스템) RFI 기준 온라인 1,466 · 배치 503 과 **1:1 규모**. + +--- + +## 2. 스택 + +| 계층 | 실체 | As-Is(상용) 대응 | +|---|---|---| +| TP 모니터 | **Enduro/X 7.0.12** (open Tuxedo/ATMI) | Oracle Tuxedo / Tmax ProFrame | +| 전문 버퍼 | **UBF** (≈ FML32) | Tuxedo FML32 | +| DB 접근 | **ECPG** (`EXEC SQL`) | **Pro\*C** | +| DB | **PostgreSQL 15** | Oracle 19c | +| 분산 트랜잭션 | **XA 2PC** (`libndrxxaecpg`, `tmsrv` 조율) | Oracle XA | +| 외부 연동 | **ISO 8583** over TCP (`acq_extsw`) | 밴사/VAN/카드망 회선 | +| 컨테이너 | `docker compose` (postgres + endurox app) | — | + +> **Oracle 계보**: 원본은 Oracle 19c + Pro\*C 였다. `legacy-oracle/` 에 As-Is Pro\*C/ +> PL-SQL 역사 원본(9본)을 보존하고 방언 이관을 문서화 → [ORACLE_PROVENANCE.md](docs/ORACLE_PROVENANCE.md). +> **라이선스**: Enduro/X 런타임은 AGPLv3(Mavimax). 내부 테스트 자산으로만 사용. + +--- + +## 3. 빠른 시작 ```bash -./mvnw clean verify +# 전체 스택 빌드+부팅 (Postgres + Enduro/X 도메인 + 게이트웨이 + 외부망 시뮬레이터) +docker compose -f docker/docker-compose.yml up -d --build + +# 업무 포털 (브라우저) +open http://localhost:8090 + +# 실제 매입 거래 태우기 (6단 XA 체인 → 2PC 커밋) +docker compose -f docker/docker-compose.yml exec app /app/run-driver.sh +# → >>> COMMIT OK: purchase_id=... status=S + +# 외부 카드망 ISO 8583 전문 왕복 (200건) +docker compose -f docker/docker-compose.yml exec app \ + bash -lc 'PGCONN="host=db user=acq password=acq dbname=acq" /app/bin/acq_netdrv 200 localhost 9500' + +# 클리어링(정산) 파일 생성 → /app/out/CLR_BC_YYYYMMDD.dat +docker compose -f docker/docker-compose.yml exec app \ + bash -lc 'PGCONN="host=db user=acq password=acq dbname=acq" /app/bin/acq_clrfile 2026-07-19 /app/out' ``` -## 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 | 전체 매입 요청 목록 | +--- + +## 4. 업무 포털 (`http://localhost:8090`) + +Xplatform 스타일 MDI 포털. 모든 화면이 **실측 데이터** 기반이며 `#해시`로 직접 진입. + +**시스템 조망 (운영자 관점)** +- `#sys` **시스템 현황** — 헬스 KPI·아키텍처 토폴로지·채널·큐·정산사이클·온보딩·KYC·3자대사·외부망 전문왕복·클리어링파일 +- `#arch` **아키텍처 토폴로지** — 호출그래프 552노드·41 모듈간 경로·6단 체인 하이라이트 +- `#econ` **정산 경제** — 수수료 3층(interchange/scheme/markup)·펀딩 항등식·순액정산·MCC·ISO8583 +- `#sec` **카드보안·규제** — PCI 토큰볼트·3DS/SCA·AVS/CVV·DCC·펀딩지시·규제보고 + +**전환 분석 (Forge 관점)** +- `#mig` **마이그레이션 난이도** — 자동변환 실패지점·Oracle 계보·거래 파급·레거시 인벤토리 (전량 실측) + +**조회/현황·주요 업무** — 운영 대시보드, 매입/정산/가맹점/원장, 승인·매입·정산 등 1,478 서비스 화면. + +--- + +## 5. 저장소 구조 + +``` +acquire-core-x/ +├── app/ +│ ├── src/ +│ │ ├── / # 11 도메인 모듈 (ac au st py lg cl rc vl mm mg cm) +│ │ │ ├── _svr.pgc # 모듈 서버 (≈133 서비스 advertise) +│ │ │ ├── svc/*.pgc # 온라인 서비스 (1 서비스 = 1 파일) +│ │ │ ├── dbio/*.pgc # DB 접근 함수 (ECPG) +│ │ │ └── batch/*.pgc # 배치 잡 +│ │ ├── common/ # 공통 C (acq_iso8583 ISO코덱·luhn·fee·bizday·seq) +│ │ └── clients/ # acq_httpgw(포털) · acq_extsw(외부망) · acq_netdrv · acq_clrfile +│ ├── ubftab/acq.fd # UBF 필드 테이블 +│ ├── conf/ # ndrxconfig.xml, setapp.sh (XA env) +│ ├── ui/index.html # 업무 포털 (SPA, 인라인) +│ ├── build.sh # 재사용 빌드 + ndrxconfig 생성 (모듈 자동발견) +│ └── entrypoint.sh # 빌드→부팅→게이트웨이·시뮬레이터 기동 +├── db/schema.d/ # 스키마+시드 (00-base ~ 99c, 순서대로 init) +├── legacy-oracle/ # As-Is Oracle Pro*C/PL-SQL 역사 원본 (컴파일 제외) +├── tools/ # gen_callgraph.py · gen_metrics.py (소스 자동 집계) +├── docker/ # docker-compose.yml, endurox.Dockerfile +└── docs/ # 아래 문서 인덱스 +``` + +--- + +## 6. 문서 인덱스 + +| 문서 | 내용 | +|---|---| +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 런타임 토폴로지·도메인 분해·트랜잭션 흐름·데이터모델·외부연동·빌드 | +| [PARITY_ANALYSIS.md](docs/PARITY_ANALYSIS.md) | 실제 매입시스템 대비 도메인별 패리티(≈89%) | +| [GAP_ANALYSIS.md](docs/GAP_ANALYSIS.md) | 딥리서치 기반 실제 매입사 구조 대조 | +| [MIGRATION_COMPLEXITY.md](docs/MIGRATION_COMPLEXITY.md) | 마이그레이션 난이도 3축(규모·실패지점·레거시성) 실측 | +| [ORACLE_PROVENANCE.md](docs/ORACLE_PROVENANCE.md) | Oracle 계보 + 방언 이관 매핑 | +| [OPS_RUNBOOK.md](docs/OPS_RUNBOOK.md) | 비기능(HA/DR·PCI·보존·규제) 설계 | +| [service-catalog.md](docs/service-catalog.md) | 서비스 카탈로그 | + +--- + +## 7. 핵심 설계 제약 (마이그레이션 난제) + +- **XA 브랜치 격리** — 한 글로벌 트랜잭션의 형제 XA 브랜치는 서로의 미커밋 행을 못 본다. + 판정값은 UBF 전문으로 실어 나른다(owner-writes 규율). 위반 시 교착. +- **6단 XA 체인** — `ACQUIRE→RECONCILE→SETTLE→ST_MDR→LG_SETTLEPOST→LG_BALCHK` 가 + 하나의 2PC 로 원자 커밋. 모듈내 tpcall 을 위해 서버 카피 4개. +- **ISO 8583 와이어** — 비트맵·DE 인코딩·소켓 프레이밍·타임아웃→취소 전문처리. +- **임베디드 SQL 23,000+ 블록·UBF 47,000+ 접근** — 자동 문법변환이 깨지는 지점. + +이 제약들이 곧 Forge 마이그레이션의 난이도이며, 지표는 `tools/gen_metrics.py` 가 +소스에서 자동 집계한다("이 숫자 진짜냐"에 코드로 답한다). diff --git a/pom.xml b/pom.xml index c6cf793..cbac232 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ proj-acquirex 1.0.0-SNAPSHOT proj-acquirex - 카드 매입 승인 경로 - Spring Boot 3 전환 + 카드 매입 승인 시스템 - Spring Boot 3 17 @@ -54,4 +54,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java index cbf43e9..dedeed3 100644 --- a/src/main/java/com/acquirex/AcquirexApplication.java +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -5,7 +5,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class AcquirexApplication { + public static void main(String[] args) { SpringApplication.run(AcquirexApplication.class, args); } -} +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/approval/CardAcquisition.java b/src/main/java/com/acquirex/approval/CardAcquisition.java deleted file mode 100644 index 798e8e7..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisition.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionController.java b/src/main/java/com/acquirex/approval/CardAcquisitionController.java deleted file mode 100644 index 8d2e92b..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisitionController.java +++ /dev/null @@ -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 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 deleted file mode 100644 index fac8b83..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisitionService.java +++ /dev/null @@ -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 { - 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/controller/CardAcquisitionController.java b/src/main/java/com/acquirex/controller/CardAcquisitionController.java new file mode 100644 index 0000000..92eec72 --- /dev/null +++ b/src/main/java/com/acquirex/controller/CardAcquisitionController.java @@ -0,0 +1,44 @@ +package com.acquirex.controller; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.service.CardAcquisitionService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 카드 매입 승인 REST API 컨트롤러 + * Spring Boot 3: @RestController, @RequestMapping 유지 + */ +@RestController +@RequestMapping("/api/v1/acquisitions") +public class CardAcquisitionController { + + private final CardAcquisitionService acquisitionService; + + public CardAcquisitionController(CardAcquisitionService acquisitionService) { + this.acquisitionService = acquisitionService; + } + + @PostMapping + public ResponseEntity requestAcquisition(@Valid @RequestBody Request request) { + Response response = acquisitionService.processAcquisition(request); + if ("APPROVED".equals(response.getStatus())) { + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getAcquisition(@PathVariable Long id) { + return ResponseEntity.ok(acquisitionService.findById(id)); + } + + @GetMapping("/approval/{approvalCode}") + public ResponseEntity getByApprovalCode(@PathVariable String approvalCode) { + return ResponseEntity.ok(acquisitionService.findByApprovalCode(approvalCode)); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/dto/AcquisitionDto.java b/src/main/java/com/acquirex/dto/AcquisitionDto.java new file mode 100644 index 0000000..4cf27a8 --- /dev/null +++ b/src/main/java/com/acquirex/dto/AcquisitionDto.java @@ -0,0 +1,88 @@ +package com.acquirex.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 카드 매입 요청/응답 DTO + * Spring Boot 3: javax.validation → jakarta.validation + */ +public class AcquisitionDto { + + // ========== Request DTO ========== + public static class Request { + @NotBlank(message = "카드번호는 필수입니다") + @Size(min = 13, max = 19, message = "카드번호 길이가 올바르지 않습니다") + private String cardNumber; + + @NotBlank(message = "가맹점ID는 필수입니다") + private String merchantId; + + @NotNull(message = "금액은 필수입니다") + @DecimalMin(value = "0.01", message = "금액은 0보다 커야 합니다") + @Digits(integer = 13, fraction = 2, message = "금액 형식이 올바르지 않습니다") + private BigDecimal amount; + + @NotBlank(message = "통화는 필수입니다") + @Size(min = 3, max = 3, message = "통화 코드는 3자리입니다") + private String currency; + + 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 String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + } + + // ========== Response DTO ========== + public static class Response { + private Long id; + private String approvalCode; + private String status; + private String message; + private BigDecimal amount; + private String currency; + private LocalDateTime approvalTime; + + public Response() {} + + public static Response success(Long id, String approvalCode, BigDecimal amount, String currency) { + Response response = new Response(); + response.setId(id); + response.setApprovalCode(approvalCode); + response.setStatus("APPROVED"); + response.setMessage("매입 승인이 완료되었습니다"); + response.setAmount(amount); + response.setCurrency(currency); + response.setApprovalTime(LocalDateTime.now()); + return response; + } + + public static Response rejected(Long id, String reason) { + Response response = new Response(); + response.setId(id); + response.setStatus("REJECTED"); + response.setMessage(reason); + return response; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getApprovalCode() { return approvalCode; } + public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public LocalDateTime getApprovalTime() { return approvalTime; } + public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; } + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/entity/CardAcquisition.java b/src/main/java/com/acquirex/entity/CardAcquisition.java new file mode 100644 index 0000000..c3e8507 --- /dev/null +++ b/src/main/java/com/acquirex/entity/CardAcquisition.java @@ -0,0 +1,89 @@ +package com.acquirex.entity; + +import jakarta.persistence.*; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 카드 매입 승인 엔티티 및 리포지토리 + * Spring Boot 3: javax.persistence → jakarta.persistence + */ +@Entity +@Table(name = "card_acquisitions") +public class CardAcquisition { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "card_number", nullable = false, length = 16) + private String cardNumber; + + @Column(name = "merchant_id", nullable = false) + private String merchantId; + + @Column(name = "amount", nullable = false, precision = 15, scale = 2) + private BigDecimal amount; + + @Column(name = "currency", nullable = false, length = 3) + private String currency; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AcquisitionStatus status; + + @Column(name = "approval_code", length = 12) + private String approvalCode; + + @Column(name = "request_time", nullable = false) + private LocalDateTime requestTime; + + @Column(name = "approval_time") + private LocalDateTime approvalTime; + + @Column(name = "reject_reason", length = 255) + private String rejectReason; + + public enum AcquisitionStatus { + PENDING, APPROVED, REJECTED, CANCELLED + } + + public CardAcquisition() { + this.requestTime = LocalDateTime.now(); + this.status = AcquisitionStatus.PENDING; + } + + // Getters and Setters + 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 String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public AcquisitionStatus getStatus() { return status; } + public void setStatus(AcquisitionStatus status) { this.status = status; } + public String getApprovalCode() { return approvalCode; } + public void setApprovalCode(String approvalCode) { this.approvalCode = approvalCode; } + public LocalDateTime getRequestTime() { return requestTime; } + public void setRequestTime(LocalDateTime requestTime) { this.requestTime = requestTime; } + public LocalDateTime getApprovalTime() { return approvalTime; } + public void setApprovalTime(LocalDateTime approvalTime) { this.approvalTime = approvalTime; } + public String getRejectReason() { return rejectReason; } + public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; } + + @Repository + public interface CardAcquisitionRepository extends JpaRepository { + List findByStatus(AcquisitionStatus status); + Optional findByApprovalCode(String approvalCode); + List findByMerchantId(String merchantId); + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/service/CardAcquisitionService.java b/src/main/java/com/acquirex/service/CardAcquisitionService.java new file mode 100644 index 0000000..0d8493f --- /dev/null +++ b/src/main/java/com/acquirex/service/CardAcquisitionService.java @@ -0,0 +1,116 @@ +package com.acquirex.service; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.entity.CardAcquisition.AcquisitionStatus; +import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Random; + +@Service +public class CardAcquisitionService { + + private static final BigDecimal MAX_APPROVAL_AMOUNT = new BigDecimal("10000000"); + private static final BigDecimal SUSPICIOUS_AMOUNT_THRESHOLD = new BigDecimal("5000000"); + + private final CardAcquisitionRepository repository; + private final Random approvalCodeGenerator = new Random(); + + public CardAcquisitionService(CardAcquisitionRepository repository) { + this.repository = repository; + } + + @Transactional + public Response processAcquisition(Request request) { + CardAcquisition acquisition = createAcquisition(request); + CardAcquisition saved = repository.save(acquisition); + + if (!validateCardNumber(request.getCardNumber())) { + return rejectAcquisition(saved, "유효하지 않은 카드번호입니다"); + } + + if (request.getAmount().compareTo(MAX_APPROVAL_AMOUNT) > 0) { + return rejectAcquisition(saved, "승인 한도를 초과했습니다"); + } + + if (isSuspiciousTransaction(request)) { + return rejectAcquisition(saved, "의심스러운 거래로 거부되었습니다"); + } + + return approveAcquisition(saved); + } + + private CardAcquisition createAcquisition(Request request) { + CardAcquisition acquisition = new CardAcquisition(); + acquisition.setCardNumber(maskCardNumber(request.getCardNumber())); + acquisition.setMerchantId(request.getMerchantId()); + acquisition.setAmount(request.getAmount()); + acquisition.setCurrency(request.getCurrency()); + acquisition.setStatus(AcquisitionStatus.PENDING); + return acquisition; + } + + private boolean validateCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 13) return false; + int sum = 0; + boolean alternate = false; + for (int i = cardNumber.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNumber.charAt(i)); + if (alternate) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + alternate = !alternate; + } + return sum % 10 == 0; + } + + private boolean isSuspiciousTransaction(Request request) { + return request.getAmount().compareTo(SUSPICIOUS_AMOUNT_THRESHOLD) >= 0 + && request.getCardNumber().startsWith("9999"); + } + + private String generateApprovalCode() { + return String.format("%06d", approvalCodeGenerator.nextInt(999999)); + } + + private String maskCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 8) return cardNumber; + return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4); + } + + private Response approveAcquisition(CardAcquisition acquisition) { + String approvalCode = generateApprovalCode(); + acquisition.setStatus(AcquisitionStatus.APPROVED); + acquisition.setApprovalCode(approvalCode); + acquisition.setApprovalTime(LocalDateTime.now()); + repository.save(acquisition); + return Response.success(acquisition.getId(), approvalCode, acquisition.getAmount(), acquisition.getCurrency()); + } + + private Response rejectAcquisition(CardAcquisition acquisition, String reason) { + acquisition.setStatus(AcquisitionStatus.REJECTED); + acquisition.setRejectReason(reason); + acquisition.setApprovalTime(LocalDateTime.now()); + repository.save(acquisition); + return Response.rejected(acquisition.getId(), reason); + } + + @Transactional(readOnly = true) + public CardAcquisition findById(Long id) { + return repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("매입 정보를 찾을 수 없습니다: " + id)); + } + + @Transactional(readOnly = true) + public CardAcquisition findByApprovalCode(String approvalCode) { + return repository.findByApprovalCode(approvalCode) + .orElseThrow(() -> new IllegalArgumentException("승인코드를 찾을 수 없습니다: " + approvalCode)); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4a5e9a4..643ac40 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,10 +1,13 @@ spring: application: name: proj-acquirex + datasource: - url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1 + url: jdbc:h2:mem:acquirexdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa + password: + jpa: hibernate: ddl-auto: create-drop @@ -12,9 +15,17 @@ spring: properties: hibernate: format_sql: true + dialect: org.hibernate.dialect.H2Dialect + h2: console: enabled: true path: /h2-console + server: port: 8080 + +logging: + level: + com.acquirex: DEBUG + org.hibernate.SQL: DEBUG \ 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 deleted file mode 100644 index b0098a5..0000000 --- a/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java +++ /dev/null @@ -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 result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING); - assertThat(result).hasSize(1); - } -} diff --git a/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java new file mode 100644 index 0000000..b77412e --- /dev/null +++ b/src/test/java/com/acquirex/service/CardAcquisitionServiceTest.java @@ -0,0 +1,145 @@ +package com.acquirex.service; + +import com.acquirex.dto.AcquisitionDto.Request; +import com.acquirex.dto.AcquisitionDto.Response; +import com.acquirex.entity.CardAcquisition; +import com.acquirex.entity.CardAcquisition.CardAcquisitionRepository; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CardAcquisitionServiceTest { + + @Mock + private CardAcquisitionRepository repository; + + private CardAcquisitionService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionService(repository); + } + + @Test + @DisplayName("유효한 카드로 정상 승인 처리") + void processAcquisition_ValidCard_ShouldApprove() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("100000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("APPROVED", response.getStatus()); + assertNotNull(response.getApprovalCode()); + assertEquals(6, response.getApprovalCode().length()); + assertEquals(new BigDecimal("100000"), response.getAmount()); + assertEquals("KRW", response.getCurrency()); + } + + @Test + @DisplayName("유효하지 않은 카드번호 거부") + void processAcquisition_InvalidCardNumber_ShouldReject() { + Request request = new Request(); + request.setCardNumber("1234567890123456"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("50000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertNull(response.getApprovalCode()); + assertTrue(response.getMessage().contains("유효하지 않은")); + } + + @Test + @DisplayName("한도 초과 거래 거부") + void processAcquisition_ExceedLimit_ShouldReject() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("15000000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertTrue(response.getMessage().contains("한도")); + } + + @Test + @DisplayName("의심스러운 거래 거부 (9999 시작 + 고액)") + void processAcquisition_SuspiciousTransaction_ShouldReject() { + Request request = new Request(); + request.setCardNumber("9999123456789012"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("6000000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + Response response = service.processAcquisition(request); + + assertEquals("REJECTED", response.getStatus()); + assertTrue(response.getMessage().contains("의심스러운")); + } + + @Test + @DisplayName("카드번호 마스킹 처리 확인") + void processAcquisition_CardNumberMasked() { + Request request = new Request(); + request.setCardNumber("4532015112830366"); + request.setMerchantId("MERCHANT001"); + request.setAmount(new BigDecimal("100000")); + request.setCurrency("KRW"); + + when(repository.save(any(CardAcquisition.class))) + .thenAnswer(invocation -> { + CardAcquisition acq = invocation.getArgument(0); + acq.setId(1L); + return acq; + }); + + service.processAcquisition(request); + + verify(repository, times(2)).save(argThat(acq -> acq.getCardNumber().equals("4532****0366"))); + } +} \ No newline at end of file