diff --git a/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java b/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java new file mode 100644 index 0000000..9e44fd4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java @@ -0,0 +1,18 @@ +package com.yau.digitalrmb.payment.application.command; + +import java.math.BigDecimal; + +public final class CreatePaymentCommand { + private final String payerWalletId; + private final String payeeWalletId; + private final BigDecimal amount; + private final String note; + + public CreatePaymentCommand(String payerWalletId, String payeeWalletId, BigDecimal amount, String note) { + this.payerWalletId = payerWalletId; this.payeeWalletId = payeeWalletId; this.amount = amount; this.note = note; + } + public String getPayerWalletId() { return payerWalletId; } + public String getPayeeWalletId() { return payeeWalletId; } + public BigDecimal getAmount() { return amount; } + public String getNote() { return note; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/application/query/PaymentOrderView.java b/src/main/java/com/yau/digitalrmb/payment/application/query/PaymentOrderView.java new file mode 100644 index 0000000..58687a9 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/query/PaymentOrderView.java @@ -0,0 +1,70 @@ +package com.yau.digitalrmb.payment.application.query; + +import com.yau.digitalrmb.payment.domain.model.PaymentCoin; +import com.yau.digitalrmb.payment.domain.model.PaymentNotification; +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +public final class PaymentOrderView { + private final String id; + private final String paymentNo; + private final String payerWalletId; + private final String payerBankCode; + private final String payeeWalletId; + private final String payeeBankCode; + private final BigDecimal amount; + private final String note; + private final String requestTimestamp; + private final String paymentOriginalText; + private final String paymentDigest; + private final String payerSignature; + private final String complianceReport; + private final String complianceDigest; + private final String payerBankSignature; + private final String transactionId; + private final String clearingNo; + private final String settlementOriginalText; + private final String settlementDigest; + private final String centralBankSignature; + private final int coinCount; + private final BigDecimal payerBalanceAfter; + private final BigDecimal payeeBalanceAfter; + private final String status; + private final Instant createdAt; + private final Instant completedAt; + private final List coins; + private final List notifications; + + private PaymentOrderView(PaymentOrder value, List coins, List notifications) { + id = value.getId().toString(); paymentNo = value.getPaymentNo(); payerWalletId = value.getPayerWalletId(); + payerBankCode = value.getPayerBankCode(); payeeWalletId = value.getPayeeWalletId(); payeeBankCode = value.getPayeeBankCode(); + amount = value.getAmount(); note = value.getNote(); requestTimestamp = value.getRequestTimestamp(); + paymentOriginalText = value.getPaymentOriginalText(); paymentDigest = value.getPaymentDigest(); payerSignature = value.getPayerSignature(); + complianceReport = value.getComplianceReport(); complianceDigest = value.getComplianceDigest(); payerBankSignature = value.getPayerBankSignature(); + transactionId = value.getTransactionId(); clearingNo = value.getClearingNo(); settlementOriginalText = value.getSettlementOriginalText(); + settlementDigest = value.getSettlementDigest(); centralBankSignature = value.getCentralBankSignature(); coinCount = value.getCoinCount(); + payerBalanceAfter = value.getPayerBalanceAfter(); payeeBalanceAfter = value.getPayeeBalanceAfter(); status = value.getStatus().name(); + createdAt = value.getCreatedAt(); completedAt = value.getCompletedAt(); + this.coins = coins == null ? Collections.emptyList() : coins; + this.notifications = notifications == null ? Collections.emptyList() : notifications; + } + public static PaymentOrderView from(PaymentOrder value, List coins, List notifications) { + return new PaymentOrderView(value, coins, notifications); + } + public String getId() { return id; } public String getPaymentNo() { return paymentNo; } + public String getPayerWalletId() { return payerWalletId; } public String getPayerBankCode() { return payerBankCode; } + public String getPayeeWalletId() { return payeeWalletId; } public String getPayeeBankCode() { return payeeBankCode; } + public BigDecimal getAmount() { return amount; } public String getNote() { return note; } public String getRequestTimestamp() { return requestTimestamp; } + public String getPaymentOriginalText() { return paymentOriginalText; } public String getPaymentDigest() { return paymentDigest; } + public String getPayerSignature() { return payerSignature; } public String getComplianceReport() { return complianceReport; } + public String getComplianceDigest() { return complianceDigest; } public String getPayerBankSignature() { return payerBankSignature; } + public String getTransactionId() { return transactionId; } public String getClearingNo() { return clearingNo; } + public String getSettlementOriginalText() { return settlementOriginalText; } public String getSettlementDigest() { return settlementDigest; } + public String getCentralBankSignature() { return centralBankSignature; } public int getCoinCount() { return coinCount; } + public BigDecimal getPayerBalanceAfter() { return payerBalanceAfter; } public BigDecimal getPayeeBalanceAfter() { return payeeBalanceAfter; } + public String getStatus() { return status; } public Instant getCreatedAt() { return createdAt; } public Instant getCompletedAt() { return completedAt; } + public List getCoins() { return coins; } public List getNotifications() { return notifications; } +} 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 new file mode 100644 index 0000000..10ad823 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java @@ -0,0 +1,175 @@ +package com.yau.digitalrmb.payment.application.service; + +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.payment.application.command.CreatePaymentCommand; +import com.yau.digitalrmb.payment.application.query.PaymentOrderView; +import com.yau.digitalrmb.payment.domain.model.CentralSettlementResult; +import com.yau.digitalrmb.payment.domain.model.PaymentActor; +import com.yau.digitalrmb.payment.domain.model.PaymentContext; +import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult; +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import com.yau.digitalrmb.payment.domain.model.PaymentOrderId; +import com.yau.digitalrmb.payment.domain.model.PaymentStatus; +import com.yau.digitalrmb.payment.domain.model.PayerBankProcessingResult; +import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository; +import com.yau.digitalrmb.payment.domain.repository.PaymentResourceRepository; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@Service +public class PaymentApplicationService { + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC); + private final PaymentOrderRepository orderRepository; + private final PaymentResourceRepository resourceRepository; + private final InstitutionIdentityCryptography cryptography; + private final Clock clock; + + @Autowired + public PaymentApplicationService(PaymentOrderRepository orderRepository, PaymentResourceRepository resourceRepository, + InstitutionIdentityCryptography cryptography) { + this(orderRepository, resourceRepository, cryptography, Clock.systemUTC()); + } + + PaymentApplicationService(PaymentOrderRepository orderRepository, PaymentResourceRepository resourceRepository, + InstitutionIdentityCryptography cryptography, Clock clock) { + this.orderRepository = orderRepository; this.resourceRepository = resourceRepository; + this.cryptography = cryptography; this.clock = clock; + } + + public PaymentContext context(String payeeWalletId, PaymentActor actor) { + return resourceRepository.loadContext(actor.getUserId(), payeeWalletId); + } + + @Transactional + public PaymentOrderView create(CreatePaymentCommand command, PaymentActor actor) { + if (command == null) throw validation("支付请求不能为空"); + BigDecimal amount = amount(command.getAmount()); + PaymentContext context = resourceRepository.loadContext(actor.getUserId(), command.getPayeeWalletId()); + if (!context.getPayer().getWalletId().equals(command.getPayerWalletId())) throw validation("付款钱包不属于当前用户"); + Instant now = clock.instant(); + UUID id = UUID.randomUUID(); + String timestamp = TIMESTAMP.format(now); + String paymentNo = "PAY_REQ_" + timestamp + "_" + id.toString().substring(0, 8).toUpperCase(); + String note = command.getNote() == null ? "" : command.getNote().trim(); + String original = "PAY|" + context.getPayer().getWalletId() + "|" + context.getPayee().getWalletId() + "|" + + amount.toPlainString() + "|" + note + "|" + timestamp; + PaymentOrder order = PaymentOrder.create(new PaymentOrderId(id), paymentNo, context.getPayer(), context.getPayee(), + amount, note, timestamp, original, cryptography.sm3(original), now); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "02", "用户支付数字货币:发送支付请求", original, actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView sign(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.REQUEST_PREPARED) return atOrAfter(order, PaymentStatus.REQUEST_PREPARED, actor); + order.sign(resourceRepository.signWithWallet(order.getPayerWalletId(), order.getPaymentDigest())); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "02-SIGN", "用户支付数字货币:付款钱包SM2签名", order.getPayerSignature(), actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView processPayerBank(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYER_SIGNED) return atOrAfter(order, PaymentStatus.PAYER_SIGNED, actor); + if (!resourceRepository.verifyWalletSignature(order.getPayerWalletId(), order.getPaymentDigest(), order.getPayerSignature())) { + throw validation("商业银行A验证付款钱包SM2签名失败"); + } + PayerBankProcessingResult result = resourceRepository.processPayerBank(order, actor); + order.acceptByPayerBank(result.getComplianceReport(), result.getComplianceDigest(), result.getBankSignature(), result.getCoins().size()); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "03", "商业银行A预处理", result.getComplianceReport(), actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYER_BANK_ACCEPTED) return atOrAfter(order, PaymentStatus.PAYER_BANK_ACCEPTED, actor); + CentralSettlementResult result = resourceRepository.settleAtCentralBank(order, actor); + order.settle(result.getTransactionId(), result.getClearingNo(), result.getSettlementOriginalText(), result.getSettlementDigest(), result.getCentralBankSignature()); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "04", "中央银行结算", result.getSettlementOriginalText(), actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView creditPayee(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.CENTRAL_SETTLED) return atOrAfter(order, PaymentStatus.CENTRAL_SETTLED, actor); + PaymentCreditResult result = resourceRepository.creditPayee(order, actor); + order.creditPayee(result.getPayerBalanceAfter(), result.getPayeeBalanceAfter()); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "05", "商业银行B处理", "收款钱包余额:" + result.getPayeeBalanceAfter(), actor); + return view(order, actor); + } + + @Transactional + public PaymentOrderView createNotifications(UUID id, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.PAYEE_CREDITED) return atOrAfter(order, PaymentStatus.PAYEE_CREDITED, actor); + resourceRepository.createNotifications(order, actor); + 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(); + return view(order, actor); + } + + public PaymentOrderView notifications(UUID id, PaymentActor actor) { + PaymentOrder order = order(id); + if (!order.involves(actor.getUserId())) throw forbidden(); + return view(order, actor); + } + + private PaymentOrder ownedForUpdate(UUID id, PaymentActor actor) { + PaymentOrder order = orderRepository.findByIdForUpdate(new PaymentOrderId(id)) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "支付订单不存在")); + try { order.requireOwnedBy(actor.getUserId()); } + catch (SecurityException exception) { throw forbidden(); } + return order; + } + + private PaymentOrder order(UUID id) { + return orderRepository.findById(new PaymentOrderId(id)) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "支付订单不存在")); + } + + private PaymentOrderView atOrAfter(PaymentOrder order, PaymentStatus expected, PaymentActor actor) { + if (order.getStatus().ordinal() > expected.ordinal()) return view(order, actor); + throw validation("请先完成前一步支付流程"); + } + + private PaymentOrderView view(PaymentOrder order, PaymentActor actor) { + return PaymentOrderView.from(order, resourceRepository.findCoins(order), resourceRepository.findNotifications(order, actor.getUserId())); + } + + private BigDecimal amount(BigDecimal value) { + if (value == null) throw validation("支付金额不能为空"); + try { + BigDecimal normalized = value.setScale(2, RoundingMode.UNNECESSARY); + if (normalized.signum() <= 0) throw validation("支付金额必须大于0"); + return normalized; + } catch (ArithmeticException exception) { throw validation("支付金额最多保留两位小数"); } + } + private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } + private BusinessException forbidden() { return new BusinessException(ErrorCode.FORBIDDEN, "无权访问该支付订单"); } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/CentralSettlementResult.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/CentralSettlementResult.java new file mode 100644 index 0000000..6e3c8c5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/CentralSettlementResult.java @@ -0,0 +1,20 @@ +package com.yau.digitalrmb.payment.domain.model; + +public final class CentralSettlementResult { + private final String transactionId; + private final String clearingNo; + private final String settlementOriginalText; + private final String settlementDigest; + private final String centralBankSignature; + + public CentralSettlementResult(String transactionId, String clearingNo, String settlementOriginalText, + String settlementDigest, String centralBankSignature) { + this.transactionId = transactionId; this.clearingNo = clearingNo; this.settlementOriginalText = settlementOriginalText; + this.settlementDigest = settlementDigest; this.centralBankSignature = centralBankSignature; + } + public String getTransactionId() { return transactionId; } + public String getClearingNo() { return clearingNo; } + public String getSettlementOriginalText() { return settlementOriginalText; } + public String getSettlementDigest() { return settlementDigest; } + public String getCentralBankSignature() { return centralBankSignature; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PayerBankProcessingResult.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PayerBankProcessingResult.java new file mode 100644 index 0000000..2a0dc64 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PayerBankProcessingResult.java @@ -0,0 +1,20 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.util.List; + +public final class PayerBankProcessingResult { + private final String complianceReport; + private final String complianceDigest; + private final String bankSignature; + private final List coins; + + public PayerBankProcessingResult(String complianceReport, String complianceDigest, String bankSignature, + List coins) { + this.complianceReport = complianceReport; this.complianceDigest = complianceDigest; + this.bankSignature = bankSignature; this.coins = coins; + } + public String getComplianceReport() { return complianceReport; } + public String getComplianceDigest() { return complianceDigest; } + public String getBankSignature() { return bankSignature; } + public List getCoins() { return coins; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentActor.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentActor.java new file mode 100644 index 0000000..fa9cb97 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentActor.java @@ -0,0 +1,28 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.util.Objects; + +public final class PaymentActor { + private final String userId; + private final String username; + private final long schoolId; + private final long classId; + + public PaymentActor(String userId, String username, long schoolId, long classId) { + this.userId = required(userId, "user id"); + this.username = required(username, "username"); + this.schoolId = schoolId; + this.classId = classId; + } + + public String getUserId() { return userId; } + public String getUsername() { return username; } + public long getSchoolId() { return schoolId; } + public long getClassId() { return classId; } + + private static String required(String value, String name) { + Objects.requireNonNull(value, name + " is required"); + if (value.trim().isEmpty()) throw new IllegalArgumentException(name + " is required"); + return value.trim(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCoin.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCoin.java new file mode 100644 index 0000000..5620e3b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCoin.java @@ -0,0 +1,16 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.math.BigDecimal; + +public final class PaymentCoin { + private final String currencyId; + private final BigDecimal denomination; + private final String status; + + public PaymentCoin(String currencyId, BigDecimal denomination, String status) { + this.currencyId = currencyId; this.denomination = denomination; this.status = status; + } + public String getCurrencyId() { return currencyId; } + public BigDecimal getDenomination() { return denomination; } + public String getStatus() { return status; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentContext.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentContext.java new file mode 100644 index 0000000..7809b81 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentContext.java @@ -0,0 +1,12 @@ +package com.yau.digitalrmb.payment.domain.model; + +public final class PaymentContext { + private final PaymentParticipant payer; + private final PaymentParticipant payee; + + public PaymentContext(PaymentParticipant payer, PaymentParticipant payee) { + this.payer = payer; this.payee = payee; + } + public PaymentParticipant getPayer() { return payer; } + public PaymentParticipant getPayee() { return payee; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCreditResult.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCreditResult.java new file mode 100644 index 0000000..0cf5b24 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentCreditResult.java @@ -0,0 +1,14 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.math.BigDecimal; + +public final class PaymentCreditResult { + private final BigDecimal payerBalanceAfter; + private final BigDecimal payeeBalanceAfter; + + public PaymentCreditResult(BigDecimal payerBalanceAfter, BigDecimal payeeBalanceAfter) { + this.payerBalanceAfter = payerBalanceAfter; this.payeeBalanceAfter = payeeBalanceAfter; + } + public BigDecimal getPayerBalanceAfter() { return payerBalanceAfter; } + public BigDecimal getPayeeBalanceAfter() { return payeeBalanceAfter; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentNotification.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentNotification.java new file mode 100644 index 0000000..2c2fc83 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentNotification.java @@ -0,0 +1,18 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.time.Instant; + +public final class PaymentNotification { + private final String notificationType; + private final String walletId; + private final String content; + private final Instant createdAt; + + public PaymentNotification(String notificationType, String walletId, String content, Instant createdAt) { + this.notificationType = notificationType; this.walletId = walletId; this.content = content; this.createdAt = createdAt; + } + public String getNotificationType() { return notificationType; } + public String getWalletId() { return walletId; } + public String getContent() { return content; } + public Instant getCreatedAt() { return createdAt; } +} 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 new file mode 100644 index 0000000..dec9e20 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java @@ -0,0 +1,141 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; + +public final class PaymentOrder { + private final PaymentOrderId id; + private final String paymentNo; + private final String payerUserId; + private final String payerWalletId; + private final String payerBankCode; + private final String payerOrganizationId; + private final String payeeUserId; + private final String payeeWalletId; + private final String payeeBankCode; + private final String payeeOrganizationId; + private final BigDecimal amount; + private final String note; + private final String requestTimestamp; + private final String paymentOriginalText; + private final String paymentDigest; + private String payerSignature; + private String complianceReport; + private String complianceDigest; + private String payerBankSignature; + private String transactionId; + private String clearingNo; + private String settlementOriginalText; + private String settlementDigest; + private String centralBankSignature; + private int coinCount; + private BigDecimal payerBalanceAfter; + private BigDecimal payeeBalanceAfter; + private PaymentStatus status; + private final Instant createdAt; + private Instant completedAt; + + private PaymentOrder(PaymentOrderId id, String paymentNo, String payerUserId, String payerWalletId, + String payerBankCode, String payerOrganizationId, String payeeUserId, String payeeWalletId, + String payeeBankCode, String payeeOrganizationId, BigDecimal amount, String note, + String requestTimestamp, String paymentOriginalText, String paymentDigest, String payerSignature, + String complianceReport, String complianceDigest, String payerBankSignature, String transactionId, + String clearingNo, String settlementOriginalText, String settlementDigest, String centralBankSignature, + int coinCount, BigDecimal payerBalanceAfter, BigDecimal payeeBalanceAfter, PaymentStatus status, + Instant createdAt, Instant completedAt) { + this.id = Objects.requireNonNull(id); this.paymentNo = required(paymentNo); this.payerUserId = required(payerUserId); + this.payerWalletId = required(payerWalletId); this.payerBankCode = required(payerBankCode); + this.payerOrganizationId = required(payerOrganizationId); this.payeeUserId = required(payeeUserId); + this.payeeWalletId = required(payeeWalletId); this.payeeBankCode = required(payeeBankCode); + this.payeeOrganizationId = required(payeeOrganizationId); this.amount = money(amount); this.note = note == null ? "" : note.trim(); + this.requestTimestamp = required(requestTimestamp); this.paymentOriginalText = required(paymentOriginalText); + this.paymentDigest = required(paymentDigest); this.payerSignature = payerSignature; this.complianceReport = complianceReport; + this.complianceDigest = complianceDigest; this.payerBankSignature = payerBankSignature; this.transactionId = transactionId; + this.clearingNo = clearingNo; this.settlementOriginalText = settlementOriginalText; this.settlementDigest = settlementDigest; + this.centralBankSignature = centralBankSignature; this.coinCount = coinCount; this.payerBalanceAfter = payerBalanceAfter; + this.payeeBalanceAfter = payeeBalanceAfter; this.status = Objects.requireNonNull(status); + this.createdAt = Objects.requireNonNull(createdAt); this.completedAt = completedAt; + } + + public static PaymentOrder create(PaymentOrderId id, String paymentNo, PaymentParticipant payer, + PaymentParticipant payee, BigDecimal amount, String note, String requestTimestamp, + String paymentOriginalText, String paymentDigest, Instant now) { + return new PaymentOrder(id, paymentNo, payer.getUserId(), payer.getWalletId(), payer.getBankCode(), + payer.getOrganizationId(), payee.getUserId(), payee.getWalletId(), payee.getBankCode(), + payee.getOrganizationId(), amount, note, requestTimestamp, paymentOriginalText, paymentDigest, + null, null, null, null, null, null, null, null, null, 0, null, null, + PaymentStatus.REQUEST_PREPARED, now, null); + } + + public static PaymentOrder rehydrate(PaymentOrderId id, String paymentNo, String payerUserId, String payerWalletId, + String payerBankCode, String payerOrganizationId, String payeeUserId, + String payeeWalletId, String payeeBankCode, String payeeOrganizationId, + BigDecimal amount, String note, String requestTimestamp, String paymentOriginalText, + String paymentDigest, String payerSignature, String complianceReport, + String complianceDigest, String payerBankSignature, String transactionId, String clearingNo, + String settlementOriginalText, String settlementDigest, String centralBankSignature, + int coinCount, BigDecimal payerBalanceAfter, BigDecimal payeeBalanceAfter, + PaymentStatus status, Instant createdAt, Instant completedAt) { + return new PaymentOrder(id, paymentNo, payerUserId, payerWalletId, payerBankCode, payerOrganizationId, + payeeUserId, payeeWalletId, payeeBankCode, payeeOrganizationId, amount, note, requestTimestamp, + paymentOriginalText, paymentDigest, payerSignature, complianceReport, complianceDigest, + payerBankSignature, transactionId, clearingNo, settlementOriginalText, settlementDigest, + centralBankSignature, coinCount, payerBalanceAfter, payeeBalanceAfter, status, createdAt, completedAt); + } + + public void sign(String signature) { require(PaymentStatus.REQUEST_PREPARED); payerSignature = required(signature); status = PaymentStatus.PAYER_SIGNED; } + public void acceptByPayerBank(String report, String digest, String signature, int selectedCoinCount) { + require(PaymentStatus.PAYER_SIGNED); if (selectedCoinCount <= 0) throw new IllegalArgumentException("coin count must be positive"); + complianceReport = required(report); complianceDigest = required(digest); payerBankSignature = required(signature); + coinCount = selectedCoinCount; status = PaymentStatus.PAYER_BANK_ACCEPTED; + } + 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); + status = PaymentStatus.CENTRAL_SETTLED; + } + public void creditPayee(BigDecimal payerAfter, BigDecimal payeeAfter) { + require(PaymentStatus.CENTRAL_SETTLED); payerBalanceAfter = moneyOrZero(payerAfter); payeeBalanceAfter = moneyOrZero(payeeAfter); + status = PaymentStatus.PAYEE_CREDITED; + } + public void complete(Instant now) { require(PaymentStatus.PAYEE_CREDITED); completedAt = Objects.requireNonNull(now); status = PaymentStatus.SUCCESS; } + public void requireOwnedBy(String userId) { if (!payerUserId.equals(userId)) throw new SecurityException("payment order is not owned by current user"); } + public boolean involves(String userId) { return payerUserId.equals(userId) || payeeUserId.equals(userId); } + + private void require(PaymentStatus expected) { if (status != expected) throw new IllegalStateException("expected " + expected + " but was " + status); } + private static String required(String value) { Objects.requireNonNull(value, "required value"); if (value.trim().isEmpty()) throw new IllegalArgumentException("required value"); return value.trim(); } + private static BigDecimal money(BigDecimal value) { Objects.requireNonNull(value, "amount"); BigDecimal normalized = value.setScale(2); if (normalized.signum() <= 0) throw new IllegalArgumentException("amount must be positive"); return normalized; } + private static BigDecimal moneyOrZero(BigDecimal value) { Objects.requireNonNull(value, "balance"); return value.setScale(2); } + + public PaymentOrderId getId() { return id; } + public String getPaymentNo() { return paymentNo; } + public String getPayerUserId() { return payerUserId; } + public String getPayerWalletId() { return payerWalletId; } + public String getPayerBankCode() { return payerBankCode; } + public String getPayerOrganizationId() { return payerOrganizationId; } + public String getPayeeUserId() { return payeeUserId; } + public String getPayeeWalletId() { return payeeWalletId; } + public String getPayeeBankCode() { return payeeBankCode; } + public String getPayeeOrganizationId() { return payeeOrganizationId; } + public BigDecimal getAmount() { return amount; } + public String getNote() { return note; } + public String getRequestTimestamp() { return requestTimestamp; } + public String getPaymentOriginalText() { return paymentOriginalText; } + public String getPaymentDigest() { return paymentDigest; } + public String getPayerSignature() { return payerSignature; } + public String getComplianceReport() { return complianceReport; } + public String getComplianceDigest() { return complianceDigest; } + public String getPayerBankSignature() { return payerBankSignature; } + public String getTransactionId() { return transactionId; } + public String getClearingNo() { return clearingNo; } + public String getSettlementOriginalText() { return settlementOriginalText; } + public String getSettlementDigest() { return settlementDigest; } + public String getCentralBankSignature() { return centralBankSignature; } + public int getCoinCount() { return coinCount; } + public BigDecimal getPayerBalanceAfter() { return payerBalanceAfter; } + public BigDecimal getPayeeBalanceAfter() { return payeeBalanceAfter; } + public PaymentStatus getStatus() { return status; } + public Instant getCreatedAt() { return createdAt; } + public Instant getCompletedAt() { return completedAt; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrderId.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrderId.java new file mode 100644 index 0000000..11e65b4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrderId.java @@ -0,0 +1,16 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.util.Objects; +import java.util.UUID; + +public final class PaymentOrderId { + private final UUID value; + + public PaymentOrderId(UUID value) { this.value = Objects.requireNonNull(value, "payment id"); } + public UUID value() { return value; } + @Override public String toString() { return value.toString(); } + @Override public boolean equals(Object other) { + return other instanceof PaymentOrderId && value.equals(((PaymentOrderId) other).value); + } + @Override public int hashCode() { return value.hashCode(); } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentParticipant.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentParticipant.java new file mode 100644 index 0000000..b9d3b5f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentParticipant.java @@ -0,0 +1,37 @@ +package com.yau.digitalrmb.payment.domain.model; + +import java.math.BigDecimal; + +public final class PaymentParticipant { + private final String userId; + private final String walletId; + private final String walletType; + private final String certificateSerial; + private final String publicKey; + private final String bankCode; + private final String bankName; + private final String organizationId; + private final BigDecimal balance; + private final BigDecimal frozenAmount; + + public PaymentParticipant(String userId, String walletId, String walletType, String certificateSerial, String publicKey, + String bankCode, String bankName, String organizationId, BigDecimal balance, + BigDecimal frozenAmount) { + this.userId = userId; this.walletId = walletId; this.walletType = walletType; + this.certificateSerial = certificateSerial; this.publicKey = publicKey; this.bankCode = bankCode; + this.bankName = bankName; this.organizationId = organizationId; this.balance = balance; + this.frozenAmount = frozenAmount; + } + + public String getUserId() { return userId; } + public String getWalletId() { return walletId; } + public String getWalletType() { return walletType; } + public String getCertificateSerial() { return certificateSerial; } + public String getPublicKey() { return publicKey; } + public String getBankCode() { return bankCode; } + public String getBankName() { return bankName; } + public String getOrganizationId() { return organizationId; } + public BigDecimal getBalance() { return balance; } + public BigDecimal getFrozenAmount() { return frozenAmount; } + public BigDecimal getAvailableBalance() { return balance.subtract(frozenAmount); } +} 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 new file mode 100644 index 0000000..5b3d11c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java @@ -0,0 +1,10 @@ +package com.yau.digitalrmb.payment.domain.model; + +public enum PaymentStatus { + REQUEST_PREPARED, + PAYER_SIGNED, + PAYER_BANK_ACCEPTED, + CENTRAL_SETTLED, + PAYEE_CREDITED, + SUCCESS +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentOrderRepository.java b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentOrderRepository.java new file mode 100644 index 0000000..b1cbdd5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentOrderRepository.java @@ -0,0 +1,11 @@ +package com.yau.digitalrmb.payment.domain.repository; + +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import com.yau.digitalrmb.payment.domain.model.PaymentOrderId; +import java.util.Optional; + +public interface PaymentOrderRepository { + void save(PaymentOrder order, String operator); + Optional findById(PaymentOrderId id); + Optional findByIdForUpdate(PaymentOrderId id); +} 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 new file mode 100644 index 0000000..73a964b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java @@ -0,0 +1,24 @@ +package com.yau.digitalrmb.payment.domain.repository; + +import com.yau.digitalrmb.payment.domain.model.PaymentActor; +import com.yau.digitalrmb.payment.domain.model.CentralSettlementResult; +import com.yau.digitalrmb.payment.domain.model.PaymentCoin; +import com.yau.digitalrmb.payment.domain.model.PaymentContext; +import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult; +import com.yau.digitalrmb.payment.domain.model.PaymentNotification; +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import com.yau.digitalrmb.payment.domain.model.PayerBankProcessingResult; +import java.util.List; + +public interface PaymentResourceRepository { + PaymentContext loadContext(String payerUserId, String payeeWalletId); + String signWithWallet(String walletId, String digest); + boolean verifyWalletSignature(String walletId, String digest, String signature); + PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor); + CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor); + PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor); + void createNotifications(PaymentOrder order, PaymentActor actor); + 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/JdbcPaymentOrderRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentOrderRepository.java new file mode 100644 index 0000000..d297d44 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentOrderRepository.java @@ -0,0 +1,72 @@ +package com.yau.digitalrmb.payment.infrastructure.persistence; + +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import com.yau.digitalrmb.payment.domain.model.PaymentOrderId; +import com.yau.digitalrmb.payment.domain.model.PaymentStatus; +import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class JdbcPaymentOrderRepository implements PaymentOrderRepository { + private final JdbcTemplate jdbc; + + public JdbcPaymentOrderRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @Override + public void save(PaymentOrder order, String operator) { + int updated = jdbc.update("UPDATE payment_order SET payer_signature=?,compliance_report=?,compliance_digest=?," + + "payer_bank_signature=?,transaction_id=?,clearing_no=?,settlement_original_text=?,settlement_digest=?," + + "central_bank_signature=?,coin_count=?,payer_balance_after=?,payee_balance_after=?,status=?," + + "updated_at=CURRENT_TIMESTAMP,completed_at=?,updated_by=? WHERE id=?", + order.getPayerSignature(), order.getComplianceReport(), order.getComplianceDigest(), order.getPayerBankSignature(), + order.getTransactionId(), order.getClearingNo(), order.getSettlementOriginalText(), order.getSettlementDigest(), + order.getCentralBankSignature(), order.getCoinCount(), order.getPayerBalanceAfter(), order.getPayeeBalanceAfter(), + order.getStatus().name(), timestamp(order.getCompletedAt()), operator, order.getId().toString()); + if (updated == 0) { + jdbc.update("INSERT INTO payment_order (id,payment_no,payer_user_id,payer_wallet_id,payer_bank_code,payer_organization_id," + + "payee_user_id,payee_wallet_id,payee_bank_code,payee_organization_id,amount,payment_note,request_timestamp," + + "payment_original_text,payment_digest,payer_signature,compliance_report,compliance_digest,payer_bank_signature," + + "transaction_id,clearing_no,settlement_original_text,settlement_digest,central_bank_signature,coin_count," + + "payer_balance_after,payee_balance_after,status,created_at,updated_at,completed_at,created_by,updated_by) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + order.getId().toString(), order.getPaymentNo(), order.getPayerUserId(), order.getPayerWalletId(), + order.getPayerBankCode(), order.getPayerOrganizationId(), order.getPayeeUserId(), order.getPayeeWalletId(), + order.getPayeeBankCode(), order.getPayeeOrganizationId(), order.getAmount(), order.getNote(), + order.getRequestTimestamp(), order.getPaymentOriginalText(), order.getPaymentDigest(), order.getPayerSignature(), + order.getComplianceReport(), order.getComplianceDigest(), order.getPayerBankSignature(), order.getTransactionId(), + order.getClearingNo(), order.getSettlementOriginalText(), order.getSettlementDigest(), order.getCentralBankSignature(), + order.getCoinCount(), order.getPayerBalanceAfter(), order.getPayeeBalanceAfter(), order.getStatus().name(), + timestamp(order.getCreatedAt()), timestamp(order.getCreatedAt()), timestamp(order.getCompletedAt()), operator, operator); + } + } + + @Override public Optional findById(PaymentOrderId id) { return find(id, false); } + @Override public Optional findByIdForUpdate(PaymentOrderId id) { return find(id, true); } + + private Optional find(PaymentOrderId id, boolean forUpdate) { + List values = jdbc.query("SELECT * FROM payment_order WHERE id=?" + (forUpdate ? " FOR UPDATE" : ""), + (rs, row) -> PaymentOrder.rehydrate(new PaymentOrderId(UUID.fromString(rs.getString("id"))), + rs.getString("payment_no"), rs.getString("payer_user_id"), rs.getString("payer_wallet_id"), + rs.getString("payer_bank_code"), rs.getString("payer_organization_id"), rs.getString("payee_user_id"), + rs.getString("payee_wallet_id"), rs.getString("payee_bank_code"), rs.getString("payee_organization_id"), + rs.getBigDecimal("amount"), rs.getString("payment_note"), rs.getString("request_timestamp"), + rs.getString("payment_original_text"), rs.getString("payment_digest"), rs.getString("payer_signature"), + rs.getString("compliance_report"), rs.getString("compliance_digest"), rs.getString("payer_bank_signature"), + rs.getString("transaction_id"), rs.getString("clearing_no"), rs.getString("settlement_original_text"), + rs.getString("settlement_digest"), rs.getString("central_bank_signature"), rs.getInt("coin_count"), + rs.getBigDecimal("payer_balance_after"), rs.getBigDecimal("payee_balance_after"), + PaymentStatus.valueOf(rs.getString("status")), instant(rs.getTimestamp("created_at")), + instant(rs.getTimestamp("completed_at"))), id.toString()); + return values.isEmpty() ? Optional.empty() : Optional.of(values.get(0)); + } + + private static Timestamp timestamp(Instant value) { return value == null ? null : Timestamp.from(value); } + private static Instant instant(Timestamp value) { return value == null ? null : value.toInstant(); } +} 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 new file mode 100644 index 0000000..7d94273 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -0,0 +1,315 @@ +package com.yau.digitalrmb.payment.infrastructure.persistence; + +import com.yau.digitalrmb.exchange.infrastructure.crypto.WalletPrivateKeyCipher; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.payment.domain.model.CentralSettlementResult; +import com.yau.digitalrmb.payment.domain.model.PaymentActor; +import com.yau.digitalrmb.payment.domain.model.PaymentCoin; +import com.yau.digitalrmb.payment.domain.model.PaymentContext; +import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult; +import com.yau.digitalrmb.payment.domain.model.PaymentNotification; +import com.yau.digitalrmb.payment.domain.model.PaymentOrder; +import com.yau.digitalrmb.payment.domain.model.PaymentParticipant; +import com.yau.digitalrmb.payment.domain.model.PayerBankProcessingResult; +import com.yau.digitalrmb.payment.domain.repository.PaymentResourceRepository; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Repository +public class JdbcPaymentResourceRepository implements PaymentResourceRepository { + private final JdbcTemplate jdbc; + private final InstitutionIdentityCryptography cryptography; + private final WalletPrivateKeyCipher walletCipher; + private final InstitutionKeyService keyService; + + public JdbcPaymentResourceRepository(JdbcTemplate jdbc, InstitutionIdentityCryptography cryptography, + WalletPrivateKeyCipher walletCipher, InstitutionKeyService keyService) { + this.jdbc = jdbc; this.cryptography = cryptography; this.walletCipher = walletCipher; this.keyService = keyService; + } + + @Override + public PaymentContext loadContext(String payerUserId, String payeeWalletId) { + PaymentParticipant payer = loadParticipant("w.user_id=?", payerUserId); + PaymentParticipant payee = loadParticipant("w.wallet_id=?", payeeWalletId); + if (payer.getWalletId().equals(payee.getWalletId())) throw validation("付款钱包和收款钱包不能相同"); + if (payer.getBankCode().equals(payee.getBankCode())) throw validation("本实验仅支持商业银行A向商业银行B的跨行支付"); + return new PaymentContext(payer, payee); + } + + @Override + public String signWithWallet(String walletId, String digest) { + List values = jdbc.query("SELECT cert.encrypted_private_key FROM wallet_certificate cert " + + "JOIN digital_wallet w ON w.wallet_id=cert.wallet_id WHERE cert.wallet_id=? " + + "AND cert.status='VALID' AND cert.filing_status='REGISTERED' AND w.status='ACTIVE'", + (rs, row) -> rs.getString(1), walletId); + if (values.isEmpty()) throw validation("付款钱包证书不存在或状态异常"); + return cryptography.sign(walletCipher.decrypt(values.get(0)), digest); + } + + @Override + public boolean verifyWalletSignature(String walletId, String digest, String signature) { + List values = jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " + + "JOIN digital_wallet w ON w.wallet_id=cert.wallet_id WHERE cert.wallet_id=? " + + "AND cert.status='VALID' AND cert.filing_status='REGISTERED' AND w.status='ACTIVE'", + (rs, row) -> rs.getString(1), walletId); + return !values.isEmpty() && cryptography.verify(values.get(0), digest, signature); + } + + @Override + public PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor) { + WalletSnapshot payer = lockWallet(order.getPayerWalletId()); + if (!"ACTIVE".equals(payer.status) || payer.balance.subtract(payer.frozenAmount).compareTo(order.getAmount()) < 0) { + throw validation("付款钱包可用余额不足或状态异常"); + } + assertPayerContract(order, actor); + List coins = selectAndLockCoins(order, actor); + int frozen = jdbc.update("UPDATE digital_wallet SET frozen_amount=frozen_amount+?,updated_at=CURRENT_TIMESTAMP " + + "WHERE wallet_id=? AND balance-frozen_amount>=?", + order.getAmount(), order.getPayerWalletId(), order.getAmount()); + if (frozen != 1) throw validation("付款钱包可用余额已变化,请重新发起支付"); + String report = "COMPLIANCE|" + order.getPaymentNo() + "|" + order.getPayerWalletId() + "|" + + order.getPayeeWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getPaymentDigest() + + "|SIGNATURE_VALID|CONTRACT_VALID|" + coins.size() + "|" + order.getPayerBankCode(); + String digest = cryptography.sm3(report); + InstitutionKeySubject subject = subject(actor); + String signature = keyService.signCommercialBank(subject, digest); + if (!keyService.verifyCommercialBank(subject, digest, signature)) throw validation("商业银行A合规报告签名验证失败"); + return new PayerBankProcessingResult(report, digest, signature, coins); + } + + @Override + public CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor) { + InstitutionKeySubject subject = subject(actor); + if (!keyService.verifyCommercialBank(subject, order.getComplianceDigest(), order.getPayerBankSignature())) { + throw validation("中央银行验证商业银行A签名失败"); + } + List coins = findCoins(order); + if (coins.isEmpty() || coins.size() != order.getCoinCount()) throw validation("付款币串锁定记录不完整"); + String transactionId = "PAY_TXN_" + order.getPaymentNo(); + String clearingNo = "ACS_" + order.getPaymentNo(); + String originalText = "SETTLEMENT|" + order.getPaymentNo() + "|" + transactionId + "|" + + order.getPayerWalletId() + "|" + order.getPayeeWalletId() + "|" + coins.size() + "|" + + order.getAmount().toPlainString() + "|" + order.getPayerBankCode() + "|" + order.getPayeeBankCode() + + "|" + clearingNo + "|" + Instant.now().toString(); + String digest = cryptography.sm3(originalText); + String signature = keyService.signCentralBank(subject, digest); + if (!keyService.verifyCentralBank(subject, digest, signature)) throw validation("中央银行结算确认签名验证失败"); + + for (PaymentCoin coin : coins) { + int ownership = jdbc.update("UPDATE central_bank_currency_ownership SET owner_type='WALLET',owner_id=?," + + "status='AVAILABLE',last_transaction_id=NULL,updated_at=CURRENT_TIMESTAMP WHERE currency_id=? " + + "AND owner_type='WALLET' AND owner_id=? AND status='AVAILABLE'", + order.getPayeeWalletId(), coin.getCurrencyId(), order.getPayerWalletId()); + if (ownership != 1) throw validation("中央银行登记中心币串权属已变化:" + coin.getCurrencyId()); + int currency = jdbc.update("UPDATE commercial_bank_currency SET status='TRANSFERRED' " + + "WHERE currency_id=? AND status='PAYMENT_LOCKED'", coin.getCurrencyId()); + if (currency != 1) throw validation("付款币串锁定状态异常:" + coin.getCurrencyId()); + 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()); + } + 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,?)", + transactionId, order.getId().toString(), order.getPayerWalletId(), order.getPayeeWalletId(), order.getAmount(), + coins.size(), digest, signature, 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)", + clearingNo, order.getId().toString(), order.getPayerBankCode(), order.getPayeeBankCode(), order.getAmount()); + return new CentralSettlementResult(transactionId, clearingNo, originalText, digest, signature); + } + + @Override + public PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor) { + InstitutionKeySubject subject = subject(actor); + if (!keyService.verifyCentralBank(subject, order.getSettlementDigest(), order.getCentralBankSignature())) { + throw validation("商业银行B验证中央银行签名失败"); + } + 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("付款钱包冻结金额或余额异常"); + } + if (!"ACTIVE".equals(payee.status)) throw validation("收款钱包状态异常"); + BigDecimal payerAfter = payer.balance.subtract(order.getAmount()).setScale(2); + BigDecimal payeeAfter = payee.balance.add(order.getAmount()).setScale(2); + jdbc.update("UPDATE digital_wallet SET balance=?,frozen_amount=frozen_amount-?,updated_at=CURRENT_TIMESTAMP WHERE wallet_id=?", + payerAfter, order.getAmount(), order.getPayerWalletId()); + jdbc.update("UPDATE digital_wallet SET balance=?,updated_at=CURRENT_TIMESTAMP WHERE wallet_id=?", + payeeAfter, order.getPayeeWalletId()); + updatePayerUsage(order); + jdbc.update("INSERT INTO payment_wallet_ledger (ledger_no,payment_id,wallet_id,direction,amount,balance_after,created_at,created_by) " + + "VALUES (?,?,?,'DEBIT',?,?,CURRENT_TIMESTAMP,?)", + "PAY_DEBIT_" + order.getPaymentNo(), order.getId().toString(), order.getPayerWalletId(), order.getAmount(), payerAfter, actor.getUsername()); + jdbc.update("INSERT INTO payment_wallet_ledger (ledger_no,payment_id,wallet_id,direction,amount,balance_after,created_at,created_by) " + + "VALUES (?,?,?,'CREDIT',?,?,CURRENT_TIMESTAMP,?)", + "PAY_CREDIT_" + order.getPaymentNo(), order.getId().toString(), order.getPayeeWalletId(), order.getAmount(), payeeAfter, actor.getUsername()); + return new PaymentCreditResult(payerAfter, payeeAfter); + } + + @Override + public void createNotifications(PaymentOrder order, PaymentActor actor) { + 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)); + 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(), + "CREDIT_NOTICE", "到账通知:" + order.getPaymentNo(), java.sql.Timestamp.from(now)); + } + + @Override + public List findCoins(PaymentOrder order) { + return jdbc.query("SELECT currency_id,denomination,status FROM payment_coin_reservation WHERE payment_id=? " + + "ORDER BY denomination DESC,currency_id", + (rs, row) -> new PaymentCoin(rs.getString("currency_id"), rs.getBigDecimal("denomination"), + rs.getString("status")), order.getId().toString()); + } + + @Override + public List findNotifications(PaymentOrder order, String currentUserId) { + return jdbc.query("SELECT notification_type,recipient_wallet_id,content,created_at FROM payment_notification " + + "WHERE payment_id=? AND recipient_user_id=? ORDER BY created_at", + (rs, row) -> new PaymentNotification(rs.getString("notification_type"), rs.getString("recipient_wallet_id"), + rs.getString("content"), rs.getTimestamp("created_at").toInstant()), order.getId().toString(), currentUserId); + } + + @Override + public void appendStepLog(PaymentOrder order, String stepCode, String stepName, String output, PaymentActor actor) { + jdbc.update("INSERT INTO payment_step_log (payment_id,step_code,step_name,status,output_text,operated_at,operator_user_id,operator_name) " + + "VALUES (?,?,?,'SUCCESS',?,CURRENT_TIMESTAMP,?,?)", + order.getId().toString(), stepCode, stepName, output, actor.getUserId(), actor.getUsername()); + } + + private PaymentParticipant loadParticipant(String where, String value) { + List values = jdbc.query("SELECT w.user_id,w.wallet_id,w.wallet_type,cert.certificate_serial,cert.public_key," + + "a.bank_code,a.bank_name,w.balance,COALESCE(w.frozen_amount,0) frozen_amount " + + "FROM digital_wallet w JOIN wallet_certificate cert ON cert.wallet_id=w.wallet_id " + + "AND cert.status='VALID' AND cert.filing_status='REGISTERED' " + + "JOIN wallet_bank_binding binding ON binding.wallet_id=w.wallet_id AND binding.status='BOUND' " + + "JOIN simulated_bank_account a ON a.account_id=binding.bank_account_id AND a.status='ACTIVE' " + + "WHERE " + where + " AND w.status='ACTIVE' AND w.central_bank_confirmation_signature IS NOT NULL", + (rs, row) -> new PaymentParticipant(rs.getString("user_id"), rs.getString("wallet_id"), + rs.getString("wallet_type"), rs.getString("certificate_serial"), rs.getString("public_key"), + rs.getString("bank_code"), rs.getString("bank_name"), organizationId(rs.getString("bank_code")), + rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount")), value); + if (values.isEmpty()) throw validation("付款方或收款方未完成钱包开通、证书备案或银行绑定"); + return values.get(0); + } + + private String organizationId(String bankCode) { + List values = jdbc.query("SELECT institution_identifier FROM institution_identifier_application " + + "WHERE bank_code=? AND status IN ('ISSUED','FEEDBACKED') AND deleted=FALSE " + + "AND institution_identifier IS NOT NULL ORDER BY created_at DESC LIMIT 1", + (rs, row) -> rs.getString(1), bankCode); + if (values.isEmpty()) throw validation("请先完成机构标识实验并取得商业银行机构标识"); + return values.get(0); + } + + private WalletSnapshot lockWallet(String walletId) { + List values = jdbc.query("SELECT balance,COALESCE(frozen_amount,0) frozen_amount,status " + + "FROM digital_wallet WHERE wallet_id=? FOR UPDATE", + (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 " + + "WHERE wallet_id=? FOR UPDATE", + (rs, row) -> new ContractSnapshot(rs.getBigDecimal("single_payment_limit"), rs.getBigDecimal("daily_payment_limit"), + rs.getBigDecimal("daily_used_amount"), rs.getDate("daily_counter_date") == null ? null : rs.getDate("daily_counter_date").toLocalDate(), + rs.getBigDecimal("annual_payment_limit"), rs.getBigDecimal("annual_used_amount"), + rs.getObject("annual_counter_year") == null ? null : rs.getInt("annual_counter_year"), rs.getString("status"), + rs.getTimestamp("valid_until") == null ? null : rs.getTimestamp("valid_until").toInstant()), order.getPayerWalletId()); + if (values.isEmpty() || !"ACTIVE".equals(values.get(0).status) || (values.get(0).validUntil != null && values.get(0).validUntil.isBefore(Instant.now()))) { + throw validation("付款钱包合约不存在或未生效"); + } + ContractSnapshot contract = values.get(0); + BigDecimal usedToday = LocalDate.now().equals(contract.dailyDate) ? contract.dailyUsed : BigDecimal.ZERO.setScale(2); + int year = LocalDate.now().getYear(); + BigDecimal usedYear = contract.annualYear != null && contract.annualYear == year ? contract.annualUsed : BigDecimal.ZERO.setScale(2); + if (order.getAmount().compareTo(contract.singleLimit) > 0 || usedToday.add(order.getAmount()).compareTo(contract.dailyLimit) > 0 || + usedYear.add(order.getAmount()).compareTo(contract.annualLimit) > 0) throw validation("付款金额超过钱包合约支付限额"); + } + + private List selectAndLockCoins(PaymentOrder order, PaymentActor actor) { + List candidates = jdbc.query("SELECT c.currency_id,c.denomination,c.status FROM commercial_bank_currency c " + + "JOIN central_bank_currency_ownership o ON o.currency_id=c.currency_id WHERE o.owner_type='WALLET' " + + "AND o.owner_id=? AND o.status='AVAILABLE' AND c.status='TRANSFERRED' ORDER BY c.denomination DESC,c.currency_id FOR UPDATE", + (rs, row) -> new CoinCandidate(rs.getString("currency_id"), rs.getBigDecimal("denomination"), rs.getString("status")), + order.getPayerWalletId()); + BigDecimal remaining = order.getAmount(); + List selected = new ArrayList(); + for (CoinCandidate candidate : candidates) { + if (candidate.denomination.compareTo(remaining) <= 0) { + selected.add(candidate); remaining = remaining.subtract(candidate.denomination).setScale(2); + if (remaining.signum() == 0) break; + } + } + if (remaining.signum() != 0) throw validation("付款钱包中的数字货币无法组成支付金额"); + List results = new ArrayList(); + for (CoinCandidate coin : selected) { + if (jdbc.update("UPDATE commercial_bank_currency SET status='PAYMENT_LOCKED' WHERE currency_id=? AND status='TRANSFERRED'", + coin.currencyId) != 1) throw validation("付款币串已被其他支付占用"); + jdbc.update("INSERT INTO payment_coin_reservation (payment_id,currency_id,denomination,source_status,status,reserved_at) " + + "VALUES (?,?,?,?, 'RESERVED',CURRENT_TIMESTAMP)", + order.getId().toString(), coin.currencyId, coin.denomination, coin.status); + results.add(new PaymentCoin(coin.currencyId, coin.denomination, "RESERVED")); + } + return results; + } + + private void updatePayerUsage(PaymentOrder order) { + List values = jdbc.query("SELECT daily_used_amount,daily_counter_date,annual_used_amount,annual_counter_year " + + "FROM wallet_contract WHERE wallet_id=? FOR UPDATE", + (rs, row) -> new ContractSnapshot(null, null, rs.getBigDecimal("daily_used_amount"), + rs.getDate("daily_counter_date") == null ? null : rs.getDate("daily_counter_date").toLocalDate(), null, + rs.getBigDecimal("annual_used_amount"), rs.getObject("annual_counter_year") == null ? null : rs.getInt("annual_counter_year"), + "ACTIVE", null), order.getPayerWalletId()); + ContractSnapshot contract = values.get(0); + BigDecimal day = LocalDate.now().equals(contract.dailyDate) ? contract.dailyUsed : BigDecimal.ZERO.setScale(2); + int year = LocalDate.now().getYear(); + BigDecimal annual = contract.annualYear != null && contract.annualYear == year ? contract.annualUsed : BigDecimal.ZERO.setScale(2); + jdbc.update("UPDATE wallet_contract SET daily_used_amount=?,daily_counter_date=CURRENT_DATE,annual_used_amount=?," + + "annual_counter_year=?,updated_at=CURRENT_TIMESTAMP WHERE wallet_id=?", + day.add(order.getAmount()), annual.add(order.getAmount()), year, order.getPayerWalletId()); + } + + private InstitutionKeySubject subject(PaymentActor actor) { return new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId()); } + private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } + + private static final class WalletSnapshot { + private final BigDecimal balance; private final BigDecimal frozenAmount; private final String status; + private WalletSnapshot(BigDecimal balance, BigDecimal frozenAmount, String status) { this.balance = balance; this.frozenAmount = frozenAmount; this.status = status; } + } + private static final class ContractSnapshot { + private final BigDecimal singleLimit; private final BigDecimal dailyLimit; private final BigDecimal dailyUsed; + private final LocalDate dailyDate; private final BigDecimal annualLimit; private final BigDecimal annualUsed; + private final Integer annualYear; private final String status; private final Instant validUntil; + private ContractSnapshot(BigDecimal singleLimit, BigDecimal dailyLimit, BigDecimal dailyUsed, LocalDate dailyDate, + BigDecimal annualLimit, BigDecimal annualUsed, Integer annualYear, String status, Instant validUntil) { + this.singleLimit = singleLimit; this.dailyLimit = dailyLimit; this.dailyUsed = dailyUsed; this.dailyDate = dailyDate; + this.annualLimit = annualLimit; this.annualUsed = annualUsed; this.annualYear = annualYear; this.status = status; this.validUntil = validUntil; + } + } + private static final class CoinCandidate { + private final String currencyId; private final BigDecimal denomination; private final String status; + private CoinCandidate(String currencyId, BigDecimal denomination, String status) { this.currencyId = currencyId; this.denomination = denomination; this.status = status; } + } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/CreatePaymentRequest.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/CreatePaymentRequest.java new file mode 100644 index 0000000..0dd0b83 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/CreatePaymentRequest.java @@ -0,0 +1,19 @@ +package com.yau.digitalrmb.payment.interfaces.dto; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.math.BigDecimal; + +public class CreatePaymentRequest { + @NotBlank private String payerWalletId; + @NotBlank private String payeeWalletId; + @NotNull @DecimalMin("0.01") @Digits(integer = 18, fraction = 2) private BigDecimal amount; + @Size(max = 256) private String note; + public String getPayerWalletId() { return payerWalletId; } public void setPayerWalletId(String value) { payerWalletId = value; } + public String getPayeeWalletId() { return payeeWalletId; } public void setPayeeWalletId(String value) { payeeWalletId = value; } + public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal value) { amount = value; } + public String getNote() { return note; } public void setNote(String value) { note = value; } +} 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 new file mode 100644 index 0000000..b0eda1c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java @@ -0,0 +1,88 @@ +package com.yau.digitalrmb.payment.interfaces.rest; + +import com.yau.digitalrmb.payment.application.command.CreatePaymentCommand; +import com.yau.digitalrmb.payment.application.query.PaymentOrderView; +import com.yau.digitalrmb.payment.application.service.PaymentApplicationService; +import com.yau.digitalrmb.payment.domain.model.PaymentActor; +import com.yau.digitalrmb.payment.domain.model.PaymentContext; +import com.yau.digitalrmb.payment.interfaces.dto.CreatePaymentRequest; +import com.yau.digitalrmb.security.application.CurrentUser; +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/payments") +@Tag(name = "用户支付数字货币实验模块", description = "用户A经商业银行A、中央银行和商业银行B向用户B支付数字货币的六步教学流程") +public class PaymentController { + private final PaymentApplicationService service; + private final CurrentUserService currentUserService; + + public PaymentController(PaymentApplicationService service, CurrentUserService currentUserService) { + this.service = service; this.currentUserService = currentUserService; + } + + @GetMapping("/context") + @Operation(summary = "用户支付数字货币:步骤一,初始化付款方和收款方钱包信息") + public ApiResponse context(@RequestParam String payeeWalletId) { return ok(service.context(payeeWalletId, actor())); } + + @PostMapping + @Operation(summary = "用户支付数字货币:步骤二,发送支付请求") + public ApiResponse create(@Valid @RequestBody CreatePaymentRequest request) { + return ok(service.create(new CreatePaymentCommand(request.getPayerWalletId(), request.getPayeeWalletId(), + request.getAmount(), request.getNote()), actor())); + } + + @PostMapping("/{id}/sign") + @Operation(summary = "用户支付数字货币:步骤二,使用付款钱包私钥签名支付请求") + public ApiResponse sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); } + + @PostMapping("/{id}/payer-bank-process") + @Operation(summary = "用户支付数字货币:步骤三,商业银行A验签、合规校验并冻结数字货币") + public ApiResponse payerBankProcess(@PathVariable UUID id) { return ok(service.processPayerBank(id, actor())); } + + @PostMapping("/{id}/central-bank-settle") + @Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算") + public ApiResponse centralBankSettle(@PathVariable UUID id) { return ok(service.settleAtCentralBank(id, actor())); } + + @PostMapping("/{id}/payee-bank-credit") + @Operation(summary = "用户支付数字货币:步骤五,商业银行B验签并向收款钱包入账") + public ApiResponse payeeBankCredit(@PathVariable UUID id) { return ok(service.creditPayee(id, actor())); } + + @PostMapping("/{id}/notifications") + @Operation(summary = "用户支付数字货币:步骤六,生成付款回执和收款到账通知") + public ApiResponse notifications(@PathVariable UUID id) { return ok(service.createNotifications(id, actor())); } + + @GetMapping("/{id}") + @Operation(summary = "查询用户支付数字货币实验订单和步骤结果") + public ApiResponse get(@PathVariable UUID id) { return ok(service.get(id, actor())); } + + @GetMapping("/{id}/notifications") + @Operation(summary = "查询当前用户的支付回执或到账通知") + public ApiResponse notificationView(@PathVariable UUID id) { return ok(service.notifications(id, actor())); } + + private PaymentActor actor() { + CurrentUser user = currentUserService.getCurrentUser(); + try { return new PaymentActor(user.getUserId(), user.getName(), Long.parseLong(user.getSchoolId()), Long.parseLong(user.getClassId())); } + catch (RuntimeException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户缺少支付实验所需的用户、学校或班级信息"); + } + } + + private ApiResponse ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } +} diff --git a/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java b/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java new file mode 100644 index 0000000..b751890 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/application/TrainingTaskProgressService.java @@ -0,0 +1,132 @@ +package com.yau.digitalrmb.training.application; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class TrainingTaskProgressService { + private static final String GENERATION = "PBOC_CURRENCY_GENERATION"; + private static final String ISSUANCE = "PBOC_CURRENCY_ISSUANCE"; + private static final String WALLET = "WALLET_OPENING"; + private static final String EXCHANGE = "CURRENCY_EXCHANGE"; + private static final String PAYMENT = "CURRENCY_PAYMENT"; + + private final JdbcTemplate jdbc; + private final Map definitions; + + public TrainingTaskProgressService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + this.definitions = definitions(); + } + + public List all(String userId) { + List result = new ArrayList<>(); + for (String moduleCode : definitions.keySet()) result.add(one(userId, moduleCode)); + return result; + } + + public TrainingTaskView one(String userId, String moduleCode) { + ModuleDefinition module = definitions.get(moduleCode); + if (module == null) throw new IllegalArgumentException("不支持的实训模块:" + moduleCode); + List completed = jdbc.queryForList("SELECT step_code FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", + String.class, userId, moduleCode); + return view(module, completed); + } + + /** 真实业务步骤成功后调用;同步补齐此前步骤,避免历史业务数据导致进度断层。 */ + public void completeThrough(String userId, String moduleCode, String stepCode) { + ModuleDefinition module = definitions.get(moduleCode); + if (module == null || module.unavailable) return; + int index = module.indexOf(stepCode); + if (index < 0) return; + Instant now = Instant.now(); + for (int i = 0; i <= index; i++) { + String currentStep = module.steps.get(i).code; + jdbc.update("INSERT INTO training_task_step_progress (user_id, module_code, step_code, completed_at) VALUES (?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE completed_at = completed_at", + userId, moduleCode, currentStep, now); + } + } + + public void reset(String userId, String moduleCode) { + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id = ? AND module_code = ?", userId, moduleCode); + } + + private TrainingTaskView view(ModuleDefinition module, List completed) { + List steps = new ArrayList<>(); + boolean prefixCompleted = true; + int completedCount = 0; + String currentStepCode = null; + for (StepDefinition step : module.steps) { + boolean done = prefixCompleted && completed.contains(step.code); + String status; + boolean actionAvailable = false; + if (done) { + status = "COMPLETED"; + completedCount++; + } else if (module.unavailable && completedCount == 0) { + status = "NOT_STARTED"; + prefixCompleted = false; + } else if (prefixCompleted) { + status = "CURRENT"; + actionAvailable = true; + currentStepCode = step.code; + prefixCompleted = false; + } else { + status = "LOCKED"; + } + steps.add(new TrainingStepView(step.code, step.name, step.sortNo, status, actionAvailable)); + } + String status = completedCount == module.steps.size() ? "COMPLETED" : completedCount == 0 ? "NOT_STARTED" : "IN_PROGRESS"; + return new TrainingTaskView(module.code, module.name, status, module.steps.size(), completedCount, currentStepCode, + module.unavailable, module.unavailableReason, steps); + } + + private Map definitions() { + Map result = new LinkedHashMap<>(); + result.put(GENERATION, module(GENERATION, "货币生成", false, null, + "机构身份信息与密钥准备", "生成数字货币请求", "验证货币生成请求", "生成交易信息标识", "生成额度控制位请求", "验证额度控制位请求", "控制系统签名", "生成额度控制位", "生成标准面额币串")); + result.put(ISSUANCE, module(ISSUANCE, "货币发行", false, null, + "查看货币需求", "生成发行申请计划", "发行计划数字签名", "发送标准报文", "中央银行验证与业务核查", "准备金通知与ACS扣减", "发行并确权数字货币")); + result.put(WALLET, module(WALLET, "开立数字钱包", true, "钱包开立真实业务接口尚未实现", + "开立数字钱包")); + result.put(EXCHANGE, module(EXCHANGE, "货币兑换", false, null, + "确认银行卡、钱包和商业银行库存", "生成取币请求报文", "钱包私钥签名", "商业银行验签并扣款", "央行确认币串权属")); + result.put(PAYMENT, module(PAYMENT, "数字货币支付", false, null, + "初始化付款方和收款方钱包", "签名支付请求", "付款行预处理", "中央银行结算", "收款行入账", "生成支付回执与到账通知")); + return Collections.unmodifiableMap(result); + } + + private ModuleDefinition module(String code, String name, boolean unavailable, String reason, String... stepNames) { + List steps = new ArrayList<>(); + for (int i = 0; i < stepNames.length; i++) steps.add(new StepDefinition(String.format("%02d", i + 1), stepNames[i], (i + 1) * 10)); + return new ModuleDefinition(code, name, unavailable, reason, steps); + } + + @Getter @AllArgsConstructor + public static class TrainingTaskView { + private final String moduleCode; private final String moduleName; private final String status; + private final int totalSteps; private final int completedSteps; private final String currentStepCode; + private final boolean actionUnavailable; private final String unavailableReason; private final List steps; + } + @Getter @AllArgsConstructor + public static class TrainingStepView { + private final String stepCode; private final String stepName; private final int sortNo; private final String status; private final boolean actionAvailable; + } + private static class ModuleDefinition { + private final String code, name; private final boolean unavailable; private final String unavailableReason; private final List steps; + private ModuleDefinition(String code, String name, boolean unavailable, String unavailableReason, List steps) { this.code = code; this.name = name; this.unavailable = unavailable; this.unavailableReason = unavailableReason; this.steps = steps; } + private int indexOf(String code) { for (int i = 0; i < steps.size(); i++) if (steps.get(i).code.equals(code)) return i; return -1; } + } + private static class StepDefinition { private final String code, name; private final int sortNo; private StepDefinition(String code, String name, int sortNo) { this.code = code; this.name = name; this.sortNo = sortNo; } } +} diff --git a/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java new file mode 100644 index 0000000..7a73fe1 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressInterceptor.java @@ -0,0 +1,74 @@ +package com.yau.digitalrmb.training.infrastructure; + +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** 将已成功返回的真实业务操作映射为对应模块任务步骤;不暴露独立的人工完成接口。 */ +@Component +public class TrainingTaskProgressInterceptor implements HandlerInterceptor { + private final TrainingTaskProgressService progressService; + private final CurrentUserService currentUserService; + + public TrainingTaskProgressInterceptor(TrainingTaskProgressService progressService, CurrentUserService currentUserService) { + this.progressService = progressService; + this.currentUserService = currentUserService; + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { + if (response.getStatus() >= 400) return; + String[] mapping = mapping(request.getMethod(), path(request)); + if (mapping == null) return; + String userId = currentUserService.getCurrentUser().getUserId(); + if ("RESET".equals(mapping[1])) progressService.reset(userId, mapping[0]); + else progressService.completeThrough(userId, mapping[0], mapping[1]); + } + + private String path(HttpServletRequest request) { + String value = request.getRequestURI(); + String context = request.getContextPath(); + return context == null || context.isEmpty() ? value : value.substring(context.length()); + } + + private String[] mapping(String method, String path) { + if ("POST".equals(method) && "/api/v1/institution-identifiers/training/reset".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "RESET"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/applications/feedback".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "01"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/currency-requests/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "02"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/currency-request-verifications/steps/return-response".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "03"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/transaction-information-identifiers/steps/assemble".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "04"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/quota-requests/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "05"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/quota-verifications/steps/compare-digests".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "06"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/control-system-signatures/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "07"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/quota-control-bits/steps/send".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "08"); + if ("POST".equals(method) && "/api/v1/institution-identifiers/standard-currencies/steps/generate".equals(path)) return pair("PBOC_CURRENCY_GENERATION", "09"); + + if ("GET".equals(method) && "/api/v1/commercial-banks/issuance/inventory".equals(path)) return pair("PBOC_CURRENCY_ISSUANCE", "01"); + if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/prepare-message")) return pair("PBOC_CURRENCY_ISSUANCE", "02"); + if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/sign")) return pair("PBOC_CURRENCY_ISSUANCE", "03"); + if ("POST".equals(method) && path.matches("/api/v1/commercial-banks/issuance/requests/[^/]+/send")) return pair("PBOC_CURRENCY_ISSUANCE", "04"); + if ("POST".equals(method) && path.matches("/api/v1/central-banks/issuance/requests/[^/]+/business-review")) return pair("PBOC_CURRENCY_ISSUANCE", "05"); + if ("POST".equals(method) && (path.matches("/api/v1/central-banks/issuance/reserve-deductions/[^/]+/execute") || "/api/v1/central-banks/issuance/reserve-deductions".equals(path))) return pair("PBOC_CURRENCY_ISSUANCE", "06"); + if ("POST".equals(method) && path.matches("/api/v1/central-banks/issuance/requests/[^/]+/digital-currency-ownership")) return pair("PBOC_CURRENCY_ISSUANCE", "07"); + + if ("GET".equals(method) && "/api/v1/exchanges/context".equals(path)) return pair("CURRENCY_EXCHANGE", "01"); + if ("POST".equals(method) && "/api/v1/exchanges".equals(path)) return pair("CURRENCY_EXCHANGE", "02"); + if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/sign")) return pair("CURRENCY_EXCHANGE", "03"); + if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/bank-process")) return pair("CURRENCY_EXCHANGE", "04"); + if ("POST".equals(method) && path.matches("/api/v1/exchanges/[^/]+/confirm-ownership")) return pair("CURRENCY_EXCHANGE", "05"); + + if ("GET".equals(method) && "/api/v1/payments/context".equals(path)) return pair("CURRENCY_PAYMENT", "01"); + if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/sign")) return pair("CURRENCY_PAYMENT", "02"); + if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/payer-bank-process")) return pair("CURRENCY_PAYMENT", "03"); + if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/central-bank-settle")) return pair("CURRENCY_PAYMENT", "04"); + if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/payee-bank-credit")) return pair("CURRENCY_PAYMENT", "05"); + if ("POST".equals(method) && path.matches("/api/v1/payments/[^/]+/notifications")) return pair("CURRENCY_PAYMENT", "06"); + return null; + } + private String[] pair(String moduleCode, String stepCode) { return new String[]{moduleCode, stepCode}; } +} diff --git a/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressWebConfig.java b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressWebConfig.java new file mode 100644 index 0000000..76bfad0 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/infrastructure/TrainingTaskProgressWebConfig.java @@ -0,0 +1,12 @@ +package com.yau.digitalrmb.training.infrastructure; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class TrainingTaskProgressWebConfig implements WebMvcConfigurer { + private final TrainingTaskProgressInterceptor interceptor; + public TrainingTaskProgressWebConfig(TrainingTaskProgressInterceptor interceptor) { this.interceptor = interceptor; } + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(interceptor).addPathPatterns("/api/v1/**"); } +} diff --git a/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java new file mode 100644 index 0000000..830e74d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/interfaces/rest/TrainingTaskProgressController.java @@ -0,0 +1,35 @@ +package com.yau.digitalrmb.training.interfaces.rest; + +import com.yau.digitalrmb.security.application.CurrentUserService; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import com.yau.digitalrmb.training.application.TrainingTaskProgressService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/training-tasks") +@Tag(name = "实训任务进度", description = "按当前用户分别返回五个实验模块的子步骤完成进度") +public class TrainingTaskProgressController { + private final TrainingTaskProgressService service; + private final CurrentUserService currentUserService; + public TrainingTaskProgressController(TrainingTaskProgressService service, CurrentUserService currentUserService) { this.service = service; this.currentUserService = currentUserService; } + + @GetMapping + @Operation(summary = "查询当前用户全部实训任务进度") + public ApiResponse> all() { return ok(service.all(userId())); } + + @GetMapping("/{moduleCode}") + @Operation(summary = "查询当前用户单个实训任务进度") + public ApiResponse one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); } + + private String userId() { return currentUserService.getCurrentUser().getUserId(); } + private ApiResponse ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } +} diff --git a/src/main/resources/db/manual/payment_module_upgrade.sql b/src/main/resources/db/manual/payment_module_upgrade.sql new file mode 100644 index 0000000..e3f0112 --- /dev/null +++ b/src/main/resources/db/manual/payment_module_upgrade.sql @@ -0,0 +1,136 @@ +-- 模块五:用户支付数字货币实验模块。 +-- MySQL 8.0.32 不支持 ALTER TABLE ... ADD COLUMN IF NOT EXISTS, +-- 通过 information_schema 判断后再执行,使脚本可重复运行。 +SET @payment_frozen_amount_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'digital_wallet' + AND column_name = 'frozen_amount' +); +SET @payment_frozen_amount_ddl = IF( + @payment_frozen_amount_exists = 0, + 'ALTER TABLE digital_wallet ADD COLUMN frozen_amount DECIMAL(20,2) NOT NULL DEFAULT 0', + 'SELECT 1' +); +PREPARE payment_frozen_amount_statement FROM @payment_frozen_amount_ddl; +EXECUTE payment_frozen_amount_statement; +DEALLOCATE PREPARE payment_frozen_amount_statement; + +CREATE TABLE IF NOT EXISTS payment_order ( + id CHAR(36) PRIMARY KEY, + payment_no VARCHAR(96) NOT NULL UNIQUE, + payer_user_id VARCHAR(36) NOT NULL, + payer_wallet_id VARCHAR(96) NOT NULL, + payer_bank_code VARCHAR(32) NOT NULL, + payer_organization_id VARCHAR(64) NOT NULL, + payee_user_id VARCHAR(36) NOT NULL, + payee_wallet_id VARCHAR(96) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + payee_organization_id VARCHAR(64) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + payment_note VARCHAR(256) NOT NULL, + request_timestamp VARCHAR(32) NOT NULL, + payment_original_text TEXT NOT NULL, + payment_digest CHAR(64) NOT NULL, + payer_signature VARCHAR(512) NULL, + compliance_report TEXT NULL, + compliance_digest CHAR(64) NULL, + payer_bank_signature VARCHAR(512) NULL, + transaction_id VARCHAR(128) NULL UNIQUE, + clearing_no VARCHAR(128) NULL UNIQUE, + settlement_original_text TEXT NULL, + settlement_digest CHAR(64) NULL, + central_bank_signature VARCHAR(512) NULL, + coin_count INT NOT NULL DEFAULT 0, + payer_balance_after DECIMAL(20,2) NULL, + payee_balance_after DECIMAL(20,2) NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payer_wallet_id) REFERENCES digital_wallet(wallet_id), + FOREIGN KEY (payee_wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS payment_coin_reservation ( + payment_id CHAR(36) NOT NULL, + currency_id VARCHAR(96) NOT NULL, + denomination DECIMAL(20,2) NOT NULL, + source_status VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + reserved_at TIMESTAMP NOT NULL, + transferred_at TIMESTAMP NULL, + PRIMARY KEY (payment_id, currency_id), + UNIQUE KEY uk_payment_reserved_currency (currency_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (currency_id) REFERENCES commercial_bank_currency(currency_id) +); + +CREATE TABLE IF NOT EXISTS payment_ownership_transfer ( + transaction_id VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + from_wallet_id VARCHAR(96) NOT NULL, + to_wallet_id VARCHAR(96) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + coin_count INT NOT NULL, + settlement_digest CHAR(64) NOT NULL, + central_bank_signature VARCHAR(512) NOT NULL, + status VARCHAR(32) NOT NULL, + confirmed_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_clearing_record ( + clearing_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + payer_bank_code VARCHAR(32) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + status VARCHAR(32) NOT NULL, + settled_at TIMESTAMP NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_wallet_ledger ( + ledger_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + wallet_id VARCHAR(96) NOT NULL, + direction VARCHAR(16) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + balance_after DECIMAL(20,2) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + UNIQUE KEY uk_payment_wallet_ledger (payment_id, wallet_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS payment_notification ( + id CHAR(36) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + recipient_user_id VARCHAR(36) NOT NULL, + recipient_wallet_id VARCHAR(96) NOT NULL, + notification_type VARCHAR(32) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE KEY uk_payment_notification (payment_id, notification_type), + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_step_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + step_name VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL, + output_text TEXT NULL, + operated_at TIMESTAMP NOT NULL, + operator_user_id VARCHAR(36) NOT NULL, + operator_name VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); diff --git a/src/main/resources/db/manual/training_task_progress_upgrade.sql b/src/main/resources/db/manual/training_task_progress_upgrade.sql new file mode 100644 index 0000000..a95ea4d --- /dev/null +++ b/src/main/resources/db/manual/training_task_progress_upgrade.sql @@ -0,0 +1,9 @@ +-- 五个实验模块的用户步骤进度。每条记录只由真实业务接口成功后写入。 +CREATE TABLE IF NOT EXISTS training_task_step_progress ( + user_id VARCHAR(36) NOT NULL, + module_code VARCHAR(64) NOT NULL, + step_code VARCHAR(32) NOT NULL, + completed_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, module_code, step_code), + KEY idx_training_task_progress_user_module (user_id, module_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index f21af67..9e38b86 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -692,3 +692,133 @@ INSERT INTO business_knowledge_step_relation (knowledge_code, module_code, step_ ('CURRENCY_STRING', 'PBOC_CURRENCY_ISSUANCE', '07', 'OUTPUT', 20), ('FIRST_PRIVATE_KEY', 'PBOC_CURRENCY_ISSUANCE', '07', 'SIGN', 30) ON DUPLICATE KEY UPDATE knowledge_code = VALUES(knowledge_code); +-- 模块五:用户支付数字货币实验模块。 +ALTER TABLE digital_wallet + ADD COLUMN frozen_amount DECIMAL(20,2) NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS payment_order ( + id CHAR(36) PRIMARY KEY, + payment_no VARCHAR(96) NOT NULL UNIQUE, + payer_user_id VARCHAR(36) NOT NULL, + payer_wallet_id VARCHAR(96) NOT NULL, + payer_bank_code VARCHAR(32) NOT NULL, + payer_organization_id VARCHAR(64) NOT NULL, + payee_user_id VARCHAR(36) NOT NULL, + payee_wallet_id VARCHAR(96) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + payee_organization_id VARCHAR(64) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + payment_note VARCHAR(256) NOT NULL, + request_timestamp VARCHAR(32) NOT NULL, + payment_original_text TEXT NOT NULL, + payment_digest CHAR(64) NOT NULL, + payer_signature VARCHAR(512) NULL, + compliance_report TEXT NULL, + compliance_digest CHAR(64) NULL, + payer_bank_signature VARCHAR(512) NULL, + transaction_id VARCHAR(128) NULL UNIQUE, + clearing_no VARCHAR(128) NULL UNIQUE, + settlement_original_text TEXT NULL, + settlement_digest CHAR(64) NULL, + central_bank_signature VARCHAR(512) NULL, + coin_count INT NOT NULL DEFAULT 0, + payer_balance_after DECIMAL(20,2) NULL, + payee_balance_after DECIMAL(20,2) NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payer_wallet_id) REFERENCES digital_wallet(wallet_id), + FOREIGN KEY (payee_wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS training_task_step_progress ( + user_id VARCHAR(36) NOT NULL, + module_code VARCHAR(64) NOT NULL, + step_code VARCHAR(32) NOT NULL, + completed_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, module_code, step_code), + KEY idx_training_task_progress_user_module (user_id, module_code) +); + +CREATE TABLE IF NOT EXISTS payment_coin_reservation ( + payment_id CHAR(36) NOT NULL, + currency_id VARCHAR(96) NOT NULL, + denomination DECIMAL(20,2) NOT NULL, + source_status VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + reserved_at TIMESTAMP NOT NULL, + transferred_at TIMESTAMP NULL, + PRIMARY KEY (payment_id, currency_id), + UNIQUE KEY uk_payment_reserved_currency (currency_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (currency_id) REFERENCES commercial_bank_currency(currency_id) +); + +CREATE TABLE IF NOT EXISTS payment_ownership_transfer ( + transaction_id VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + from_wallet_id VARCHAR(96) NOT NULL, + to_wallet_id VARCHAR(96) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + coin_count INT NOT NULL, + settlement_digest CHAR(64) NOT NULL, + central_bank_signature VARCHAR(512) NOT NULL, + status VARCHAR(32) NOT NULL, + confirmed_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_clearing_record ( + clearing_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + payer_bank_code VARCHAR(32) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + status VARCHAR(32) NOT NULL, + settled_at TIMESTAMP NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_wallet_ledger ( + ledger_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + wallet_id VARCHAR(96) NOT NULL, + direction VARCHAR(16) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + balance_after DECIMAL(20,2) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + UNIQUE KEY uk_payment_wallet_ledger (payment_id, wallet_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS payment_notification ( + id CHAR(36) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + recipient_user_id VARCHAR(36) NOT NULL, + recipient_wallet_id VARCHAR(96) NOT NULL, + notification_type VARCHAR(32) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE KEY uk_payment_notification (payment_id, notification_type), + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_step_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + step_name VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL, + output_text TEXT NULL, + operated_at TIMESTAMP NOT NULL, + operator_user_id VARCHAR(36) NOT NULL, + operator_name VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); 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 new file mode 100644 index 0000000..cd14e0b --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -0,0 +1,179 @@ +package com.yau.digitalrmb.payment.interfaces.rest; + +import com.jayway.jsonpath.JsonPath; +import com.yau.digitalrmb.exchange.infrastructure.crypto.WalletPrivateKeyCipher; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "file.path=target/test-files") +@AutoConfigureMockMvc +@ActiveProfiles("test") +class PaymentControllerTest { + private static final String PAYER_USER = "00000000-0000-0000-0000-000000000901"; + private static final String PAYEE_USER = "00000000-0000-0000-0000-000000000902"; + private static final String PAYER_WALLET = "WALLET_PAYMENT_A"; + private static final String PAYEE_WALLET = "WALLET_PAYMENT_B"; + + @Autowired private MockMvc mockMvc; + @Autowired private JdbcTemplate jdbc; + @Autowired private InstitutionIdentityCryptography cryptography; + @Autowired private WalletPrivateKeyCipher walletCipher; + @Autowired private InstitutionKeyService keyService; + + @BeforeEach + void seedPrerequisites() { + cleanPaymentData(); + insertUser(PAYER_USER, "payment-a", "3001", "2001"); + insertUser(PAYEE_USER, "payment-b", "3001", "2001"); + jdbc.update("INSERT INTO institution_identifier_application (id,bank_code,user_id,school_id,class_id,request_timestamp," + + "original_text,status,institution_identifier,training_round,scoring_criteria,created_at,updated_at,created_by,updated_by,deleted) " + + "VALUES (?,?,?,?,?,?,?,'ISSUED',?,1,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", + 991001L, "BKCHCNBJ00001", PAYER_USER, 3001L, 2001L, "20260814090000", "BANK_A", "ORG_PAYMENT_A"); + jdbc.update("INSERT INTO institution_identifier_application (id,bank_code,user_id,school_id,class_id,request_timestamp," + + "original_text,status,institution_identifier,training_round,scoring_criteria,created_at,updated_at,created_by,updated_by,deleted) " + + "VALUES (?,?,?,?,?,?,?,'ISSUED',?,1,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", + 991002L, "BKCHCNBJ00002", PAYEE_USER, 3001L, 2001L, "20260814090000", "BANK_B", "ORG_PAYMENT_B"); + InstitutionKeySubject subject = new InstitutionKeySubject(PAYER_USER, 3001L, 2001L); + keyService.commercialBankKey(subject, "test"); + keyService.centralBankKey(subject, "test"); + insertWallet(PAYER_USER, PAYER_WALLET, "CERT_PAYMENT_A", "BKCHCNBJ00001", "ACCOUNT_PAYMENT_A", new BigDecimal("200.00")); + insertWallet(PAYEE_USER, PAYEE_WALLET, "CERT_PAYMENT_B", "BKCHCNBJ00002", "ACCOUNT_PAYMENT_B", BigDecimal.ZERO); + insertPayerContract(); + for (int index = 1; index <= 2; index++) { + jdbc.update("INSERT INTO commercial_bank_currency (currency_id,source_currency_id,source_batch_id,issuance_request_id," + + "bank_code,organization_id,denomination,currency,complete_currency,issuance_ownership_original_text," + + "issuance_ownership_signature,status,received_at,received_by_user_id,received_by) VALUES (?,?,?,?,?,?,100,'DC',?,'ISSUANCE',?,'TRANSFERRED',CURRENT_TIMESTAMP,?,'test')", + "DC_PAYMENT_" + index, 991100L + index, 991100L, "PAYMENT_TEST_ISSUANCE", "BKCHCNBJ00001", "ORG_PAYMENT_A", + "COMPLETE_PAYMENT_" + index, keyService.signCentralBank(subject, "ISSUANCE"), PAYER_USER); + jdbc.update("INSERT INTO central_bank_currency_ownership (currency_id,owner_type,owner_id,status,last_transaction_id,updated_at) " + + "VALUES (?,'WALLET',?,'AVAILABLE',NULL,CURRENT_TIMESTAMP)", "DC_PAYMENT_" + index, PAYER_WALLET); + } + } + + @AfterEach + void cleanUp() { + cleanPaymentData(); + jdbc.update("DELETE FROM institution_sm2_key_audit WHERE user_id=?", PAYER_USER); + jdbc.update("DELETE FROM institution_sm2_key WHERE user_id=?", PAYER_USER); + jdbc.update("DELETE FROM institution_identifier_application WHERE id IN (991001,991002)"); + jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); + jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", PAYER_WALLET); + jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); + jdbc.update("DELETE FROM simulated_bank_account WHERE account_id IN ('ACCOUNT_PAYMENT_A','ACCOUNT_PAYMENT_B')"); + jdbc.update("DELETE FROM digital_wallet WHERE wallet_id IN (?,?)", PAYER_WALLET, PAYEE_WALLET); + jdbc.update("DELETE FROM commercial_bank_currency WHERE issuance_request_id='PAYMENT_TEST_ISSUANCE'"); + jdbc.update("DELETE FROM sys_user WHERE user_id IN (?,?)", PAYER_USER, PAYEE_USER); + } + + @Test + void completesTheSixPrototypeStepsAcrossTwoBanks() throws Exception { + mockMvc.perform(get("/api/v1/payments/context").param("payeeWalletId", PAYEE_WALLET).with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.payer.walletId").value(PAYER_WALLET)) + .andExpect(jsonPath("$.data.payee.walletId").value(PAYEE_WALLET)); + String created = mockMvc.perform(post("/api/v1/payments").with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"payerWalletId\":\"" + PAYER_WALLET + "\",\"payeeWalletId\":\"" + PAYEE_WALLET + "\",\"amount\":200.00,\"note\":\"实验支付\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("REQUEST_PREPARED")) + .andExpect(jsonPath("$.data.paymentOriginalText").value(org.hamcrest.Matchers.startsWith("PAY|"))) + .andReturn().getResponse().getContentAsString(); + String id = JsonPath.read(created, "$.data.id"); + + mockMvc.perform(post("/api/v1/payments/{id}/sign", id).with(payer())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYER_SIGNED")); + mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYER_BANK_ACCEPTED")) + .andExpect(jsonPath("$.data.coinCount").value(2)); + 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(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}/notifications", id).with(payer())) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.notifications[0].notificationType").value("PAYMENT_RECEIPT")); + + mockMvc.perform(get("/api/v1/training-tasks/CURRENCY_PAYMENT").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.completedSteps").value(6)) + .andExpect(jsonPath("$.data.steps[5].status").value("COMPLETED")); + mockMvc.perform(get("/api/v1/training-tasks").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(5)) + .andExpect(jsonPath("$.data[2].moduleCode").value("WALLET_OPENING")) + .andExpect(jsonPath("$.data[2].actionUnavailable").value(true)); + + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYER_WALLET)) + .isEqualByComparingTo("0.00"); + assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYER_WALLET)) + .isEqualByComparingTo("0.00"); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, PAYEE_WALLET)) + .isEqualByComparingTo("200.00"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger WHERE payment_id=?", Integer.class, id)).isEqualTo(2); + assertThat(jdbc.queryForObject("SELECT owner_id FROM central_bank_currency_ownership WHERE currency_id='DC_PAYMENT_1'", String.class)) + .isEqualTo(PAYEE_WALLET); + } + + 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); + } + + private void insertWallet(String userId, String walletId, String certificate, String bankCode, String accountId, BigDecimal balance) { + InstitutionSm2KeyPair key = cryptography.generateSm2KeyPair(); + jdbc.update("INSERT INTO digital_wallet (wallet_id,user_id,wallet_type,status,balance,frozen_amount,central_bank_confirmation_signature,opened_at,created_at,updated_at) " + + "VALUES (?,?,'TYPE_II','ACTIVE',?,0,'CB_FINAL',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", walletId, userId, balance); + jdbc.update("INSERT INTO wallet_certificate (certificate_serial,wallet_id,public_key,encrypted_private_key,filing_status,central_bank_root_signature,status,issued_at) " + + "VALUES (?,?,?,?,'REGISTERED','CB_ROOT','VALID',CURRENT_TIMESTAMP)", certificate, walletId, key.getPublicKey(), walletCipher.encrypt(key.getPrivateKey())); + jdbc.update("INSERT INTO simulated_bank_account (account_id,user_id,bank_code,bank_name,card_number,card_last4,balance,frozen_amount,status,created_at,updated_at) " + + "VALUES (?,?,?,?,'6222020000000001','0001',0,0,'ACTIVE',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", accountId, userId, bankCode, bankCode); + jdbc.update("INSERT INTO wallet_bank_binding (wallet_id,bank_account_id,status,bound_at) VALUES (?,?,'BOUND',CURRENT_TIMESTAMP)", walletId, accountId); + } + + private void insertPayerContract() { + jdbc.update("INSERT INTO wallet_contract (contract_id,wallet_id,wallet_type,single_payment_limit,daily_payment_limit,annual_payment_limit,balance_limit,valid_until,original_text,digest,status,daily_used_amount,daily_counter_date,annual_used_amount,annual_counter_year,created_at,updated_at) " + + "VALUES ('CONTRACT_PAYMENT_A',?,'TYPE_II',50000,100000,500000,500000,NULL,'CONTRACT','DIGEST','ACTIVE',0,CURRENT_DATE,0,2026,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", PAYER_WALLET); + } + + private void cleanPaymentData() { + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", PAYER_USER); + jdbc.update("DELETE FROM payment_step_log"); + jdbc.update("DELETE FROM payment_notification"); + jdbc.update("DELETE FROM payment_wallet_ledger"); + jdbc.update("DELETE FROM payment_clearing_record"); + jdbc.update("DELETE FROM payment_ownership_transfer"); + jdbc.update("DELETE FROM payment_coin_reservation"); + jdbc.update("DELETE FROM payment_order"); + jdbc.update("DELETE FROM central_bank_currency_ownership WHERE currency_id LIKE 'DC_PAYMENT_%'"); + } + + private RequestPostProcessor payer() { + return jwt().jwt(jwt -> jwt.subject(PAYER_USER).claim("userId", PAYER_USER).claim("preferred_username", "payment-a")); + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index fbe9756..59768b3 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -568,3 +568,132 @@ INSERT INTO business_knowledge_step_relation (knowledge_code, module_code, step_ ('ACS', 'PBOC_CURRENCY_ISSUANCE', '06', 'SYSTEM', 10), ('LIMIT_CONTROL_BIT', 'PBOC_CURRENCY_ISSUANCE', '07', 'DATA', 10), ('CURRENCY_STRING', 'PBOC_CURRENCY_ISSUANCE', '07', 'OUTPUT', 20), ('FIRST_PRIVATE_KEY', 'PBOC_CURRENCY_ISSUANCE', '07', 'SIGN', 30) ON DUPLICATE KEY UPDATE knowledge_code = VALUES(knowledge_code); +-- 模块五:用户支付数字货币实验模块。 +ALTER TABLE digital_wallet + ADD COLUMN frozen_amount DECIMAL(20,2) NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS payment_order ( + id CHAR(36) PRIMARY KEY, + payment_no VARCHAR(96) NOT NULL UNIQUE, + payer_user_id VARCHAR(36) NOT NULL, + payer_wallet_id VARCHAR(96) NOT NULL, + payer_bank_code VARCHAR(32) NOT NULL, + payer_organization_id VARCHAR(64) NOT NULL, + payee_user_id VARCHAR(36) NOT NULL, + payee_wallet_id VARCHAR(96) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + payee_organization_id VARCHAR(64) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + payment_note VARCHAR(256) NOT NULL, + request_timestamp VARCHAR(32) NOT NULL, + payment_original_text TEXT NOT NULL, + payment_digest CHAR(64) NOT NULL, + payer_signature VARCHAR(512) NULL, + compliance_report TEXT NULL, + compliance_digest CHAR(64) NULL, + payer_bank_signature VARCHAR(512) NULL, + transaction_id VARCHAR(128) NULL UNIQUE, + clearing_no VARCHAR(128) NULL UNIQUE, + settlement_original_text TEXT NULL, + settlement_digest CHAR(64) NULL, + central_bank_signature VARCHAR(512) NULL, + coin_count INT NOT NULL DEFAULT 0, + payer_balance_after DECIMAL(20,2) NULL, + payee_balance_after DECIMAL(20,2) NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payer_wallet_id) REFERENCES digital_wallet(wallet_id), + FOREIGN KEY (payee_wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS training_task_step_progress ( + user_id VARCHAR(36) NOT NULL, + module_code VARCHAR(64) NOT NULL, + step_code VARCHAR(32) NOT NULL, + completed_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, module_code, step_code) +); + +CREATE TABLE IF NOT EXISTS payment_coin_reservation ( + payment_id CHAR(36) NOT NULL, + currency_id VARCHAR(96) NOT NULL, + denomination DECIMAL(20,2) NOT NULL, + source_status VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + reserved_at TIMESTAMP NOT NULL, + transferred_at TIMESTAMP NULL, + PRIMARY KEY (payment_id, currency_id), + UNIQUE KEY uk_payment_reserved_currency (currency_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (currency_id) REFERENCES commercial_bank_currency(currency_id) +); + +CREATE TABLE IF NOT EXISTS payment_ownership_transfer ( + transaction_id VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + from_wallet_id VARCHAR(96) NOT NULL, + to_wallet_id VARCHAR(96) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + coin_count INT NOT NULL, + settlement_digest CHAR(64) NOT NULL, + central_bank_signature VARCHAR(512) NOT NULL, + status VARCHAR(32) NOT NULL, + confirmed_at TIMESTAMP NOT NULL, + confirmed_by VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_clearing_record ( + clearing_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL UNIQUE, + payer_bank_code VARCHAR(32) NOT NULL, + payee_bank_code VARCHAR(32) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + status VARCHAR(32) NOT NULL, + settled_at TIMESTAMP NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_wallet_ledger ( + ledger_no VARCHAR(128) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + wallet_id VARCHAR(96) NOT NULL, + direction VARCHAR(16) NOT NULL, + amount DECIMAL(20,2) NOT NULL, + balance_after DECIMAL(20,2) NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by VARCHAR(64) NOT NULL, + UNIQUE KEY uk_payment_wallet_ledger (payment_id, wallet_id), + FOREIGN KEY (payment_id) REFERENCES payment_order(id), + FOREIGN KEY (wallet_id) REFERENCES digital_wallet(wallet_id) +); + +CREATE TABLE IF NOT EXISTS payment_notification ( + id CHAR(36) PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + recipient_user_id VARCHAR(36) NOT NULL, + recipient_wallet_id VARCHAR(96) NOT NULL, + notification_type VARCHAR(32) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE KEY uk_payment_notification (payment_id, notification_type), + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +); + +CREATE TABLE IF NOT EXISTS payment_step_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + payment_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + step_name VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL, + output_text TEXT NULL, + operated_at TIMESTAMP NOT NULL, + operator_user_id VARCHAR(36) NOT NULL, + operator_name VARCHAR(64) NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payment_order(id) +);