From 2ddaa9d0ee64fe5960a82733753a6171d8c09319 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 18 Aug 2026 15:29:54 +0800 Subject: [PATCH] feat: complete resumable payment settlement --- .../service/PaymentApplicationService.java | 117 +++++++++++++++++- .../service/PaymentTrainingActionService.java | 32 ++++- .../payment/domain/model/PaymentOrder.java | 33 ++++- .../payment/domain/model/PaymentStatus.java | 4 + .../repository/PaymentResourceRepository.java | 8 ++ .../JdbcPaymentResourceRepository.java | 104 +++++++++++++++- .../interfaces/rest/PaymentController.java | 35 +++++- .../PaymentTrainingActionServiceTest.java | 66 ++++++++++ .../rest/PaymentControllerTest.java | 63 ++++++++++ .../PaymentTrainingAttemptControllerTest.java | 9 +- 10 files changed, 457 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java index 4b0e14a..0d5f9dc 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java @@ -181,7 +181,7 @@ public class PaymentApplicationService { @Transactional public void cancel(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); - if (order.getStatus().ordinal() >= PaymentStatus.CENTRAL_SETTLED.ordinal()) { + if (order.getStatus().ordinal() >= PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED.ordinal()) { throw validation("中央银行已完成权属转移,不能取消支付实验"); } if (order.getStatus() == PaymentStatus.COMPLIANCE_LOCKED @@ -192,6 +192,98 @@ public class PaymentApplicationService { } } + public PaymentOrderView verifyPayerBankSignature(UUID id, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getStatus().ordinal() < PaymentStatus.PAYER_BANK_ACCEPTED.ordinal()) { + throw validation("请先完成付款银行合规处理"); + } + resourceRepository.verifyPayerBankSignature(order, actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView transferOwnership(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYER_BANK_ACCEPTED) { + return atOrAfter(order, PaymentStatus.PAYER_BANK_ACCEPTED, actor); + } + resourceRepository.verifyPayerBankSignature(order, actor); + order.transferOwnership(resourceRepository.transferOwnership(order, actor)); + orderRepository.save(order, actor.getUsername()); + return view(order, actor); + } + + @Transactional + public PaymentOrderView concatenateSettlement(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED) { + return atOrAfter(order, PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED, actor); + } + String clearingNo = "ACS_" + order.getPaymentNo(); + String original = "SETTLEMENT|" + order.getPaymentNo() + "|" + order.getTransactionId() + "|" + + order.getPayerWalletId() + "|" + order.getPayeeWalletId() + "|" + order.getCoinCount() + "|" + + order.getAmount().toPlainString() + "|" + order.getPayerBankCode() + "|" + + order.getPayeeBankCode() + "|" + clearingNo + "|" + clock.instant().toString(); + order.prepareSettlement(clearingNo, original); + orderRepository.save(order, actor.getUsername()); + return view(order, actor); + } + + @Transactional + public PaymentOrderView generateSettlementDigest(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.SETTLEMENT_TEXT_PREPARED) { + return atOrAfter(order, PaymentStatus.SETTLEMENT_TEXT_PREPARED, actor); + } + order.digestSettlement(cryptography.sm3(order.getSettlementOriginalText())); + orderRepository.save(order, actor.getUsername()); + return view(order, actor); + } + + @Transactional + public PaymentOrderView signSettlement(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.SETTLEMENT_DIGESTED) { + return atOrAfter(order, PaymentStatus.SETTLEMENT_DIGESTED, actor); + } + order.signSettlement(resourceRepository.signSettlementDigest(order, actor, order.getSettlementDigest())); + orderRepository.save(order, actor.getUsername()); + return view(order, actor); + } + + @Transactional + public PaymentOrderView clearAtCentralBank(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.CENTRAL_SIGNED) { + return atOrAfter(order, PaymentStatus.CENTRAL_SIGNED, actor); + } + resourceRepository.recordCentralSettlement(order, actor); + order.recordCentralClearing(); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "04", "中央银行结算", order.getSettlementOriginalText(), actor); + return view(order, actor); + } + + public PaymentOrderView verifyCentralBankSignature(UUID id, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getStatus().ordinal() < PaymentStatus.CENTRAL_SETTLED.ordinal()) { + throw validation("请先完成中央银行结算"); + } + resourceRepository.verifyCentralBankSignature(order, actor); + return view(order, actor); + } + + public PaymentOrderView prepareCredit(UUID id, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getStatus() != PaymentStatus.CENTRAL_SETTLED) { + if (order.getStatus().ordinal() > PaymentStatus.CENTRAL_SETTLED.ordinal()) return view(order, actor); + throw validation("请先完成中央银行结算"); + } + resourceRepository.verifyCentralBankSignature(order, actor); + resourceRepository.validateCredit(order); + return view(order, actor); + } + @Transactional public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -225,6 +317,29 @@ public class PaymentApplicationService { return view(order, actor); } + @Transactional + public PaymentOrderView notifyPayer(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYEE_CREDITED) { + return atOrAfter(order, PaymentStatus.PAYEE_CREDITED, actor); + } + resourceRepository.createPayerNotification(order); + return view(order, actor); + } + + @Transactional + public PaymentOrderView notifyPayee(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYEE_CREDITED) { + return atOrAfter(order, PaymentStatus.PAYEE_CREDITED, actor); + } + resourceRepository.createPayeeNotification(order); + order.complete(clock.instant()); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "06", "用户返回", "已生成支付回执和到账通知", actor); + return view(order, actor); + } + public PaymentOrderView get(UUID id, PaymentActor actor) { PaymentOrder order = order(id); if (!order.involves(actor.getUserId())) throw forbidden(); diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java index 7a4c442..490738d 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java @@ -41,7 +41,12 @@ public class PaymentTrainingActionService { "02:wallet-sign", "02:package-payment", "02:send-payment", "03:verify-payer-signature", "03:compliance-check", "03:generate-compliance-report", "03:generate-compliance-digest", "03:bank-sign", "03:package-bank-request", - "03:send-to-central-bank"); + "03:send-to-central-bank", + "04:verify-payer-bank-signature", "04:reserve-and-transfer-ownership", + "04:concatenate-settlement", "04:generate-settlement-digest", "04:central-bank-sign", + "04:acs-clear", "04:package-settlement", "04:return-settlement", + "05:verify-central-bank-signature", "05:prepare-credit", "05:execute-credit", + "06:advance-receipt-flow", "06:notify-payer", "06:notify-payee"); private final ExperimentAttemptService attempts; private final PaymentApplicationService payments; @@ -100,6 +105,9 @@ public class PaymentTrainingActionService { case "02:send-payment": case "03:package-bank-request": case "03:send-to-central-bank": + case "04:package-settlement": + case "04:return-settlement": + case "06:advance-receipt-flow": return completed(payments.get(paymentId, actor)); case "03:verify-payer-signature": return completed(payments.verifyPayerSignature(paymentId, actor)); @@ -111,6 +119,28 @@ public class PaymentTrainingActionService { return completed(payments.generateComplianceDigest(paymentId, actor)); case "03:bank-sign": return completed(payments.signCompliance(paymentId, actor)); + case "04:verify-payer-bank-signature": + return completed(payments.verifyPayerBankSignature(paymentId, actor)); + case "04:reserve-and-transfer-ownership": + return completed(payments.transferOwnership(paymentId, actor)); + case "04:concatenate-settlement": + return completed(payments.concatenateSettlement(paymentId, actor)); + case "04:generate-settlement-digest": + return completed(payments.generateSettlementDigest(paymentId, actor)); + case "04:central-bank-sign": + return completed(payments.signSettlement(paymentId, actor)); + case "04:acs-clear": + return completed(payments.clearAtCentralBank(paymentId, actor)); + case "05:verify-central-bank-signature": + return completed(payments.verifyCentralBankSignature(paymentId, actor)); + case "05:prepare-credit": + return completed(payments.prepareCredit(paymentId, actor)); + case "05:execute-credit": + return completed(payments.creditPayee(paymentId, actor)); + case "06:notify-payer": + return completed(payments.notifyPayer(paymentId, actor)); + case "06:notify-payee": + return ActionOutcome.completedAttempt(payments.notifyPayee(paymentId, actor)); default: throw validation("不支持的支付实验动作:" + key); } diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java index 8a94a04..ab3d639 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java @@ -122,11 +122,38 @@ public final class PaymentOrder { coinCount = 0; status = PaymentStatus.PAYER_SIGNED; } - public void settle(String transactionId, String clearingNo, String originalText, String digest, String signature) { - require(PaymentStatus.PAYER_BANK_ACCEPTED); this.transactionId = required(transactionId); this.clearingNo = required(clearingNo); - settlementOriginalText = required(originalText); settlementDigest = required(digest); centralBankSignature = required(signature); + public void transferOwnership(String value) { + require(PaymentStatus.PAYER_BANK_ACCEPTED); + transactionId = required(value); + status = PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED; + } + public void prepareSettlement(String value, String originalText) { + require(PaymentStatus.CENTRAL_OWNERSHIP_TRANSFERRED); + clearingNo = required(value); + settlementOriginalText = required(originalText); + status = PaymentStatus.SETTLEMENT_TEXT_PREPARED; + } + public void digestSettlement(String digest) { + require(PaymentStatus.SETTLEMENT_TEXT_PREPARED); + settlementDigest = required(digest); + status = PaymentStatus.SETTLEMENT_DIGESTED; + } + public void signSettlement(String signature) { + require(PaymentStatus.SETTLEMENT_DIGESTED); + centralBankSignature = required(signature); + status = PaymentStatus.CENTRAL_SIGNED; + } + public void recordCentralClearing() { + require(PaymentStatus.CENTRAL_SIGNED); status = PaymentStatus.CENTRAL_SETTLED; } + public void settle(String transactionId, String clearingNo, String originalText, String digest, String signature) { + transferOwnership(transactionId); + prepareSettlement(clearingNo, originalText); + digestSettlement(digest); + signSettlement(signature); + recordCentralClearing(); + } public void creditPayee(BigDecimal payerAfter, BigDecimal payeeAfter) { require(PaymentStatus.CENTRAL_SETTLED); payerBalanceAfter = moneyOrZero(payerAfter); payeeBalanceAfter = moneyOrZero(payeeAfter); status = PaymentStatus.PAYEE_CREDITED; diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java index 3e260f4..f9a26ad 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java @@ -5,6 +5,10 @@ public enum PaymentStatus { PAYER_SIGNED, COMPLIANCE_LOCKED, PAYER_BANK_ACCEPTED, + CENTRAL_OWNERSHIP_TRANSFERRED, + SETTLEMENT_TEXT_PREPARED, + SETTLEMENT_DIGESTED, + CENTRAL_SIGNED, CENTRAL_SETTLED, PAYEE_CREDITED, SUCCESS diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java index 23ae453..d9094df 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java @@ -20,10 +20,18 @@ public interface PaymentResourceRepository { List lockForCompliance(PaymentOrder order, PaymentActor actor); void releasePaymentResources(PaymentOrder order, PaymentActor actor); String signComplianceDigest(PaymentActor actor, String digest); + void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor); + String transferOwnership(PaymentOrder order, PaymentActor actor); + String signSettlementDigest(PaymentOrder order, PaymentActor actor, String digest); + void recordCentralSettlement(PaymentOrder order, PaymentActor actor); + void verifyCentralBankSignature(PaymentOrder order, PaymentActor actor); + void validateCredit(PaymentOrder order); PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor); CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor); PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor); void createNotifications(PaymentOrder order, PaymentActor actor); + void createPayerNotification(PaymentOrder order); + void createPayeeNotification(PaymentOrder order); List findCoins(PaymentOrder order); List findNotifications(PaymentOrder order, String currentUserId); void appendStepLog(PaymentOrder order, String stepCode, String stepName, String output, PaymentActor actor); diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java index 876bb59..1b7e8e9 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -138,6 +138,76 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return signature; } + @Override + public void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor) { + InstitutionKeySubject subject = subject(actor); + if (!keyService.verifyCommercialBank(subject, order.getComplianceDigest(), order.getPayerBankSignature())) { + throw validation("中央银行验证商业银行A签名失败"); + } + } + + @Override + public String transferOwnership(PaymentOrder order, PaymentActor actor) { + List coins = findCoins(order); + if (coins.isEmpty() || coins.size() != order.getCoinCount()) { + throw validation("付款币串锁定记录不完整"); + } + String transactionId = "PAY_TXN_" + order.getPaymentNo(); + for (PaymentCoin coin : coins) { + if (!ownershipLocks.transferLocked(coin.getCurrencyId(), "WALLET", order.getPayerWalletId(), + "PAYMENT", order.getId().toString(), "WALLET", order.getPayeeWalletId(), transactionId)) { + throw validation("中央银行登记中心币串权属已变化:" + coin.getCurrencyId()); + } + int updated = jdbc.update("UPDATE payment_coin_reservation SET status='TRANSFERRED'," + + "transferred_at=CURRENT_TIMESTAMP WHERE payment_id=? AND currency_id=? AND status='RESERVED'", + order.getId().toString(), coin.getCurrencyId()); + if (updated != 1) throw validation("付款币串预留状态已变化:" + coin.getCurrencyId()); + } + return transactionId; + } + + @Override + public String signSettlementDigest(PaymentOrder order, PaymentActor actor, String digest) { + InstitutionKeySubject subject = subject(actor); + String signature = keyService.signCentralBank(subject, digest); + if (!keyService.verifyCentralBank(subject, digest, signature)) { + throw validation("中央银行结算确认签名验证失败"); + } + return signature; + } + + @Override + public void recordCentralSettlement(PaymentOrder order, PaymentActor actor) { + jdbc.update("INSERT INTO payment_ownership_transfer (transaction_id,payment_id,from_wallet_id,to_wallet_id," + + "amount,coin_count,settlement_digest,central_bank_signature,status,confirmed_at,confirmed_by) " + + "VALUES (?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP,?)", + order.getTransactionId(), order.getId().toString(), order.getPayerWalletId(), order.getPayeeWalletId(), + order.getAmount(), order.getCoinCount(), order.getSettlementDigest(), + order.getCentralBankSignature(), actor.getUsername()); + jdbc.update("INSERT INTO payment_clearing_record (clearing_no,payment_id,payer_bank_code,payee_bank_code," + + "amount,status,settled_at) VALUES (?,?,?,?,?,'SETTLED',CURRENT_TIMESTAMP)", + order.getClearingNo(), order.getId().toString(), order.getPayerBankCode(), + order.getPayeeBankCode(), order.getAmount()); + } + + @Override + public void verifyCentralBankSignature(PaymentOrder order, PaymentActor actor) { + InstitutionKeySubject subject = subject(actor); + if (!keyService.verifyCentralBank(subject, order.getSettlementDigest(), order.getCentralBankSignature())) { + throw validation("商业银行B验证中央银行签名失败"); + } + } + + @Override + public void validateCredit(PaymentOrder order) { + WalletSnapshot payer = wallet(order.getPayerWalletId()); + WalletSnapshot payee = wallet(order.getPayeeWalletId()); + if (payer.frozenAmount.compareTo(order.getAmount()) < 0 || payer.balance.compareTo(order.getAmount()) < 0) { + throw validation("付款钱包冻结金额或余额异常"); + } + if (!"ACTIVE".equals(payee.status)) throw validation("收款钱包状态异常"); + } + @Override public CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor) { InstitutionKeySubject subject = subject(actor); @@ -178,12 +248,16 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor) { - InstitutionKeySubject subject = subject(actor); - if (!keyService.verifyCentralBank(subject, order.getSettlementDigest(), order.getCentralBankSignature())) { - throw validation("商业银行B验证中央银行签名失败"); + verifyCentralBankSignature(order, actor); + WalletSnapshot payer; + WalletSnapshot payee; + if (order.getPayerWalletId().compareTo(order.getPayeeWalletId()) <= 0) { + payer = lockWallet(order.getPayerWalletId()); + payee = lockWallet(order.getPayeeWalletId()); + } else { + payee = lockWallet(order.getPayeeWalletId()); + payer = lockWallet(order.getPayerWalletId()); } - WalletSnapshot payer = lockWallet(order.getPayerWalletId()); - WalletSnapshot payee = lockWallet(order.getPayeeWalletId()); if (payer.frozenAmount.compareTo(order.getAmount()) < 0 || payer.balance.compareTo(order.getAmount()) < 0) { throw validation("付款钱包冻结金额或余额异常"); } @@ -206,11 +280,22 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public void createNotifications(PaymentOrder order, PaymentActor actor) { + createPayerNotification(order); + createPayeeNotification(order); + } + + @Override + public void createPayerNotification(PaymentOrder order) { Instant now = Instant.now(); jdbc.update("INSERT INTO payment_notification (id,payment_id,recipient_user_id,recipient_wallet_id,notification_type,content,status,created_at) " + "VALUES (?,?,?,?,?,?, 'CREATED',?)", UUID.randomUUID().toString(), order.getId().toString(), order.getPayerUserId(), order.getPayerWalletId(), "PAYMENT_RECEIPT", "支付成功:" + order.getPaymentNo(), java.sql.Timestamp.from(now)); + } + + @Override + public void createPayeeNotification(PaymentOrder order) { + Instant now = Instant.now(); jdbc.update("INSERT INTO payment_notification (id,payment_id,recipient_user_id,recipient_wallet_id,notification_type,content,status,created_at) " + "VALUES (?,?,?,?,?,?, 'CREATED',?)", UUID.randomUUID().toString(), order.getId().toString(), order.getPayeeUserId(), order.getPayeeWalletId(), @@ -267,6 +352,15 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return values.get(0); } + private WalletSnapshot wallet(String walletId) { + List values = jdbc.query("SELECT balance,COALESCE(frozen_amount,0) frozen_amount,status " + + "FROM digital_wallet WHERE wallet_id=?", + (rs, row) -> new WalletSnapshot(rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount"), + rs.getString("status")), walletId); + if (values.isEmpty()) throw validation("钱包不存在"); + return values.get(0); + } + private void assertPayerContract(PaymentOrder order, PaymentActor actor) { List values = jdbc.query("SELECT single_payment_limit,daily_payment_limit,daily_used_amount,daily_counter_date," + "annual_payment_limit,annual_used_amount,annual_counter_year,status,valid_until FROM wallet_contract " + diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java index 8234adc..0ba99e1 100644 --- a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java @@ -94,15 +94,44 @@ public class PaymentController { @PostMapping("/{id}/central-bank-settle") @Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算") - public ApiResponse centralBankSettle(@PathVariable UUID id) { return ok(service.settleAtCentralBank(id, actor())); } + public ApiResponse centralBankSettle(@PathVariable UUID id) { + CurrentUser user = currentUserService.getCurrentUser(); + ExperimentAttemptView attempt = matchingAttempt(id, user); + if (attempt == null) return ok(service.settleAtCentralBank(id, actor(user))); + String[] actionCodes = {"verify-payer-bank-signature", "reserve-and-transfer-ownership", + "concatenate-settlement", "generate-settlement-digest", "central-bank-sign", "acs-clear", + "package-settlement", "return-settlement"}; + for (String actionCode : actionCodes) { + run(attempt, "04", actionCode, new PaymentActionRequest(), user); + } + return ok(service.get(id, actor(user))); + } @PostMapping("/{id}/payee-bank-credit") @Operation(summary = "用户支付数字货币:步骤五,商业银行B验签并向收款钱包入账") - public ApiResponse payeeBankCredit(@PathVariable UUID id) { return ok(service.creditPayee(id, actor())); } + public ApiResponse payeeBankCredit(@PathVariable UUID id) { + CurrentUser user = currentUserService.getCurrentUser(); + ExperimentAttemptView attempt = matchingAttempt(id, user); + if (attempt == null) return ok(service.creditPayee(id, actor(user))); + String[] actionCodes = {"verify-central-bank-signature", "prepare-credit", "execute-credit"}; + for (String actionCode : actionCodes) { + run(attempt, "05", actionCode, new PaymentActionRequest(), user); + } + return ok(service.get(id, actor(user))); + } @PostMapping("/{id}/notifications") @Operation(summary = "用户支付数字货币:步骤六,生成付款回执和收款到账通知") - public ApiResponse notifications(@PathVariable UUID id) { return ok(service.createNotifications(id, actor())); } + public ApiResponse notifications(@PathVariable UUID id) { + CurrentUser user = currentUserService.getCurrentUser(); + ExperimentAttemptView attempt = matchingAttempt(id, user); + if (attempt == null) return ok(service.createNotifications(id, actor(user))); + String[] actionCodes = {"advance-receipt-flow", "notify-payer", "notify-payee"}; + for (String actionCode : actionCodes) { + run(attempt, "06", actionCode, new PaymentActionRequest(), user); + } + return ok(service.get(id, actor(user))); + } @GetMapping("/{id}") @Operation(summary = "查询用户支付数字货币实验订单和步骤结果") diff --git a/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionServiceTest.java b/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionServiceTest.java new file mode 100644 index 0000000..46668b3 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionServiceTest.java @@ -0,0 +1,66 @@ +package com.yau.digitalrmb.payment.application.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yau.digitalrmb.payment.domain.model.PaymentActor; +import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest; +import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService; +import com.yau.digitalrmb.training.attempt.domain.ActionStatus; +import com.yau.digitalrmb.training.attempt.domain.AttemptStatus; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView; +import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PaymentTrainingActionServiceTest { + @Test + void acceptsTheFirstCentralSettlementActionAfterCompliance() { + ExperimentAttemptService attempts = mock(ExperimentAttemptService.class); + PaymentApplicationService payments = mock(PaymentApplicationService.class); + PaymentTrainingActionService service = new PaymentTrainingActionService(attempts, payments, new ObjectMapper()); + UUID attemptId = UUID.randomUUID(); + ExperimentSubject subject = new ExperimentSubject("user-1", 1L, 2L, ""); + PaymentActor actor = new PaymentActor("user-1", "tester", 1L, 2L); + ExperimentAttemptView attempt = completedThroughCompliance(attemptId); + when(attempts.detail(attemptId, subject)).thenReturn(attempt); + when(attempts.execute(any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(action(attemptId, "04", "verify-payer-bank-signature")); + + assertThatCode(() -> service.execute(attemptId, "04", "verify-payer-bank-signature", + new PaymentActionRequest(), subject, actor)).doesNotThrowAnyException(); + } + + private ExperimentAttemptView completedThroughCompliance(UUID attemptId) { + String[][] keys = { + {"01", "refresh-payer"}, {"01", "refresh-payee"}, + {"02", "generate-timestamp"}, {"02", "concatenate-payment"}, + {"02", "generate-digest"}, {"02", "wallet-sign"}, + {"02", "package-payment"}, {"02", "send-payment"}, + {"03", "verify-payer-signature"}, {"03", "compliance-check"}, + {"03", "generate-compliance-report"}, {"03", "generate-compliance-digest"}, + {"03", "bank-sign"}, {"03", "package-bank-request"}, + {"03", "send-to-central-bank"} + }; + List> actions = new ArrayList>(); + for (String[] key : keys) actions.add(action(attemptId, key[0], key[1])); + return new ExperimentAttemptView(attemptId, ExperimentModule.PAYMENT, 1, + AttemptStatus.IN_PROGRESS, "04", "verify-payer-bank-signature", UUID.randomUUID().toString(), + Instant.now(), Instant.now(), null, actions); + } + + private ExperimentActionView action(UUID attemptId, String step, String action) { + return new ExperimentActionView(attemptId, ExperimentModule.PAYMENT, step, action, + ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, step, action, + Instant.now(), null); + } +} diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index 6492e10..e62695f 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -128,6 +128,8 @@ class PaymentControllerTest { .andExpect(jsonPath("$.data.paymentOriginalText").value(org.hamcrest.Matchers.startsWith("PAY|"))) .andReturn().getResponse().getContentAsString(); String id = JsonPath.read(created, "$.data.id"); + String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt " + + "WHERE module_code='PAYMENT' AND business_id=? ORDER BY attempt_no DESC LIMIT 1", String.class, id); mockMvc.perform(post("/api/v1/payments/{id}/sign", id).with(payer())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYER_SIGNED")); @@ -145,13 +147,27 @@ class PaymentControllerTest { mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CENTRAL_SETTLED")) .andExpect(jsonPath("$.data.clearingNo").value(org.hamcrest.Matchers.startsWith("ACS_PAY_REQ_"))); + mockMvc.perform(get("/api/v1/payment/attempts/current").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.currentStepCode").value("05")) + .andExpect(jsonPath("$.data.currentActionCode").value("verify-central-bank-signature")); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/cancel", attemptId).with(payer())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); mockMvc.perform(post("/api/v1/payments/{id}/payee-bank-credit", id).with(payer())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYEE_CREDITED")) .andExpect(jsonPath("$.data.payerBalanceAfter").value(0.00)) .andExpect(jsonPath("$.data.payeeBalanceAfter").value(200.00)); + mockMvc.perform(post("/api/v1/payments/{id}/payee-bank-credit", id).with(payer())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYEE_CREDITED")); mockMvc.perform(post("/api/v1/payments/{id}/notifications", id).with(payer())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.notifications.length()").value(1)) .andExpect(jsonPath("$.data.notifications[0].notificationType").value("PAYMENT_RECEIPT")); + mockMvc.perform(get("/api/v1/payments/{id}/notifications", id).with(payee())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.notifications.length()").value(1)) + .andExpect(jsonPath("$.data.notifications[0].notificationType").value("CREDIT_NOTICE")); mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer())) .andExpect(status().isOk()) @@ -254,6 +270,44 @@ class PaymentControllerTest { Integer.class, id)).isZero(); } + @Test + void resumesAfterOwnershipTransferAndCreditsWalletsExactlyOnce() throws Exception { + String id = createSignedPayment(PAYER_WALLET, PAYEE_WALLET, payer()); + mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer())) + .andExpect(status().isOk()); + String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt " + + "WHERE module_code='PAYMENT' AND business_id=? ORDER BY attempt_no DESC LIMIT 1", String.class, id); + + executeAction(attemptId, "04", "verify-payer-bank-signature", payer()); + executeAction(attemptId, "04", "reserve-and-transfer-ownership", payer()); + mockMvc.perform(get("/api/v1/payments/{id}", id).with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CENTRAL_OWNERSHIP_TRANSFERRED")); + assertThat(jdbc.queryForObject("SELECT owner_id FROM central_bank_currency_ownership " + + "WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo(PAYEE_WALLET); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00"); + assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00"); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/cancel", attemptId).with(payer())) + .andExpect(status().isBadRequest()); + + String[] remainingSettlement = {"concatenate-settlement", "generate-settlement-digest", + "central-bank-sign", "acs-clear", "package-settlement", "return-settlement"}; + for (String action : remainingSettlement) executeAction(attemptId, "04", action, payer()); + executeAction(attemptId, "05", "verify-central-bank-signature", payer()); + executeAction(attemptId, "05", "prepare-credit", payer()); + executeAction(attemptId, "05", "execute-credit", payer()); + executeAction(attemptId, "05", "execute-credit", payer()); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger " + + "WHERE payment_id=? AND direction='DEBIT'", Integer.class, id)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger " + + "WHERE payment_id=? AND direction='CREDIT'", Integer.class, id)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYEE_WALLET)).isEqualByComparingTo("200.00"); + } + private String createSignedPayment(String payerWalletId, String payeeWalletId, RequestPostProcessor actor) throws Exception { String created = mockMvc.perform(post("/api/v1/payments").with(actor).contentType(MediaType.APPLICATION_JSON) @@ -267,6 +321,15 @@ class PaymentControllerTest { return id; } + private void executeAction(String attemptId, String step, String action, + RequestPostProcessor actor) throws Exception { + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}", + attemptId, step, action).with(actor) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.actionStatus").value("COMPLETED")); + } + private void insertUser(String id, String account, String schoolId, String classId) { jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,role_id,is_deleted,zy_user_id) " + "VALUES (?,?,?,?,?,'支付测试班',?,'延安大学',4,0,?)", id, account, "unused", account, classId, schoolId, account); diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java index 672b783..7641d54 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java @@ -61,7 +61,14 @@ class PaymentTrainingAttemptControllerTest { {"03", "verify-payer-signature"}, {"03", "compliance-check"}, {"03", "generate-compliance-report"}, {"03", "generate-compliance-digest"}, {"03", "bank-sign"}, {"03", "package-bank-request"}, - {"03", "send-to-central-bank"} + {"03", "send-to-central-bank"}, + {"04", "verify-payer-bank-signature"}, {"04", "reserve-and-transfer-ownership"}, + {"04", "concatenate-settlement"}, {"04", "generate-settlement-digest"}, + {"04", "central-bank-sign"}, {"04", "acs-clear"}, + {"04", "package-settlement"}, {"04", "return-settlement"}, + {"05", "verify-central-bank-signature"}, {"05", "prepare-credit"}, + {"05", "execute-credit"}, {"06", "advance-receipt-flow"}, + {"06", "notify-payer"}, {"06", "notify-payee"} }; for (String[] action : actionCodes) { mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}",