commit e6e29e6b5d6d809082514d63775e5198c3f4fca3 Author: forge-bot Date: Sun Jul 19 09:16:12 2026 +0000 Phase 1: 실동작 Enduro/X + PostgreSQL XA 매입 슬라이스 - Enduro/X 7.0.12 소스빌드 이미지(-DENABLE_POSTGRES=ON, libndrxxaecpg XA 스위치) - docker-compose: postgres(max_prepared_transactions=100) + endurox app - ECPG XA 서비스 3종: ACQUIRE→RECONCILE→SETTLE (tpservice + EXEC SQL) - 클라이언트 tpbegin/tpcall체인/tpcommit → tmsrv 2PC(prepare/commit) 원자 커밋 - 검증: 서비스 AVAIL, 커밋 row(status=S) psql 확인, 롤백 시 무잔존, pg_prepared_xacts=0 - 규명된 런타임 요건: sysctl fs.mqueue.*, ulimit msgqueue/nofile, XA open-str JSON - docs/architecture.md, README, service-catalog 설계 Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eca07db --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# build artifacts +*.o +*.so +build/ +bin/acq* +# ecpg-generated C from .pgc (regenerated at build) +**/gen/*.c +# Enduro/X runtime state +ULOG.* +log/ +qdata/ +tmlogs/ +tmp/ +*.pid +# ref (upstream Enduro/X test sample, vendored for guidance only) +ref/ +# ecpg-generated C (sibling of *.pgc) +app/src/acquire.c +app/src/reconcile.c +app/src/settle.c +app/bin/ +app/conf/setapp.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..0976928 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# acquire-core-x — 카드 매입·정산 시스템 (실동작 Enduro/X) + +실제 **Enduro/X**(오픈소스 Tuxedo/ATMI TP 모니터) + **ECPG/PostgreSQL** 위에서 +**실제로 기동·트랜잭션 처리되는** 카드 매입·정산 레거시 시스템. 포지(Klaro Forge) +자율 마이그레이션 도구의 대형 전환 대상 픽스처 — 이 C/ATMI 시스템을 Java Spring Boot로 +전환한다. + +> 앞서의 `acquire-core-full`(자체 TxCore 셔임, 컴파일만 되는 픽스처)과 달리, 이 프로젝트는 +> **진짜 TP 미들웨어에서 tpcall·XA 트랜잭션이 실제로 동작**한다. (사용자 요구 = B방식) + +## 스택 + +| 계층 | 실체 | +|---|---| +| TP 모니터 | Enduro/X 7.0.12 (오픈 Tuxedo/ATMI: `tpservice`/`tpcall`/`tpadvertise`/`tpbegin`) — 소스빌드 | +| 전문 버퍼 | UBF (≈FML32): `Bget`/`Bchg`, 필드테이블 `mkfldhdr` | +| DB 접근 | ECPG (`EXEC SQL`) → PostgreSQL 15 | +| 분산 트랜잭션 | XA 2PC — `libndrxxaecpg.so` ECPG XA 스위치, `tmsrv`가 조율 | +| 오케스트레이션 | `ndrxd` + `ndrxconfig.xml` + `app.ini`(CCONFIG) | + +## 실행 (docker-compose) + +```bash +docker compose -f docker/docker-compose.yml up -d --build # postgres + endurox app +``` + +### 필수 런타임 요건 (없으면 ndrxd 부팅 실패 — 실측으로 규명됨) +POSIX 큐/세마포어 한도 때문에 app 컨테이너에 반드시 필요: +```yaml +sysctls: + fs.mqueue.msg_max: "512" + fs.mqueue.msgsize_max: "65536" +ulimits: + msgqueue: 536870912 # 큐 1개 = msg_max×msgsize_max ≈ 5.6MB, 기본 RLIMIT_MSGQUEUE(800KB) 초과 + nofile: 65536 +``` +PostgreSQL는 XA(2단계 커밋) 위해 `-c max_prepared_transactions=100`. + +## 구성 + +- `docker/` — `endurox.Dockerfile`(Enduro/X 소스빌드, `-DENABLE_POSTGRES=ON`), `docker-compose.yml` +- `db/` — `schema.sql`(merchant/purchase/approval/settlement/ledger …) +- `app/` — `conf/`(ndrxconfig.xml, app.ini, 환경), `ubftab/`(UBF 필드테이블), + 서비스 `.pgc`/`.c`, `entrypoint.sh` +- `docs/` — `architecture.md`, `service-catalog.md` +- `ref/` — Enduro/X upstream PostgreSQL 통합테스트(참고용, .gitignore) +- `slice/` — 최초 검증용 non-DB ATMI 슬라이스(부팅+tpcall 증명 완료) + +## 검증 상태 + +- [x] Enduro/X 소스빌드 (PostgreSQL XA 포함) → 이미지 `acquire-x/endurox:7.0.12` +- [x] `ndrxd` 부팅 (10 프로세스 runok) +- [x] 커스텀 `tpservice` advertise + **실제 `tpcall` 왕복** (ACQUIRE: fee=2500 net=997500) +- [x] ECPG XA 서비스 체인(매입→대사→정산) + 커밋 row psql 검증 + 롤백 검증 ✅ +- [ ] 2000본+ 확장 (Phase 2) +- [ ] 포지 마이그레이션 → Spring Boot (Phase 3) diff --git a/app/conf/app.ini b/app/conf/app.ini new file mode 100644 index 0000000..570e385 --- /dev/null +++ b/app/conf/app.ini @@ -0,0 +1,31 @@ +[@global] +NDRX_CLUSTERISED=1 +NDRX_CMDWAIT=1 +NDRX_CONFIG=${NDRX_APPHOME}/conf/ndrxconfig.xml +NDRX_DMNLOG=${NDRX_APPHOME}/log/ndrxd.log +NDRX_DPID=${NDRX_APPHOME}/tmp/ndrxd.pid +NDRX_DQMAX=100 +NDRX_IPCKEY=44100 +NDRX_LDBAL=0 +NDRX_LEV=5 +NDRX_LOG=${NDRX_APPHOME}/log/xadmin.log +NDRX_MSGMAX=100 +NDRX_MSGSIZEMAX=56000 +NDRX_NODEID=1 +NDRX_QPATH=/dev/mqueue +NDRX_QPREFIX=/acq +NDRX_RNDK=0myWI5nu +NDRX_SRVMAX=10000 +NDRX_SVCMAX=20000 +NDRX_TOUT=90 +NDRX_UBFMAXFLDS=16000 +NDRX_LIBEXT=so +NDRX_ULOG=${NDRX_APPHOME}/log +FIELDTBLS=acq.fd +FLDTBLDIR=${NDRX_APPHOME}/ubftab + +[@debug] +; * - goes for all binaries not listed below +*= ndrx=5 ubf=1 tp=5 file= +xadmin= ndrx=5 ubf=1 tp=5 file=${NDRX_APPHOME}/log/xadmin.log +ndrxd= ndrx=5 ubf=1 tp=5 file=${NDRX_APPHOME}/log/ndrxd.log diff --git a/app/conf/ndrxconfig.xml b/app/conf/ndrxconfig.xml new file mode 100644 index 0000000..0384a8c --- /dev/null +++ b/app/conf/ndrxconfig.xml @@ -0,0 +1,55 @@ + + + + 1 + 5 + 1 + 1 + 5 + 20 + Y + + + 1 + 1 + 1 + 20 + 100 + 800 + 30 + 1 + + + + + 40 + 1 + 1 + -e ${NDRX_APPHOME}/log/tmsrv-rm1.log -r -- -t1 -l${NDRX_APPHOME}/tmlogs/rm1 -m10 + + + + + 100 + 1 + 1 + -e ${NDRX_APPHOME}/log/acqacquire.log -r + + + 110 + 1 + 1 + -e ${NDRX_APPHOME}/log/acqreconcile.log -r + + + 120 + 1 + 1 + -e ${NDRX_APPHOME}/log/acqsettle.log -r + + + diff --git a/app/entrypoint.sh b/app/entrypoint.sh new file mode 100755 index 0000000..80be350 --- /dev/null +++ b/app/entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/bash +## +## Build + boot the Enduro/X acquiring slice, then stay alive. +## Runs inside the app container (image acquire-x/endurox:7.0.12). +## +set -e + +. /app/conf/setapp.sh + +echo "==================================================================" +echo " acquire-core-x : build + boot" +echo "==================================================================" + +# runtime dirs +mkdir -p /app/log /app/tmp /app/tmlogs/rm1 /app/bin +rm -f /app/log/*.log 2>/dev/null || true + +# ensure posix mqueue is available (compose provides the sysctls/ulimits) +if [ ! -d /dev/mqueue ]; then + mkdir -p /dev/mqueue + mount -t mqueue none /dev/mqueue 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# 1. UBF field header from the field table +# --------------------------------------------------------------------------- +echo "--- mkfldhdr acq.fd ---" +cd /app/ubftab +FIELDTBLS=acq.fd FLDTBLDIR=/app/ubftab mkfldhdr acq.fd +ls -l /app/ubftab/acq.fd.h + +# --------------------------------------------------------------------------- +# 2. ECPG precompile + buildserver each .pgc (XA switch loaded via env, so +# buildserver needs no -r; we just link ecpg + pq). buildclient the driver. +# --------------------------------------------------------------------------- +export CFLAGS="-I/app/ubftab -I/app/src -I$(pg_config --includedir)" +PGLIB="-L$(pg_config --libdir) -lecpg -lpq" + +cd /app/src +build_svc () { + local src="$1" bin="$2" + echo "--- ecpg $src ---" + ecpg -I/app/ubftab -o "/app/src/${src%.pgc}.c" "/app/src/$src" + echo "--- buildserver -> $bin ---" + buildserver -o "/app/bin/$bin" -f "/app/src/${src%.pgc}.c" -a "$PGLIB" +} + +build_svc acquire.pgc acqacquire +build_svc reconcile.pgc acqreconcile +build_svc settle.pgc acqsettle + +echo "--- buildclient -> acqdrv ---" +buildclient -o /app/bin/acqdrv -f /app/src/acqdrv.c + +ls -l /app/bin + +# --------------------------------------------------------------------------- +# 3. Boot Enduro/X +# --------------------------------------------------------------------------- +echo "--- xadmin down/start ---" +xadmin down -y 2>/dev/null || true +xadmin start -y + +echo "--- xadmin psc (advertised services) ---" +xadmin psc +echo "--- xadmin ppm (processes) ---" +xadmin ppm + +echo "==================================================================" +echo " acquire-core-x : UP. Run the driver with:" +echo " docker compose -f docker/docker-compose.yml exec app /app/run-driver.sh" +echo "==================================================================" + +# keep the container (and the ndrxd daemon) alive +tail -F /app/log/ndrxd.log diff --git a/app/run-driver.sh b/app/run-driver.sh new file mode 100755 index 0000000..034b46c --- /dev/null +++ b/app/run-driver.sh @@ -0,0 +1,16 @@ +#!/bin/bash +## Run the acquiring driver under a real global XA transaction. +## ./run-driver.sh -> one COMMIT run + one ROLLBACK run (demo) +## ./run-driver.sh [abort] -> single custom run +. /app/conf/setapp.sh + +if [ "$#" -ge 1 ]; then + exec /app/bin/acqdrv "$@" +fi + +echo "===== COMMIT run (ACQUIRE -> RECONCILE -> SETTLE, tpcommit) =====" +/app/bin/acqdrv M0001 1000000 2026-07-19 + +echo +echo "===== ROLLBACK run (same chain, tpabort) =====" +/app/bin/acqdrv M0002 500000 2026-07-19 abort diff --git a/app/src/acqdrv.c b/app/src/acqdrv.c new file mode 100644 index 0000000..b580a1c --- /dev/null +++ b/app/src/acqdrv.c @@ -0,0 +1,86 @@ +/* + * acqdrv - acquiring driver client. + * + * Starts ONE global transaction (tpbegin), fires the ACQUIRE -> RECONCILE -> + * SETTLE chain via a single tpcall("ACQUIRE"), then tpcommit (or tpabort when + * the 4th arg is "abort", to prove XA rollback). + * + * Usage: acqdrv [merchant] [amount] [bizdate YYYY-MM-DD] [abort] + */ +#include +#include +#include +#include +#include +#include "acq.fd.h" + +int main(int argc, char **argv) +{ + const char *merch = (argc > 1) ? argv[1] : "M0001"; + long amount = (argc > 2) ? atol(argv[2]) : 1000000; /* 10,000.00 */ + const char *bizdate = (argc > 3) ? argv[3] : "2026-07-19"; + int do_abort = (argc > 4 && strcmp(argv[4], "abort") == 0); + + UBFH *b; + long rlen = 0; + long pid = 0, fee = 0, net = 0, sid = 0; + char st[16] = ""; + BFLDLEN l; + + if (tpinit(NULL) < 0) { + fprintf(stderr, "tpinit FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + if (tpopen() < 0) { + fprintf(stderr, "tpopen FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + + b = (UBFH *)tpalloc("UBF", NULL, 4096); + if (b == NULL) { + fprintf(stderr, "tpalloc FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + Bchg(b, T_MERCHANT, 0, (char *)merch, 0L); + Bchg(b, T_AMOUNT, 0, (char *)&amount, 0L); + Bchg(b, T_BIZDATE, 0, (char *)bizdate, 0L); + + if (tpbegin(60, 0) < 0) { + fprintf(stderr, "tpbegin FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + + if (tpcall("ACQUIRE", (char *)b, 0L, (char **)&b, &rlen, 0L) < 0) { + fprintf(stderr, "tpcall(ACQUIRE) FAIL: %s\n", tpstrerror(tperrno)); + tpabort(0); + return 1; + } + + Bget(b, T_PURCHASE_ID, 0, (char *)&pid, 0L); + Bget(b, T_FEE, 0, (char *)&fee, 0L); + Bget(b, T_NET, 0, (char *)&net, 0L); + Bget(b, T_SETTLE_ID, 0, (char *)&sid, 0L); + l = sizeof(st); Bget(b, T_STATUS, 0, st, &l); + + if (do_abort) { + if (tpabort(0) < 0) { + fprintf(stderr, "tpabort FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + printf(">>> ROLLBACK: global tx aborted. purchase_id=%ld should NOT persist " + "(merch=%s amount=%ld)\n", pid, merch, amount); + } else { + if (tpcommit(0) < 0) { + fprintf(stderr, "tpcommit FAIL: %s\n", tpstrerror(tperrno)); + tpabort(0); + return 1; + } + printf(">>> COMMIT OK: purchase_id=%ld merch=%s amount=%ld fee=%ld net=%ld " + "settlement_id=%ld status=%s\n", pid, merch, amount, fee, net, sid, st); + } + + tpfree((char *)b); + tpclose(); + tpterm(); + return 0; +} diff --git a/app/src/acquire.pgc b/app/src/acquire.pgc new file mode 100644 index 0000000..19e7b11 --- /dev/null +++ b/app/src/acquire.pgc @@ -0,0 +1,142 @@ +/* + * ACQUIRE (매입 접수) - ECPG XA service. + * + * Inserts a `purchase` row (fee = amount * 25/10000 MDR, net = amount - fee), + * then within the SAME global transaction tpcall()s RECONCILE. + * + * XA connection is opened by the ECPG XA switch (libndrxxaecpg.so) via tpopen(); + * there is deliberately NO `EXEC SQL CONNECT` (matches ref/atmisv67.pgc). + */ +#include +#include +#include +#include +#include +#include +#include +#include "acq.fd.h" + +void ACQUIRE(TPSVCINFO *p); + +int tpsvrinit(int argc, char **argv) +{ + if (tpopen() < 0) { + userlog("acquire: tpopen FAIL: %s", tpstrerror(tperrno)); + return -1; + } + if (tpadvertise("ACQUIRE", ACQUIRE) < 0) { + userlog("acquire: tpadvertise(ACQUIRE) FAIL: %s", tpstrerror(tperrno)); + return -1; + } + userlog("acquire: ACQUIRE advertised, RM opened"); + return 0; +} + +void tpsvrdone(void) +{ + tpclose(); + userlog("acquire: tpsvrdone"); +} + +void ACQUIRE(TPSVCINFO *p) +{ + UBFH *b = (UBFH *)p->data; + long amount = 0, fee = 0, net = 0; + char merch[64] = ""; + char bizdate[16] = ""; + BFLDLEN len; + long rlen = 0; + + EXEC SQL BEGIN DECLARE SECTION; + long h_pid; + long h_amount; + long h_fee; + long h_net; + char h_merch[64]; + char h_bizdate[16]; + EXEC SQL END DECLARE SECTION; + + len = sizeof(merch); + if (Bget(b, T_MERCHANT, 0, merch, &len) < 0) { + userlog("ACQUIRE: missing T_MERCHANT: %s", Bstrerror(Berror)); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + Bget(b, T_AMOUNT, 0, (char *)&amount, 0L); + len = sizeof(bizdate); + Bget(b, T_BIZDATE, 0, bizdate, &len); + + fee = amount * 25 / 10000; /* 0.25% MDR */ + net = amount - fee; + + /* obtain a purchase id (sequence is non-transactional by design) */ + EXEC SQL SELECT nextval('purchase_seq') INTO :h_pid; + if (sqlca.sqlcode < 0) { + userlog("ACQUIRE: nextval FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + h_amount = amount; + h_fee = fee; + h_net = net; + strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch) - 1] = 0; + strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate) - 1] = 0; + + EXEC SQL INSERT INTO purchase + (purchase_id, merchant_id, amount, fee, net, status, biz_date) + VALUES (:h_pid, :h_merch, :h_amount, :h_fee, :h_net, 'A', :h_bizdate); + if (sqlca.sqlcode < 0) { + userlog("ACQUIRE: INSERT purchase FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + /* record the approval as well (all under the same global tx) */ + EXEC SQL INSERT INTO approval (approval_id, purchase_id, approved) + VALUES (nextval('approval_seq'), :h_pid, true); + if (sqlca.sqlcode < 0) { + userlog("ACQUIRE: INSERT approval FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + Bchg(b, T_PURCHASE_ID, 0, (char *)&h_pid, 0L); + Bchg(b, T_FEE, 0, (char *)&fee, 0L); + Bchg(b, T_NET, 0, (char *)&net, 0L); + Bchg(b, T_STATUS, 0, "A", 0L); + + userlog("ACQUIRE pid=%ld merch=%s amount=%ld fee=%ld net=%ld -> RECONCILE", + h_pid, merch, amount, fee, net); + + /* Continue the chain under the SAME global transaction. RECONCILE (and, in + * turn, SETTLE) run as separate XA branches (separate DB connections) and + * therefore CANNOT see this not-yet-committed purchase row - so they write + * only their OWN rows (ledger/settlement). This owner branch performs the + * purchase status transition itself once the chain returns, because its + * connection is resumed on the same branch and can see its own INSERT. */ + if (tpcall("RECONCILE", (char *)b, 0L, (char **)&b, &rlen, 0L) < 0) { + userlog("ACQUIRE: tpcall(RECONCILE) FAIL: %s", tpstrerror(tperrno)); + tpreturn(TPFAIL, 0, (char *)b, 0L, 0L); + return; + } + + /* branch A resumed here: settle-complete the purchase we own */ + EXEC SQL UPDATE purchase + SET status = 'S', updated_at = now() + WHERE purchase_id = :h_pid; + if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) { + userlog("ACQUIRE: UPDATE purchase status FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, (char *)b, 0L, 0L); + return; + } + + Bchg(b, T_STATUS, 0, "S", 0L); + + tpreturn(TPSUCCESS, 0, (char *)b, 0L, 0L); +} + +/* vim: set ts=4 sw=4 et smartindent: */ diff --git a/app/src/reconcile.pgc b/app/src/reconcile.pgc new file mode 100644 index 0000000..d04b6f9 --- /dev/null +++ b/app/src/reconcile.pgc @@ -0,0 +1,88 @@ +/* + * RECONCILE (대사) - ECPG XA service. + * + * Marks the purchase matched (status='M'), writes a ledger recon entry, then + * tpcall()s SETTLE - all inside the caller's global transaction. + */ +#include +#include +#include +#include +#include +#include +#include +#include "acq.fd.h" + +void RECONCILE(TPSVCINFO *p); + +int tpsvrinit(int argc, char **argv) +{ + if (tpopen() < 0) { + userlog("reconcile: tpopen FAIL: %s", tpstrerror(tperrno)); + return -1; + } + if (tpadvertise("RECONCILE", RECONCILE) < 0) { + userlog("reconcile: tpadvertise(RECONCILE) FAIL: %s", tpstrerror(tperrno)); + return -1; + } + userlog("reconcile: RECONCILE advertised, RM opened"); + return 0; +} + +void tpsvrdone(void) +{ + tpclose(); + userlog("reconcile: tpsvrdone"); +} + +void RECONCILE(TPSVCINFO *p) +{ + UBFH *b = (UBFH *)p->data; + long net = 0; + char bizdate[16] = ""; + BFLDLEN len; + long rlen = 0; + + EXEC SQL BEGIN DECLARE SECTION; + long h_pid; + long h_net; + char h_bizdate[16]; + EXEC SQL END DECLARE SECTION; + + if (Bget(b, T_PURCHASE_ID, 0, (char *)&h_pid, 0L) < 0) { + userlog("RECONCILE: missing T_PURCHASE_ID: %s", Bstrerror(Berror)); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + Bget(b, T_NET, 0, (char *)&net, 0L); + len = sizeof(bizdate); + Bget(b, T_BIZDATE, 0, bizdate, &len); + h_net = net; + strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate) - 1] = 0; + + /* This is a distinct XA branch (own DB connection); it writes only its own + * recon evidence row and does NOT touch the purchase row (which belongs to + * the ACQUIRE branch and is not yet committed / not visible here). */ + EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date) + VALUES (nextval('ledger_seq'), :h_pid, 'RECON', :h_net, :h_bizdate); + if (sqlca.sqlcode < 0) { + userlog("RECONCILE: INSERT ledger FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + Bchg(b, T_STATUS, 0, "M", 0L); + + userlog("RECONCILE pid=%ld matched -> SETTLE", h_pid); + + if (tpcall("SETTLE", (char *)b, 0L, (char **)&b, &rlen, 0L) < 0) { + userlog("RECONCILE: tpcall(SETTLE) FAIL: %s", tpstrerror(tperrno)); + tpreturn(TPFAIL, 0, (char *)b, 0L, 0L); + return; + } + + tpreturn(TPSUCCESS, 0, (char *)b, 0L, 0L); +} + +/* vim: set ts=4 sw=4 et smartindent: */ diff --git a/app/src/settle.pgc b/app/src/settle.pgc new file mode 100644 index 0000000..99c4b1f --- /dev/null +++ b/app/src/settle.pgc @@ -0,0 +1,107 @@ +/* + * SETTLE (정산) - ECPG XA service. + * + * Inserts a `settlement` (netting) row, writes a ledger settle entry, and marks + * the purchase settled (status='S'). Terminal service of the chain; still runs + * inside the caller's global transaction. + */ +#include +#include +#include +#include +#include +#include +#include +#include "acq.fd.h" + +void SETTLE(TPSVCINFO *p); + +int tpsvrinit(int argc, char **argv) +{ + if (tpopen() < 0) { + userlog("settle: tpopen FAIL: %s", tpstrerror(tperrno)); + return -1; + } + if (tpadvertise("SETTLE", SETTLE) < 0) { + userlog("settle: tpadvertise(SETTLE) FAIL: %s", tpstrerror(tperrno)); + return -1; + } + userlog("settle: SETTLE advertised, RM opened"); + return 0; +} + +void tpsvrdone(void) +{ + tpclose(); + userlog("settle: tpsvrdone"); +} + +void SETTLE(TPSVCINFO *p) +{ + UBFH *b = (UBFH *)p->data; + long net = 0; + char merch[64] = ""; + char bizdate[16] = ""; + BFLDLEN len; + + EXEC SQL BEGIN DECLARE SECTION; + long h_pid; + long h_sid; + long h_net; + char h_merch[64]; + char h_bizdate[16]; + EXEC SQL END DECLARE SECTION; + + if (Bget(b, T_PURCHASE_ID, 0, (char *)&h_pid, 0L) < 0) { + userlog("SETTLE: missing T_PURCHASE_ID: %s", Bstrerror(Berror)); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + Bget(b, T_NET, 0, (char *)&net, 0L); + len = sizeof(merch); + Bget(b, T_MERCHANT, 0, merch, &len); + len = sizeof(bizdate); + Bget(b, T_BIZDATE, 0, bizdate, &len); + + h_net = net; + strncpy(h_merch, merch, sizeof(h_merch) - 1); h_merch[sizeof(h_merch) - 1] = 0; + strncpy(h_bizdate, bizdate, sizeof(h_bizdate) - 1); h_bizdate[sizeof(h_bizdate) - 1] = 0; + + EXEC SQL SELECT nextval('settlement_seq') INTO :h_sid; + if (sqlca.sqlcode < 0) { + userlog("SETTLE: nextval FAIL [%d] %s", sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + EXEC SQL INSERT INTO settlement + (settlement_id, purchase_id, merchant_id, net, biz_date, status) + VALUES (:h_sid, :h_pid, :h_merch, :h_net, :h_bizdate, 'SETTLED'); + if (sqlca.sqlcode < 0) { + userlog("SETTLE: INSERT settlement FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + /* SETTLE is its own XA branch: it writes the settlement + a settle ledger + * entry (its own rows). The purchase status transition is performed by the + * ACQUIRE branch that owns that row. */ + EXEC SQL INSERT INTO ledger (ledger_id, purchase_id, entry_type, amount, biz_date) + VALUES (nextval('ledger_seq'), :h_pid, 'SETTLE', :h_net, :h_bizdate); + if (sqlca.sqlcode < 0) { + userlog("SETTLE: INSERT ledger FAIL [%d] %s", + sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + + Bchg(b, T_SETTLE_ID, 0, (char *)&h_sid, 0L); + Bchg(b, T_STATUS, 0, "S", 0L); + + userlog("SETTLE pid=%ld settlement_id=%ld net=%ld status=S", h_pid, h_sid, net); + + tpreturn(TPSUCCESS, 0, (char *)b, 0L, 0L); +} + +/* vim: set ts=4 sw=4 et smartindent: */ diff --git a/app/ubftab/acq.fd b/app/ubftab/acq.fd new file mode 100644 index 0000000..a0b73dd --- /dev/null +++ b/app/ubftab/acq.fd @@ -0,0 +1,13 @@ +$/* @(#) Acquiring (카드 매입) UBF field table */ +*base 6000 + +# name id type flag comment +T_MERCHANT 1 string - - +T_AMOUNT 2 long - - +T_FEE 3 long - - +T_NET 4 long - - +T_STATUS 5 string - - +T_PURCHASE_ID 6 long - - +T_BIZDATE 7 string - - +T_SETTLE_ID 8 long - - +T_MSG 9 string - - diff --git a/app/ubftab/acq.fd.h b/app/ubftab/acq.fd.h new file mode 100644 index 0000000..de21306 --- /dev/null +++ b/app/ubftab/acq.fd.h @@ -0,0 +1,12 @@ +/* @(#) Acquiring (카드 매입) UBF field table */ +/* fname bfldid */ +/* ----- ----- */ +#define T_MERCHANT ((BFLDID32)167778161) /* number: 6001 type: string */ +#define T_AMOUNT ((BFLDID32)33560434) /* number: 6002 type: long */ +#define T_FEE ((BFLDID32)33560435) /* number: 6003 type: long */ +#define T_NET ((BFLDID32)33560436) /* number: 6004 type: long */ +#define T_STATUS ((BFLDID32)167778165) /* number: 6005 type: string */ +#define T_PURCHASE_ID ((BFLDID32)33560438) /* number: 6006 type: long */ +#define T_BIZDATE ((BFLDID32)167778167) /* number: 6007 type: string */ +#define T_SETTLE_ID ((BFLDID32)33560440) /* number: 6008 type: long */ +#define T_MSG ((BFLDID32)167778169) /* number: 6009 type: string */ diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..32600bd --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,74 @@ +-- Card-acquiring vertical-slice schema (카드 매입/승인/정산/원장) +-- Loaded into PostgreSQL on first boot (docker-entrypoint-initdb.d). + +-- --------------------------------------------------------------------------- +-- 가맹점 (merchant) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS merchant ( + merchant_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + mdr_bps INTEGER NOT NULL DEFAULT 25, -- Merchant Discount Rate, basis points (0.25%) + status TEXT NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- 매입 (purchase) - the acquiring transaction +-- purchase_id driven by an explicit sequence so ECPG can SELECT nextval first. +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS purchase_seq; +CREATE TABLE IF NOT EXISTS purchase ( + purchase_id BIGINT PRIMARY KEY, + merchant_id TEXT NOT NULL, + amount BIGINT NOT NULL, -- minor units (원, 정수) + fee BIGINT NOT NULL, -- MDR fee + net BIGINT NOT NULL, -- amount - fee + status TEXT NOT NULL, -- A=접수, M=대사완료(matched), S=정산완료(settled) + biz_date DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- 승인 (approval) +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS approval_seq; +CREATE TABLE IF NOT EXISTS approval ( + approval_id BIGINT PRIMARY KEY, + purchase_id BIGINT NOT NULL, + approved BOOLEAN NOT NULL DEFAULT true, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- 원장 (ledger) - double-entry style recon check written during 대사 +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS ledger_seq; +CREATE TABLE IF NOT EXISTS ledger ( + ledger_id BIGINT PRIMARY KEY, + purchase_id BIGINT NOT NULL, + entry_type TEXT NOT NULL, -- RECON / SETTLE + amount BIGINT NOT NULL, + biz_date DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- 정산 (settlement) - netting written during 정산 +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS settlement_seq; +CREATE TABLE IF NOT EXISTS settlement ( + settlement_id BIGINT PRIMARY KEY, + purchase_id BIGINT NOT NULL, + merchant_id TEXT NOT NULL, + net BIGINT NOT NULL, + biz_date DATE NOT NULL, + status TEXT NOT NULL DEFAULT 'SETTLED', + settled_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Seed a couple of merchants so ACQUIRE has a valid counterparty. +INSERT INTO merchant (merchant_id, name, mdr_bps) VALUES + ('M0001', '클라로 커피', 25), + ('M0002', '포지 마트', 25) +ON CONFLICT (merchant_id) DO NOTHING; diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..54e07ec --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,43 @@ +name: acquire-core-x + +services: + db: + image: postgres:15 + environment: + POSTGRES_DB: acq + POSTGRES_USER: acq + POSTGRES_PASSWORD: acq + # XA / 2-phase commit needs prepared transactions enabled. + command: ["-c", "max_prepared_transactions=100"] + volumes: + - ../db/schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U acq -d acq"] + interval: 3s + timeout: 3s + retries: 20 + networks: + - acqnet + + app: + image: acquire-x/endurox:7.0.12 + depends_on: + db: + condition: service_healthy + # Enduro/X posix-mqueue runtime requirements (ndrxd won't boot otherwise). + sysctls: + fs.mqueue.msg_max: "512" + fs.mqueue.msgsize_max: "65536" + ulimits: + msgqueue: 536870912 + nofile: 65536 + volumes: + - ../app:/app + working_dir: /app + entrypoint: ["/bin/bash", "/app/entrypoint.sh"] + networks: + - acqnet + +networks: + acqnet: + driver: bridge diff --git a/docker/endurox.Dockerfile b/docker/endurox.Dockerfile new file mode 100644 index 0000000..2eeac2d --- /dev/null +++ b/docker/endurox.Dockerfile @@ -0,0 +1,26 @@ +# Enduro/X (open-source Tuxedo/ATMI) + ECPG/PostgreSQL client — runnable builder image +FROM debian:12 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -qq && apt-get install -y -qq \ + build-essential cmake flex bison git \ + libxml2-dev libssl-dev uuid-dev libgpgme-dev \ + libecpg-dev libpq-dev postgresql-server-dev-all \ + postgresql-client \ + procps net-tools vim-tiny ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# --- build Enduro/X core from source (tag 7.0.12) --- +WORKDIR /opt +RUN git clone --depth 1 --branch 7.0.12 https://github.com/endurox-dev/endurox /opt/endurox-src +WORKDIR /opt/endurox-src +# atmitest 서브디렉터리는 생성헤더(t40.h 등) 의존으로 코어 빌드를 깨뜨려 제외 (DISABLETEST가 실제 제외 안 함) +RUN sed -i -E '/add_subdirectory *\(atmitest/d' CMakeLists.txt +RUN cmake -DDEFINE_DISABLEDOC=ON -DDEFINE_DISABLETEST=ON -DENABLE_POSTGRES=ON -DCMAKE_INSTALL_PREFIX=/usr/local . \ + && make -j"$(nproc)" \ + && make install \ + && ldconfig +# runtime env defaults +ENV NDRX_HOME=/usr/local PATH=/usr/local/bin:$PATH LD_LIBRARY_PATH=/usr/local/lib +WORKDIR /app +CMD ["bash"] diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2a9f503 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# 아키텍처 — acquire-core-x + +## 1. 런타임 토폴로지 + +``` + ┌──────────────────────────── app 컨테이너 (Enduro/X) ────────────────────────────┐ + acqdrv ──▶ │ ndrxd (TP 모니터) │ + (client) │ ├─ tmsrv (RM1 = PostgreSQL, XA 조율: prepare/commit/rollback) │ + tpbegin │ ├─ cconfsrv / tpevsrv / cpmsrv (시스템) │ + tpcall ───▶│ ├─ acq_online : ACQUIRE, CANCEL, CORRECT … (매입 온라인 tpservice) │ + tpcommit │ ├─ rc_online : RECONCILE, REMATCH … (대사) │ + │ ├─ st_online : SETTLE, FEECALC … (정산·수수료) │ + │ └─ *_batch : 커서 배치 (마감/집계) │ + │ │ EXEC SQL (ECPG, XA 스위치 libndrxxaecpg) │ + └────────┼──────────────────────────────────────────────────────────────────────┘ + ▼ + PostgreSQL (db 컨테이너, max_prepared_transactions=100) + merchant / purchase / approval / settlement / ledger … +``` + +## 2. 트랜잭션 모델 (핵심 — 진짜 XA 2PC) + +클라이언트가 `tpbegin()`으로 **글로벌 트랜잭션** 시작 → `tpcall("ACQUIRE")` → +ACQUIRE 서비스가 `EXEC SQL INSERT purchase` (자동으로 XA `xa_start`로 글로벌 tx에 편입) → +같은 tx 안에서 `tpcall("RECONCILE")` → `tpcall("SETTLE")` → 클라이언트 `tpcommit()` → +`tmsrv`가 **xa_prepare → xa_commit** (2단계 커밋)으로 모든 서비스의 DB 작업을 원자적으로 확정. +`tpabort()` 시 전부 롤백 → DB에 아무것도 남지 않음. + +이것이 레거시 Tuxedo/ProFrame 시스템의 표준 패턴이며, 마이그레이션 도구가 실제로 마주할 idiom이다. + +## 3. 모듈 (11) — acquire-core-full 도메인 계승 + +| 코드 | 모듈 | 대표 서비스/배치 | +|---|---|---| +| ac | 매입(acquiring) | ACQUIRE, DDC, EDI청구, 취소/정정, 부분/할부/해외, 미매입추출 | +| au | 승인/한도 | AUTH중계, 한도차감/복원, 대행승인, 이상거래탐지 | +| rc | 대사(reconcile) | RECONCILE(승인-매입-입금 3-way), tolerance, 재대사, 반송 | +| st | 정산/수수료 | SETTLE, MDR/VAN수수료, netting, 원천징수, T+n | +| py | 지급(payment) | 지급파일생성, 결과반영, 재처리, 이체연계 | +| lg | 원장(ledger) | 복식전표, 잔액검증, 전기, 역분개, 마감연계 | +| cl | 마감(closing) | 일/월/분기 마감, 스냅샷, 재마감 | +| mm | 마스터 | 가맹점/수수료율/BIN/한도 마스터 | +| vl | 정합성검증 | 매출/카드/한도/금액 정합성, 이상탐지 | +| mg | 전문게이트웨이 | 전문 수/송신, ISO8583 파싱, STAN채번, 라우팅 | +| cm | 공통 | 공통코드, 영업일, 금액유틸, 채번, Luhn/CRC | + +## 4. 파일 아키타입 (2000본 구성 근거) + +| 아키타입 | 형식 | 개수(목표) | 성격 | +|---|---|---|---| +| online service | `.pgc` (`tpservice` + EXEC SQL) | ~350 | tpcall 진입점, XA | +| dbio | `.pgc` (EXEC SQL 함수) | ~300 | 서비스가 호출하는 DB 접근 계층 | +| batch | `.pgc`/`.c` (`main`, 커서) | ~180 | 마감/집계, XA | +| common util | `.c` | ~120 | leaf 유틸 | +| copybook/header | `.h`, UBF 필드테이블 `.fd` | ~600 | 구조체·FML 필드 | +| config | ndrxconfig.xml, app.ini, UBFTAB, XA RM | ~50 | TP/서버/RM 설정 | +| shell/run | `.sh` | ~180 | 기동/배치 스크립트 | +| schema/seed | `.sql` | ~50 | 스키마·시드 | +| **합계** | | **~2,030** | | + +## 5. 마이그레이션 타깃 매핑 (포지 → Spring Boot) + +| 레거시 (Enduro/X) | Spring Boot | +|---|---| +| `tpservice`/`tpadvertise` | `@Service` 빈 + 메서드 | +| `tpcall(SVC)` | 빈 주입 호출 (동기) | +| UBF `Bget/Bchg` (전문버퍼) | DTO/record | +| `tpbegin/tpcommit` (XA) | `@Transactional` (JTA/단일DB) | +| `EXEC SQL` / ECPG dbio | MyBatis/JPA repository | +| 커서 배치 | Spring Batch | +| 고정길이 전문 | 코덱(fixed-length) | +| ndrxconfig 서비스 등록 | 컴포넌트 스캔/라우팅 | +| `schema.sql` | Flyway | diff --git a/slice/acq.fd b/slice/acq.fd new file mode 100644 index 0000000..c0c50c7 --- /dev/null +++ b/slice/acq.fd @@ -0,0 +1,7 @@ +$/* @(#) Acquiring UBF field table */ +*base 6000 +T_MERCHANT 1 string - - +T_AMOUNT 2 long - - +T_FEE 3 long - - +T_NET 4 long - - +T_STATUS 5 string - - diff --git a/slice/acqcl.c b/slice/acqcl.c new file mode 100644 index 0000000..f796458 --- /dev/null +++ b/slice/acqcl.c @@ -0,0 +1,30 @@ +#include +#include +#include +#include +#include "acq.fd.h" + +int main(int argc, char **argv) +{ + if (tpinit(NULL) < 0) { fprintf(stderr, "tpinit FAIL: %s\n", tpstrerror(tperrno)); return 1; } + + UBFH *b = (UBFH *)tpalloc("UBF", NULL, 1024); + long amount = 1000000; /* 10,000.00원 */ + Bchg(b, T_MERCHANT, 0, "M0001", 0L); + Bchg(b, T_AMOUNT, 0, (char *)&amount, 0L); + + long rlen = 0; + if (tpcall("ACQUIRE", (char *)b, 0L, (char **)&b, &rlen, 0L) < 0) { + fprintf(stderr, "tpcall FAIL: %s\n", tpstrerror(tperrno)); + return 1; + } + + long fee = 0, net = 0; char st[32] = ""; BFLDLEN l = sizeof(st); + Bget(b, T_FEE, 0, (char *)&fee, 0L); + Bget(b, T_NET, 0, (char *)&net, 0L); + Bget(b, T_STATUS, 0, st, &l); + printf(">>> TPCALL OK: amount=%ld fee=%ld net=%ld status=%s\n", amount, fee, net, st); + + tpfree((char *)b); tpterm(); + return 0; +} diff --git a/slice/acqsv.c b/slice/acqsv.c new file mode 100644 index 0000000..2beb499 --- /dev/null +++ b/slice/acqsv.c @@ -0,0 +1,49 @@ +#include +#include +#include +#include +#include +#include "acq.fd.h" + +void ACQUIRE(TPSVCINFO *p); /* forward decl */ + +/* 서버 프로세스 초기화/종료 콜백 (ATMI 필수) */ +int tpsvrinit(int argc, char **argv) +{ + if (tpadvertise("ACQUIRE", ACQUIRE) < 0) { + userlog("acqsv: tpadvertise(ACQUIRE) FAIL: %s", tpstrerror(tperrno)); + return -1; + } + userlog("acqsv: tpsvrinit - ACQUIRE 서비스 advertise 완료"); + return 0; +} + +void tpsvrdone(void) +{ + userlog("acqsv: tpsvrdone - 종료"); +} + +void ACQUIRE(TPSVCINFO *p) +{ + UBFH *b = (UBFH *)p->data; + long amount = 0, fee, net; + char merch[64] = ""; + BFLDLEN len = sizeof(merch); + + if (Bget(b, T_MERCHANT, 0, merch, &len) < 0) { + userlog("ACQUIRE: missing T_MERCHANT: %s", Bstrerror(Berror)); + tpreturn(TPFAIL, 0, p->data, 0L, 0L); + return; + } + Bget(b, T_AMOUNT, 0, (char *)&amount, 0L); + + fee = amount * 25 / 10000; /* 0.25% MDR 수수료 */ + net = amount - fee; + + Bchg(b, T_FEE, 0, (char *)&fee, 0L); + Bchg(b, T_NET, 0, (char *)&net, 0L); + Bchg(b, T_STATUS, 0, "ACQUIRED", 0L); + + userlog("ACQUIRE merch=%s amount=%ld fee=%ld net=%ld", merch, amount, fee, net); + tpreturn(TPSUCCESS, 0, p->data, 0L, 0L); +}