From ffbeb35d18190d2ea9bfd75ec27a8261aa32099f Mon Sep 17 00:00:00 2001 From: rtgs Date: Thu, 16 Jul 2026 13:40:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20=EA=B1=B0=EB=9E=98=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=B3=84=20=EC=B2=98=EB=A6=AC=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=C2=B7=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20pacs.0?= =?UTF-8?q?02=20=EA=B2=B0=EA=B3=BC=ED=86=B5=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transfer에 pdng_at(Dior)·acsp_at(Hermes) 컬럼 추가(V5), Dior/Hermes가 각 단계 처리시각 기록 - Chanel /inquiry 응답에 접수·순번·기록·정산·완결 5단계 시각(rcvdAt/actcAt/pdngAt/acspAt/acccAt) 노출 - 경합 안전 upsert(Dior COALESCE, Hermes acsp_at 갱신·pdng_at 보존) Co-Authored-By: Claude Opus 4.8 --- .../kr/or/bok/rtgs/chanel/ChanelController.kt | 12 +++++++++++- .../kotlin/kr/or/bok/rtgs/common/ledger/Entities.kt | 3 +++ .../kotlin/kr/or/bok/rtgs/dior/DiorApplication.kt | 13 ++++++++----- .../main/kotlin/kr/or/bok/rtgs/dior/DiorService.kt | 11 ++++++----- .../kr/or/bok/rtgs/hermes/HermesApplication.kt | 11 +++++++---- .../kr/or/bok/rtgs/hermes/SettlementApplier.kt | 1 + .../db/migration/V5__transfer_stage_times.sql | 6 ++++++ 7 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 backend/louisvuitton/src/main/resources/db/migration/V5__transfer_stage_times.sql diff --git a/backend/chanel/src/main/kotlin/kr/or/bok/rtgs/chanel/ChanelController.kt b/backend/chanel/src/main/kotlin/kr/or/bok/rtgs/chanel/ChanelController.kt index 77bdde3..3bf68a3 100644 --- a/backend/chanel/src/main/kotlin/kr/or/bok/rtgs/chanel/ChanelController.kt +++ b/backend/chanel/src/main/kotlin/kr/or/bok/rtgs/chanel/ChanelController.kt @@ -2,6 +2,7 @@ package kr.or.bok.rtgs.chanel import kr.or.bok.rtgs.chanel.jpa.AccountRepository import kr.or.bok.rtgs.chanel.jpa.NotificationRepository +import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository import kr.or.bok.rtgs.chanel.jpa.TransferRepository import kr.or.bok.rtgs.common.iso20022.Iso20022Codec import org.springframework.http.MediaType @@ -21,6 +22,7 @@ class ChanelController( private val transfers: TransferRepository, private val accounts: AccountRepository, private val notifications: NotificationRepository, + private val rawMessages: RawMessageRepository, ) { /** 자금이체 신청/접수: pacs.008 XML → pacs.002 XML. */ @PostMapping( @@ -31,11 +33,13 @@ class ChanelController( fun payCustomer(@RequestBody rawXml: String): String = Iso20022Codec.writePacs002(service.accept(rawXml)) - /** 거래 상태 조회. */ + /** 거래 상태 조회. 서비스별 단계 처리시각(타임라인) 포함. */ @GetMapping("/inquiry/{bmi}") fun inquiry(@PathVariable bmi: String): Map { val t = transfers.findById(bmi).orElse(null) ?: return mapOf("bmi" to bmi, "status" to "IN_FLIGHT", "note" to "not yet persisted") + val finalized = t.status.name == "ACCC" || t.status.name == "RJCT" + val rcvdAt = rawMessages.findById(bmi).orElse(null)?.receivedAt return mapOf( "bmi" to t.bmi, "globalSeq" to t.globalSeq, @@ -46,6 +50,12 @@ class ChanelController( "originCenter" to t.originCenter, "updatedAt" to t.updatedAt, "reason" to t.reason, + // 서비스별 처리시각(epoch ms). 접수=Chanel, 순번=Sequencer, 기록=Dior, 정산=Hermes, 완결=Prada. + "rcvdAt" to rcvdAt, + "actcAt" to t.createdAt, + "pdngAt" to t.pdngAt, + "acspAt" to t.acspAt, + "acccAt" to if (finalized) t.updatedAt else null, ) } diff --git a/backend/common/src/main/kotlin/kr/or/bok/rtgs/common/ledger/Entities.kt b/backend/common/src/main/kotlin/kr/or/bok/rtgs/common/ledger/Entities.kt index 267e977..05e488f 100644 --- a/backend/common/src/main/kotlin/kr/or/bok/rtgs/common/ledger/Entities.kt +++ b/backend/common/src/main/kotlin/kr/or/bok/rtgs/common/ledger/Entities.kt @@ -47,6 +47,9 @@ class TransferRecord( @Column(name = "created_at") var createdAt: Long = 0, @Column(name = "updated_at") var updatedAt: Long = 0, @Column(name = "reason") var reason: String? = null, + /** 단계별 처리시각(서비스 타임라인): 기록(Dior)·정산(Hermes). 접수=raw_message, 순번=created_at, 완결=updated_at. */ + @Column(name = "pdng_at") var pdngAt: Long? = null, + @Column(name = "acsp_at") var acspAt: Long? = null, ) /** diff --git a/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorApplication.kt b/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorApplication.kt index a04ccde..91f0a06 100644 --- a/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorApplication.kt +++ b/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorApplication.kt @@ -23,17 +23,19 @@ class DiorApplication { interface TransferRepository : JpaRepository { /** - * 접수내역을 PDNG로 삽입하되, 이미 존재하면(=Hermes/타 소비자가 먼저 만든 경우) 아무것도 안 함. - * 원자적 upsert라 Dior-Hermes 동시 삽입 경합에서 중복키 예외가 발생하지 않는다. + * 접수내역을 PDNG로 삽입하되, 이미 존재하면(=Hermes/타 소비자가 먼저 만든 경우) 상태·금액 등은 건드리지 않고 + * 기록시각(pdng_at)만 비어있을 때 채운다(COALESCE). 원자적 upsert라 Dior-Hermes 동시 삽입 경합에서 + * 중복키 예외가 발생하지 않으며, 어느 쪽이 먼저 행을 만들어도 pdng_at은 유실 없이 기록된다. */ @Modifying @Query( value = """ INSERT INTO transfer - (bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at) + (bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at, pdng_at) VALUES - (:bmi, :seq, 'PDNG', :sender, :receiver, :amount, :origin, :createdAt, :updatedAt) - ON CONFLICT (bmi) DO NOTHING + (:bmi, :seq, 'PDNG', :sender, :receiver, :amount, :origin, :createdAt, :updatedAt, :pdngAt) + ON CONFLICT (bmi) DO UPDATE SET + pdng_at = COALESCE(transfer.pdng_at, EXCLUDED.pdng_at) """, nativeQuery = true, ) @@ -46,6 +48,7 @@ interface TransferRepository : JpaRepository { @Param("origin") origin: String, @Param("createdAt") createdAt: Long, @Param("updatedAt") updatedAt: Long, + @Param("pdngAt") pdngAt: Long, ): Int } diff --git a/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorService.kt b/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorService.kt index 9e5e874..a81af34 100644 --- a/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorService.kt +++ b/backend/dior/src/main/kotlin/kr/or/bok/rtgs/dior/DiorService.kt @@ -24,8 +24,9 @@ class DiorService( @Transactional fun onJournal(payload: String) { val e = mapper.readValue(payload, JournalEntry::class.java) - // 원자적 upsert: 이미 있으면(Hermes/타 소비자가 먼저 만든 경우) 아무 일도 안 함 → 중복키 예외 없음. - val inserted = transfers.insertPdngIfAbsent( + val now = System.currentTimeMillis() + // 원자적 upsert: 신규면 PDNG로 삽입, 이미 있으면 pdng_at(기록시각)만 채움 → 중복키 예외 없음. + transfers.insertPdngIfAbsent( bmi = e.core.bmi, seq = e.globalSeq, sender = e.core.senderCode, @@ -33,9 +34,9 @@ class DiorService( amount = e.core.amount, origin = e.originCenter, createdAt = e.seqEpochMillis, - updatedAt = System.currentTimeMillis(), + updatedAt = now, + pdngAt = now, ) - if (inserted > 0) log.info("PDNG seq=#{} bmi={}", e.globalSeq, e.core.bmi) - else log.debug("PDNG skip(exists) seq=#{} bmi={}", e.globalSeq, e.core.bmi) + log.info("PDNG seq=#{} bmi={}", e.globalSeq, e.core.bmi) } } diff --git a/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/HermesApplication.kt b/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/HermesApplication.kt index 4ba9242..70b46b9 100644 --- a/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/HermesApplication.kt +++ b/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/HermesApplication.kt @@ -31,15 +31,16 @@ interface JournalLogRepository : JpaRepository interface TransferRepository : JpaRepository { /** * 거래 원장 원자적 upsert. 신규면 INSERT, 이미 있으면(Dior/타 소비자가 먼저 생성) UPDATE. - * created_at은 최초 값 유지(갱신 제외). Dior-Hermes 동시 삽입 경합에서 중복키 예외 제거. + * created_at은 최초 값 유지(갱신 제외). acsp_at(정산시각)은 Hermes가 기록하고, pdng_at은 SET에서 + * 제외해 Dior가 기록한 값을 보존한다. Dior-Hermes 동시 삽입 경합에서 중복키 예외 제거. */ @Modifying @Query( value = """ INSERT INTO transfer - (bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at, reason) + (bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at, reason, acsp_at) VALUES - (:bmi, :seq, :status, :sender, :receiver, :amount, :origin, :createdAt, :updatedAt, :reason) + (:bmi, :seq, :status, :sender, :receiver, :amount, :origin, :createdAt, :updatedAt, :reason, :acspAt) ON CONFLICT (bmi) DO UPDATE SET global_seq = EXCLUDED.global_seq, status = EXCLUDED.status, @@ -48,7 +49,8 @@ interface TransferRepository : JpaRepository { amount = EXCLUDED.amount, origin_center = EXCLUDED.origin_center, updated_at = EXCLUDED.updated_at, - reason = EXCLUDED.reason + reason = EXCLUDED.reason, + acsp_at = EXCLUDED.acsp_at """, nativeQuery = true, ) @@ -63,6 +65,7 @@ interface TransferRepository : JpaRepository { @Param("createdAt") createdAt: Long, @Param("updatedAt") updatedAt: Long, @Param("reason") reason: String?, + @Param("acspAt") acspAt: Long, ): Int } diff --git a/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/SettlementApplier.kt b/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/SettlementApplier.kt index 6d51e64..aead9ae 100644 --- a/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/SettlementApplier.kt +++ b/backend/hermes/src/main/kotlin/kr/or/bok/rtgs/hermes/SettlementApplier.kt @@ -64,6 +64,7 @@ class SettlementApplier( bmi = e.core.bmi, seq = e.globalSeq, status = status.name, sender = e.core.senderCode, receiver = e.core.receiverCode, amount = e.core.amount, origin = e.originCenter, createdAt = e.seqEpochMillis, updatedAt = now, reason = reason, + acspAt = now, ) journalLogs.findById(e.globalSeq).ifPresent { it.applied = true; journalLogs.save(it) } return ResultMessage( diff --git a/backend/louisvuitton/src/main/resources/db/migration/V5__transfer_stage_times.sql b/backend/louisvuitton/src/main/resources/db/migration/V5__transfer_stage_times.sql new file mode 100644 index 0000000..027e8e1 --- /dev/null +++ b/backend/louisvuitton/src/main/resources/db/migration/V5__transfer_stage_times.sql @@ -0,0 +1,6 @@ +-- 거래 단계별 처리시각(서비스별 타임라인). 기존 created_at(순번)·updated_at(완결)에 더해 +-- 기록(Dior)·정산(Hermes) 처리시각을 원장에 영속 기록한다. 추가(additive)라 기존 데이터 무해. +ALTER TABLE transfer ADD COLUMN IF NOT EXISTS pdng_at BIGINT; +ALTER TABLE transfer ADD COLUMN IF NOT EXISTS acsp_at BIGINT; +COMMENT ON COLUMN transfer.pdng_at IS '기록(PDNG) 처리시각(epoch ms) — Dior'; +COMMENT ON COLUMN transfer.acsp_at IS '정산(ACSP) 처리시각(epoch ms) — Hermes';