feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)

This commit is contained in:
forge-bot 2026-07-18 15:27:41 +00:00
commit 0f6e3acfe6
92 changed files with 4320 additions and 0 deletions

34
common-framework/pom.xml Normal file
View file

@ -0,0 +1,34 @@
<?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>com.klaro.acquiring</groupId>
<artifactId>card-acquiring-boot</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>common-framework</artifactId>
<name>common-framework</name>
<description>TxCore 프레임워크의 Spring 대체: TxContext, MessageCodec(전문 codec), TxTemplate, 유틸, 예외체계</description>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -0,0 +1,28 @@
package com.klaro.acquiring.common.codec;
/**
* 전문 단일 필드 정의 (FML/UBF 필드 테이블의 행에 대응).
*
* @param name 필드명
* @param length 고정 길이(byte)
* @param type 필드 유형
*/
public record FieldSpec(String name, int length, FieldType type) {
public FieldSpec {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("필드명은 필수입니다");
}
if (length <= 0) {
throw new IllegalArgumentException("필드 길이는 1 이상이어야 합니다: " + name);
}
}
public static FieldSpec an(String name, int length) {
return new FieldSpec(name, length, FieldType.ALPHANUMERIC);
}
public static FieldSpec num(String name, int length) {
return new FieldSpec(name, length, FieldType.NUMERIC);
}
}

View file

@ -0,0 +1,14 @@
package com.klaro.acquiring.common.codec;
/**
* 전문 필드 유형. 고정길이 인코딩 규칙을 결정한다.
*
* <ul>
* <li>{@link #ALPHANUMERIC} : 좌측정렬, 우측 공백 패딩 (문자열)</li>
* <li>{@link #NUMERIC} : 우측정렬, 좌측 0 패딩 (금액/숫자)</li>
* </ul>
*/
public enum FieldType {
ALPHANUMERIC,
NUMERIC
}

View file

@ -0,0 +1,99 @@
package com.klaro.acquiring.common.codec;
import com.klaro.acquiring.common.error.ErrorCode;
import com.klaro.acquiring.common.error.AcquiringException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 고정길이 전문 코덱. 레거시 TxCore TXBUF(FML/UBF) + 고정길이 전문 송수신을
* 대체한다. 필드값 Map 고정길이 문자열 사이를 왕복(round-trip) 변환한다.
*
* <p>인코딩 규칙:
* <ul>
* <li>ALPHANUMERIC : 좌측정렬 우측 공백 패딩, 초과 잘림</li>
* <li>NUMERIC : 우측정렬 좌측 0 패딩, 초과 하위자리 유지(왼쪽 잘림)</li>
* </ul>
* 디코딩 ALPHANUMERIC 우측 공백 제거, NUMERIC 선행 0 제거(빈값은 "0").
*/
public class MessageCodec {
private final MessageSpec spec;
public MessageCodec(MessageSpec spec) {
this.spec = spec;
}
public MessageSpec spec() {
return spec;
}
/** 필드값 Map → 고정길이 전문 문자열. */
public String encode(Map<String, String> values) {
StringBuilder sb = new StringBuilder(spec.totalLength());
for (FieldSpec f : spec.fields()) {
String raw = values.getOrDefault(f.name(), "");
if (raw == null) {
raw = "";
}
sb.append(pad(raw, f));
}
return sb.toString();
}
/** 고정길이 전문 문자열 → 필드값 Map (선언 순서 유지). */
public Map<String, String> decode(String message) {
if (message == null) {
throw new AcquiringException(ErrorCode.INVALID_REQUEST, "전문이 null 입니다");
}
int expected = spec.totalLength();
if (message.length() < expected) {
throw new AcquiringException(ErrorCode.INVALID_REQUEST,
"전문 길이 부족: 기대=" + expected + " 실제=" + message.length()
+ " [" + spec.name() + "]");
}
Map<String, String> out = new LinkedHashMap<>();
int pos = 0;
for (FieldSpec f : spec.fields()) {
String chunk = message.substring(pos, pos + f.length());
pos += f.length();
out.put(f.name(), unpad(chunk, f.type()));
}
return out;
}
private String pad(String raw, FieldSpec f) {
int len = f.length();
if (f.type() == FieldType.NUMERIC) {
String digits = raw.isBlank() ? "0" : raw.trim();
if (digits.length() > len) {
digits = digits.substring(digits.length() - len);
}
return "0".repeat(len - digits.length()) + digits;
}
String v = raw;
if (v.length() > len) {
return v.substring(0, len);
}
return v + " ".repeat(len - v.length());
}
private String unpad(String chunk, FieldType type) {
if (type == FieldType.NUMERIC) {
String s = chunk.trim();
int i = 0;
while (i < s.length() - 1 && s.charAt(i) == '0') {
i++;
}
String stripped = s.isEmpty() ? "0" : s.substring(i);
return stripped.isEmpty() ? "0" : stripped;
}
// 우측 공백 제거만 수행 (선행 공백은 유의미할 있어 보존)
int end = chunk.length();
while (end > 0 && chunk.charAt(end - 1) == ' ') {
end--;
}
return chunk.substring(0, end);
}
}

View file

@ -0,0 +1,25 @@
package com.klaro.acquiring.common.codec;
import java.util.List;
/**
* 전문 레이아웃(고정길이 필드의 순서 있는 목록). FML 필드 테이블 전체에 대응.
*/
public record MessageSpec(String name, List<FieldSpec> fields) {
public MessageSpec {
if (fields == null || fields.isEmpty()) {
throw new IllegalArgumentException("전문 레이아웃에는 최소 1개 필드가 필요합니다");
}
fields = List.copyOf(fields);
}
/** 전문 총 길이(byte). */
public int totalLength() {
return fields.stream().mapToInt(FieldSpec::length).sum();
}
public static MessageSpec of(String name, FieldSpec... fields) {
return new MessageSpec(name, List.of(fields));
}
}

View file

@ -0,0 +1,17 @@
package com.klaro.acquiring.common.dto;
import com.klaro.acquiring.common.error.ErrorCode;
/**
* 표준 응답 봉투. 레거시 응답 TXBUF RESPCODE/데이터 구성을 대체.
*/
public record ApiResponse<T>(String code, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(ErrorCode.OK.code(), ErrorCode.OK.defaultMessage(), data);
}
public static <T> ApiResponse<T> error(ErrorCode code, String message) {
return new ApiResponse<>(code.code(), message, null);
}
}

View file

@ -0,0 +1,23 @@
package com.klaro.acquiring.common.error;
/**
* 도메인 예외 최상위. 모든 업무 예외는 ErrorCode 동반한다.
*/
public class AcquiringException extends RuntimeException {
private final ErrorCode errorCode;
public AcquiringException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public AcquiringException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}

View file

@ -0,0 +1,33 @@
package com.klaro.acquiring.common.error;
/**
* 표준 오류코드. TxCore TX_OK/TX_FAIL/TX_ENOENT/TX_EINVAL/TX_EDB 계열을 대체한다.
*/
public enum ErrorCode {
OK("0000", "정상"),
INVALID_REQUEST("E001", "요청값 오류"),
NOT_FOUND("E002", "대상 없음"),
DUPLICATE("E003", "중복"),
LIMIT_EXCEEDED("E004", "한도 초과"),
RECONCILE_MISMATCH("E005", "대사 불일치"),
BALANCE_MISMATCH("E006", "잔액 불일치"),
PERSISTENCE_ERROR("E007", "저장소 오류"),
ILLEGAL_STATE("E008", "상태 오류"),
INTERNAL_ERROR("E999", "내부 오류");
private final String code;
private final String message;
ErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
public String code() {
return code;
}
public String defaultMessage() {
return message;
}
}

View file

@ -0,0 +1,11 @@
package com.klaro.acquiring.common.error;
/**
* 대상 미존재. TxCore TX_ENOENT 대응.
*/
public class NotFoundException extends AcquiringException {
public NotFoundException(String message) {
super(ErrorCode.NOT_FOUND, message);
}
}

View file

@ -0,0 +1,11 @@
package com.klaro.acquiring.common.error;
/**
* 요청 검증 실패. TxCore TX_EINVAL 대응.
*/
public class ValidationException extends AcquiringException {
public ValidationException(String message) {
super(ErrorCode.INVALID_REQUEST, message);
}
}

View file

@ -0,0 +1,55 @@
package com.klaro.acquiring.common.tx;
import java.time.LocalDate;
import java.util.concurrent.atomic.AtomicLong;
/**
* TxCore TXSVCINFO 컨텍스트 요소(xid, 서비스명, 영업일) 대체하는 경량 컨텍스트.
* 스레드로컬로 현재 처리 컨텍스트를 전달한다.
*/
public final class TxContext {
private static final AtomicLong XID_SEQ = new AtomicLong(0);
private static final ThreadLocal<TxContext> CURRENT = new ThreadLocal<>();
private final long xid;
private final String service;
private final LocalDate bizDate;
private TxContext(long xid, String service, LocalDate bizDate) {
this.xid = xid;
this.service = service;
this.bizDate = bizDate;
}
public static TxContext begin(String service, LocalDate bizDate) {
TxContext ctx = new TxContext(XID_SEQ.incrementAndGet(), service, bizDate);
CURRENT.set(ctx);
return ctx;
}
public static TxContext current() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
public long xid() {
return xid;
}
public String service() {
return service;
}
public LocalDate bizDate() {
return bizDate;
}
@Override
public String toString() {
return "TxContext{xid=" + xid + ", service='" + service + "', bizDate=" + bizDate + '}';
}
}

View file

@ -0,0 +1,31 @@
package com.klaro.acquiring.common.tx;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.function.Supplier;
/**
* TxCore tx_begin/tx_commit/tx_abort 관용구를 대체하는 프로그래밍 트랜잭션 헬퍼.
* 선언적 {@code @Transactional} 표현하기 어려운 배치 스텝 등에서 사용한다.
*/
@Component
public class TxTemplate {
private final TransactionTemplate transactionTemplate;
public TxTemplate(PlatformTransactionManager transactionManager) {
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
/** 결과를 반환하는 트랜잭션 실행 (예외 시 롤백 = tx_abort). */
public <T> T execute(Supplier<T> work) {
return transactionTemplate.execute(status -> work.get());
}
/** 반환값 없는 트랜잭션 실행. */
public void run(Runnable work) {
transactionTemplate.executeWithoutResult(status -> work.run());
}
}

View file

@ -0,0 +1,43 @@
package com.klaro.acquiring.common.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 금액() 유틸. 모든 금전 계산은 BigDecimal 수행하고 단위로 반올림한다
* (레거시는 long 원화 정수; 수수료 계산 절사/반올림 규칙을 명시화).
*/
public final class AmountUtil {
/** 원 단위 (소수점 0자리). */
public static final int WON_SCALE = 0;
private AmountUtil() {
}
public static BigDecimal won(long amount) {
return BigDecimal.valueOf(amount);
}
/** 요율 적용 후 원 단위 반올림(HALF_UP). 예: 금액 10000 * 2.3% = 230. */
public static BigDecimal applyRate(BigDecimal amount, BigDecimal rate) {
if (amount == null || rate == null) {
throw new IllegalArgumentException("금액/요율은 null 일 수 없습니다");
}
return amount.multiply(rate).setScale(WON_SCALE, RoundingMode.HALF_UP);
}
/** 원 단위 정규화. */
public static BigDecimal normalize(BigDecimal amount) {
return amount == null ? BigDecimal.ZERO : amount.setScale(WON_SCALE, RoundingMode.HALF_UP);
}
public static BigDecimal nz(BigDecimal amount) {
return amount == null ? BigDecimal.ZERO : amount;
}
/** |a - b| <= tolerance 이면 true (대사 허용오차 비교). */
public static boolean withinTolerance(BigDecimal a, BigDecimal b, BigDecimal tolerance) {
return nz(a).subtract(nz(b)).abs().compareTo(nz(tolerance)) <= 0;
}
}

View file

@ -0,0 +1,76 @@
package com.klaro.acquiring.common.util;
import com.klaro.acquiring.common.error.ValidationException;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* 영업일(YYYYMMDD) 유틸. 레거시 acq_util date_is_valid / 영업일 계산 대응.
* 주말(/) 비영업일로 간주한다.
*/
public final class DateUtil {
public static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd");
private DateUtil() {
}
public static boolean isValid(String yyyymmdd) {
if (yyyymmdd == null || yyyymmdd.length() != 8) {
return false;
}
try {
LocalDate.parse(yyyymmdd, YYYYMMDD);
return true;
} catch (DateTimeParseException e) {
return false;
}
}
public static LocalDate parse(String yyyymmdd) {
if (!isValid(yyyymmdd)) {
throw new ValidationException("유효하지 않은 영업일: " + yyyymmdd);
}
return LocalDate.parse(yyyymmdd, YYYYMMDD);
}
public static String format(LocalDate date) {
return date.format(YYYYMMDD);
}
public static boolean isBusinessDay(LocalDate date) {
DayOfWeek dow = date.getDayOfWeek();
return dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY;
}
/** 해당일 이후 첫 영업일 (해당일이 영업일이면 그대로). */
public static LocalDate businessDayOnOrAfter(LocalDate date) {
LocalDate d = date;
while (!isBusinessDay(d)) {
d = d.plusDays(1);
}
return d;
}
/** 다음 영업일. */
public static LocalDate nextBusinessDay(LocalDate date) {
LocalDate d = date.plusDays(1);
return businessDayOnOrAfter(d);
}
/** n 영업일 후의 지급예정일 (정산주기 계산에 사용). */
public static LocalDate addBusinessDays(LocalDate date, int n) {
LocalDate d = date;
int added = 0;
while (added < n) {
d = d.plusDays(1);
if (isBusinessDay(d)) {
added++;
}
}
return d;
}
}

View file

@ -0,0 +1,68 @@
package com.klaro.acquiring.common.codec;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MessageCodecTest {
private final MessageSpec spec = MessageSpec.of("AC_REQ",
FieldSpec.an("KEY", 10),
FieldSpec.an("MERCHID", 8),
FieldSpec.num("AMOUNT", 12),
FieldSpec.an("BIZDATE", 8),
FieldSpec.an("STATUS", 1));
private final MessageCodec codec = new MessageCodec(spec);
@Test
void encodesFixedLengthWithPaddingRules() {
Map<String, String> v = new LinkedHashMap<>();
v.put("KEY", "A123");
v.put("MERCHID", "M0001");
v.put("AMOUNT", "10000");
v.put("BIZDATE", "20260717");
v.put("STATUS", "R");
String msg = codec.encode(v);
assertThat(msg).hasSize(spec.totalLength());
assertThat(msg).isEqualTo("A123 " + "M0001 " + "000000010000" + "20260717" + "R");
}
@Test
void decodeIsInverseOfEncode() {
Map<String, String> v = new LinkedHashMap<>();
v.put("KEY", "A123");
v.put("MERCHID", "M0001");
v.put("AMOUNT", "10000");
v.put("BIZDATE", "20260717");
v.put("STATUS", "R");
Map<String, String> decoded = codec.decode(codec.encode(v));
assertThat(decoded.get("KEY")).isEqualTo("A123");
assertThat(decoded.get("MERCHID")).isEqualTo("M0001");
assertThat(decoded.get("AMOUNT")).isEqualTo("10000");
assertThat(decoded.get("BIZDATE")).isEqualTo("20260717");
assertThat(decoded.get("STATUS")).isEqualTo("R");
}
@Test
void numericFieldStripsLeadingZerosOnDecode() {
Map<String, String> decoded = codec.decode(codec.encode(Map.of("AMOUNT", "0")));
assertThat(decoded.get("AMOUNT")).isEqualTo("0");
assertThat(decoded.get("KEY")).isEmpty();
}
@Test
void rejectsTooShortMessage() {
assertThatThrownBy(() -> codec.decode("short"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("전문 길이 부족");
}
}

View file

@ -0,0 +1,49 @@
package com.klaro.acquiring.common.util;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class DateAmountUtilTest {
@Test
void validatesYyyymmdd() {
assertThat(DateUtil.isValid("20260717")).isTrue();
assertThat(DateUtil.isValid("20261317")).isFalse();
assertThat(DateUtil.isValid("2026717")).isFalse();
assertThat(DateUtil.isValid(null)).isFalse();
}
@Test
void skipsWeekendForBusinessDay() {
// 2026-07-17 is a Friday
LocalDate friday = LocalDate.of(2026, 7, 17);
assertThat(DateUtil.isBusinessDay(friday)).isTrue();
// next business day skips Sat/Sun -> Monday 2026-07-20
assertThat(DateUtil.nextBusinessDay(friday)).isEqualTo(LocalDate.of(2026, 7, 20));
}
@Test
void addBusinessDaysSkipsWeekends() {
LocalDate friday = LocalDate.of(2026, 7, 17);
// +2 business days -> Tuesday 2026-07-21
assertThat(DateUtil.addBusinessDays(friday, 2)).isEqualTo(LocalDate.of(2026, 7, 21));
}
@Test
void appliesRateWithWonRounding() {
BigDecimal fee = AmountUtil.applyRate(AmountUtil.won(10000), new BigDecimal("0.023"));
assertThat(fee).isEqualByComparingTo("230");
}
@Test
void toleranceComparison() {
assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1005"),
new BigDecimal("10"))).isTrue();
assertThat(AmountUtil.withinTolerance(new BigDecimal("1000"), new BigDecimal("1050"),
new BigDecimal("10"))).isFalse();
}
}