新增用户支付与实训任务进度

agent/payment-training-progress
chenyuan 2 weeks ago
parent d252bdb043
commit 2da656b773

@ -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; }
}

@ -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<PaymentCoin> coins;
private final List<PaymentNotification> notifications;
private PaymentOrderView(PaymentOrder value, List<PaymentCoin> coins, List<PaymentNotification> 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.<PaymentCoin>emptyList() : coins;
this.notifications = notifications == null ? Collections.<PaymentNotification>emptyList() : notifications;
}
public static PaymentOrderView from(PaymentOrder value, List<PaymentCoin> coins, List<PaymentNotification> 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<PaymentCoin> getCoins() { return coins; } public List<PaymentNotification> getNotifications() { return notifications; }
}

@ -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, "无权访问该支付订单"); }
}

@ -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; }
}

@ -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<PaymentCoin> coins;
public PayerBankProcessingResult(String complianceReport, String complianceDigest, String bankSignature,
List<PaymentCoin> 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<PaymentCoin> getCoins() { return coins; }
}

@ -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();
}
}

@ -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; }
}

@ -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; }
}

@ -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; }
}

@ -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; }
}

@ -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; }
}

@ -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(); }
}

@ -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); }
}

@ -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
}

@ -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<PaymentOrder> findById(PaymentOrderId id);
Optional<PaymentOrder> findByIdForUpdate(PaymentOrderId id);
}

@ -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<PaymentCoin> findCoins(PaymentOrder order);
List<PaymentNotification> findNotifications(PaymentOrder order, String currentUserId);
void appendStepLog(PaymentOrder order, String stepCode, String stepName, String output, PaymentActor actor);
}

@ -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<PaymentOrder> findById(PaymentOrderId id) { return find(id, false); }
@Override public Optional<PaymentOrder> findByIdForUpdate(PaymentOrderId id) { return find(id, true); }
private Optional<PaymentOrder> find(PaymentOrderId id, boolean forUpdate) {
List<PaymentOrder> 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.<PaymentOrder>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(); }
}

@ -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<String> 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<String> 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<PaymentCoin> 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<PaymentCoin> 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<PaymentCoin> 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<PaymentNotification> 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<PaymentParticipant> 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<String> 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<WalletSnapshot> 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<ContractSnapshot> 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<PaymentCoin> selectAndLockCoins(PaymentOrder order, PaymentActor actor) {
List<CoinCandidate> 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<CoinCandidate> selected = new ArrayList<CoinCandidate>();
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<PaymentCoin> results = new ArrayList<PaymentCoin>();
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<ContractSnapshot> 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; }
}
}

@ -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; }
}

@ -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<PaymentContext> context(@RequestParam String payeeWalletId) { return ok(service.context(payeeWalletId, actor())); }
@PostMapping
@Operation(summary = "用户支付数字货币:步骤二,发送支付请求")
public ApiResponse<PaymentOrderView> 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<PaymentOrderView> sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); }
@PostMapping("/{id}/payer-bank-process")
@Operation(summary = "用户支付数字货币步骤三商业银行A验签、合规校验并冻结数字货币")
public ApiResponse<PaymentOrderView> payerBankProcess(@PathVariable UUID id) { return ok(service.processPayerBank(id, actor())); }
@PostMapping("/{id}/central-bank-settle")
@Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算")
public ApiResponse<PaymentOrderView> centralBankSettle(@PathVariable UUID id) { return ok(service.settleAtCentralBank(id, actor())); }
@PostMapping("/{id}/payee-bank-credit")
@Operation(summary = "用户支付数字货币步骤五商业银行B验签并向收款钱包入账")
public ApiResponse<PaymentOrderView> payeeBankCredit(@PathVariable UUID id) { return ok(service.creditPayee(id, actor())); }
@PostMapping("/{id}/notifications")
@Operation(summary = "用户支付数字货币:步骤六,生成付款回执和收款到账通知")
public ApiResponse<PaymentOrderView> notifications(@PathVariable UUID id) { return ok(service.createNotifications(id, actor())); }
@GetMapping("/{id}")
@Operation(summary = "查询用户支付数字货币实验订单和步骤结果")
public ApiResponse<PaymentOrderView> get(@PathVariable UUID id) { return ok(service.get(id, actor())); }
@GetMapping("/{id}/notifications")
@Operation(summary = "查询当前用户的支付回执或到账通知")
public ApiResponse<PaymentOrderView> 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 <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
}

@ -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<String, ModuleDefinition> definitions;
public TrainingTaskProgressService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
this.definitions = definitions();
}
public List<TrainingTaskView> all(String userId) {
List<TrainingTaskView> 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<String> 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<String> completed) {
List<TrainingStepView> 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<String, ModuleDefinition> definitions() {
Map<String, ModuleDefinition> 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<StepDefinition> 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<TrainingStepView> 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<StepDefinition> steps;
private ModuleDefinition(String code, String name, boolean unavailable, String unavailableReason, List<StepDefinition> 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; } }
}

@ -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}; }
}

@ -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/**"); }
}

@ -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<List<TrainingTaskProgressService.TrainingTaskView>> all() { return ok(service.all(userId())); }
@GetMapping("/{moduleCode}")
@Operation(summary = "查询当前用户单个实训任务进度")
public ApiResponse<TrainingTaskProgressService.TrainingTaskView> one(@PathVariable String moduleCode) { return ok(service.one(userId(), moduleCode)); }
private String userId() { return currentUserService.getCurrentUser().getUserId(); }
private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
}

@ -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)
);

@ -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;

@ -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)
);

@ -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"));
}
}

@ -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)
);

Loading…
Cancel
Save