feat: align payment request and compliance actions

agent/payment-training-progress
chenyuan 2 weeks ago
parent 47ff07c956
commit 754cb2620b

@ -7,12 +7,19 @@ public final class CreatePaymentCommand {
private final String payeeWalletId; private final String payeeWalletId;
private final BigDecimal amount; private final BigDecimal amount;
private final String note; private final String note;
private final String requestTimestamp;
public CreatePaymentCommand(String payerWalletId, String payeeWalletId, BigDecimal amount, String note) { public CreatePaymentCommand(String payerWalletId, String payeeWalletId, BigDecimal amount, String note) {
this.payerWalletId = payerWalletId; this.payeeWalletId = payeeWalletId; this.amount = amount; this.note = note; this(payerWalletId, payeeWalletId, amount, note, null);
}
public CreatePaymentCommand(String payerWalletId, String payeeWalletId, BigDecimal amount, String note,
String requestTimestamp) {
this.payerWalletId = payerWalletId; this.payeeWalletId = payeeWalletId;
this.amount = amount; this.note = note; this.requestTimestamp = requestTimestamp;
} }
public String getPayerWalletId() { return payerWalletId; } public String getPayerWalletId() { return payerWalletId; }
public String getPayeeWalletId() { return payeeWalletId; } public String getPayeeWalletId() { return payeeWalletId; }
public BigDecimal getAmount() { return amount; } public BigDecimal getAmount() { return amount; }
public String getNote() { return note; } public String getNote() { return note; }
public String getRequestTimestamp() { return requestTimestamp; }
} }

@ -5,10 +5,12 @@ import com.yau.digitalrmb.payment.application.command.CreatePaymentCommand;
import com.yau.digitalrmb.payment.application.query.PaymentOrderView; import com.yau.digitalrmb.payment.application.query.PaymentOrderView;
import com.yau.digitalrmb.payment.domain.model.CentralSettlementResult; import com.yau.digitalrmb.payment.domain.model.CentralSettlementResult;
import com.yau.digitalrmb.payment.domain.model.PaymentActor; 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.PaymentContext;
import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult; import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult;
import com.yau.digitalrmb.payment.domain.model.PaymentOrder; import com.yau.digitalrmb.payment.domain.model.PaymentOrder;
import com.yau.digitalrmb.payment.domain.model.PaymentOrderId; import com.yau.digitalrmb.payment.domain.model.PaymentOrderId;
import com.yau.digitalrmb.payment.domain.model.PaymentParticipant;
import com.yau.digitalrmb.payment.domain.model.PaymentStatus; import com.yau.digitalrmb.payment.domain.model.PaymentStatus;
import com.yau.digitalrmb.payment.domain.model.PayerBankProcessingResult; import com.yau.digitalrmb.payment.domain.model.PayerBankProcessingResult;
import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository; import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository;
@ -25,6 +27,7 @@ import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -51,6 +54,14 @@ public class PaymentApplicationService {
return resourceRepository.loadContext(actor, payeeWalletId); return resourceRepository.loadContext(actor, payeeWalletId);
} }
public PaymentParticipant payer(PaymentActor actor) {
return resourceRepository.loadPayer(actor);
}
public PaymentParticipant payee(String payeeWalletId) {
return resourceRepository.loadPayee(payeeWalletId);
}
@Transactional @Transactional
public PaymentOrderView create(CreatePaymentCommand command, PaymentActor actor) { public PaymentOrderView create(CreatePaymentCommand command, PaymentActor actor) {
if (command == null) throw validation("支付请求不能为空"); if (command == null) throw validation("支付请求不能为空");
@ -59,7 +70,8 @@ public class PaymentApplicationService {
if (!context.getPayer().getWalletId().equals(command.getPayerWalletId())) throw validation("付款钱包不属于当前用户"); if (!context.getPayer().getWalletId().equals(command.getPayerWalletId())) throw validation("付款钱包不属于当前用户");
Instant now = clock.instant(); Instant now = clock.instant();
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
String timestamp = TIMESTAMP.format(now); String timestamp = command.getRequestTimestamp() == null
? TIMESTAMP.format(now) : timestamp(command.getRequestTimestamp());
String paymentNo = "PAY_REQ_" + timestamp + "_" + id.toString().substring(0, 8).toUpperCase(); String paymentNo = "PAY_REQ_" + timestamp + "_" + id.toString().substring(0, 8).toUpperCase();
String note = command.getNote() == null ? "" : command.getNote().trim(); String note = command.getNote() == null ? "" : command.getNote().trim();
String original = "PAY|" + context.getPayer().getWalletId() + "|" + context.getPayee().getWalletId() + "|" + String original = "PAY|" + context.getPayer().getWalletId() + "|" + context.getPayee().getWalletId() + "|" +
@ -81,6 +93,77 @@ public class PaymentApplicationService {
return view(order, actor); return view(order, actor);
} }
public PaymentOrderView verifyPayerSignature(UUID id, PaymentActor actor) {
PaymentOrder order = owned(id, actor);
if (order.getPayerSignature() == null || !resourceRepository.verifyWalletSignature(
order.getPayerWalletId(), order.getPaymentDigest(), order.getPayerSignature())) {
throw validation("商业银行A验证付款钱包SM2签名失败");
}
return view(order, actor);
}
@Transactional
public PaymentOrderView lockComplianceResources(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() == PaymentStatus.COMPLIANCE_LOCKED
|| order.getStatus().ordinal() > PaymentStatus.COMPLIANCE_LOCKED.ordinal()) {
return view(order, actor);
}
if (order.getStatus() != PaymentStatus.PAYER_SIGNED) {
throw validation("请先完成付款钱包签名");
}
if (!resourceRepository.verifyWalletSignature(order.getPayerWalletId(),
order.getPaymentDigest(), order.getPayerSignature())) {
throw validation("商业银行A验证付款钱包SM2签名失败");
}
List<PaymentCoin> coins = resourceRepository.lockForCompliance(order, actor);
order.lockForCompliance(coins.size());
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView generateComplianceReport(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.COMPLIANCE_LOCKED) {
if (order.getStatus().ordinal() > PaymentStatus.COMPLIANCE_LOCKED.ordinal()) return view(order, actor);
throw validation("请先完成合规检查和资源锁定");
}
List<PaymentCoin> coins = resourceRepository.findCoins(order);
String report = "COMPLIANCE|" + order.getPaymentNo() + "|" + order.getPayerWalletId() + "|" +
order.getPayeeWalletId() + "|" + order.getAmount().toPlainString() + "|" +
order.getPaymentDigest() + "|SIGNATURE_VALID|CONTRACT_VALID|" + coins.size() + "|" +
order.getPayerBankCode();
order.recordComplianceReport(report);
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView generateComplianceDigest(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.COMPLIANCE_LOCKED || order.getComplianceReport() == null) {
throw validation("请先生成合规报告");
}
order.recordComplianceDigest(cryptography.sm3(order.getComplianceReport()));
orderRepository.save(order, actor.getUsername());
return view(order, actor);
}
@Transactional
public PaymentOrderView signCompliance(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != PaymentStatus.COMPLIANCE_LOCKED || order.getComplianceDigest() == null) {
throw validation("请先生成合规报告摘要");
}
order.acceptByPayerBankSignature(resourceRepository.signComplianceDigest(actor,
order.getComplianceDigest()));
orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "03", "商业银行A预处理",
order.getComplianceReport(), actor);
return view(order, actor);
}
@Transactional @Transactional
public PaymentOrderView processPayerBank(UUID id, PaymentActor actor) { public PaymentOrderView processPayerBank(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor); PaymentOrder order = ownedForUpdate(id, actor);
@ -95,6 +178,20 @@ public class PaymentApplicationService {
return view(order, actor); return view(order, actor);
} }
@Transactional
public void cancel(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor);
if (order.getStatus().ordinal() >= PaymentStatus.CENTRAL_SETTLED.ordinal()) {
throw validation("中央银行已完成权属转移,不能取消支付实验");
}
if (order.getStatus() == PaymentStatus.COMPLIANCE_LOCKED
|| order.getStatus() == PaymentStatus.PAYER_BANK_ACCEPTED) {
resourceRepository.releasePaymentResources(order, actor);
order.releasePaymentResources();
orderRepository.save(order, actor.getUsername());
}
}
@Transactional @Transactional
public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) { public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) {
PaymentOrder order = ownedForUpdate(id, actor); PaymentOrder order = ownedForUpdate(id, actor);
@ -170,6 +267,19 @@ public class PaymentApplicationService {
return normalized; return normalized;
} catch (ArithmeticException exception) { throw validation("支付金额最多保留两位小数"); } } catch (ArithmeticException exception) { throw validation("支付金额最多保留两位小数"); }
} }
private PaymentOrder owned(UUID id, PaymentActor actor) {
PaymentOrder order = order(id);
try { order.requireOwnedBy(actor.getUserId()); }
catch (SecurityException exception) { throw forbidden(); }
return order;
}
private String timestamp(String value) {
if (value == null || !value.matches("\\d{14}")) {
throw validation("请求时间戳格式必须为yyyyMMddHHmmss");
}
return value;
}
private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); }
private BusinessException forbidden() { return new BusinessException(ErrorCode.FORBIDDEN, "无权访问该支付订单"); } private BusinessException forbidden() { return new BusinessException(ErrorCode.FORBIDDEN, "无权访问该支付订单"); }
} }

@ -0,0 +1,38 @@
package com.yau.digitalrmb.payment.application.service;
import com.yau.digitalrmb.payment.domain.model.PaymentActor;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.attempt.application.AttemptCancellationHandler;
import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class PaymentAttemptCancellationHandler implements AttemptCancellationHandler {
private final PaymentApplicationService payments;
public PaymentAttemptCancellationHandler(PaymentApplicationService payments) {
this.payments = payments;
}
@Override
public ExperimentModule module() {
return ExperimentModule.PAYMENT;
}
@Override
public void cancel(ExperimentAttempt attempt, ExperimentSubject subject) {
if (attempt.getBusinessId() == null) return;
try {
payments.cancel(UUID.fromString(attempt.getBusinessId()),
new PaymentActor(subject.getUserId(), subject.getUserId(),
subject.getSchoolId(), subject.getClassId()));
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "支付实验绑定的业务标识无效");
}
}
}

@ -0,0 +1,237 @@
package com.yau.digitalrmb.payment.application.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.payment.application.command.CreatePaymentCommand;
import com.yau.digitalrmb.payment.application.query.PaymentOrderView;
import com.yau.digitalrmb.payment.domain.model.PaymentActor;
import com.yau.digitalrmb.payment.domain.model.PaymentContext;
import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.attempt.application.ActionOutcome;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class PaymentTrainingActionService {
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
.withZone(ZoneOffset.UTC);
private static final List<String> ACTIONS = Arrays.asList(
"01:refresh-payer", "01:refresh-payee",
"02:generate-timestamp", "02:concatenate-payment", "02:generate-digest",
"02:wallet-sign", "02:package-payment", "02:send-payment",
"03:verify-payer-signature", "03:compliance-check", "03:generate-compliance-report",
"03:generate-compliance-digest", "03:bank-sign", "03:package-bank-request",
"03:send-to-central-bank");
private final ExperimentAttemptService attempts;
private final PaymentApplicationService payments;
private final ObjectMapper objectMapper;
public PaymentTrainingActionService(ExperimentAttemptService attempts,
PaymentApplicationService payments,
ObjectMapper objectMapper) {
this.attempts = attempts;
this.payments = payments;
this.objectMapper = objectMapper;
}
public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode,
PaymentActionRequest request, ExperimentSubject subject,
PaymentActor actor) {
String key = stepCode + ":" + actionCode;
int index = ACTIONS.indexOf(key);
if (index < 0) throw validation("不支持的支付实验动作:" + key);
PaymentActionRequest input = request == null ? new PaymentActionRequest() : request;
ExperimentAttemptView attempt = attempts.detail(attemptId, subject);
requirePrevious(attempt, index);
UUID paymentId = businessId(attempt.getBusinessId());
if (index > ACTIONS.indexOf("02:generate-digest") && paymentId == null) {
throw validation("请先生成支付摘要");
}
String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2);
final UUID boundPaymentId = paymentId;
return attempts.execute(attemptId, subject, stepCode, actionCode,
fingerprint(key, input, subject), Object.class,
() -> run(key, input, attempt, boundPaymentId, actor), next[0], next[1]);
}
private ActionOutcome<Object> run(String key, PaymentActionRequest request,
ExperimentAttemptView attempt, UUID paymentId,
PaymentActor actor) {
switch (key) {
case "01:refresh-payer":
return completed(payments.payer(actor));
case "01:refresh-payee":
return completed(payments.payee(required(request.getPayeeWalletId(), "收款钱包标识")));
case "02:generate-timestamp":
return completed(value("timestamp", TIMESTAMP.format(Instant.now())));
case "02:concatenate-payment":
return completed(paymentSource(request, attempt, actor));
case "02:generate-digest": {
Map<String, Object> source = prior(attempt, "02", "concatenate-payment");
PaymentOrderView created = payments.create(new CreatePaymentCommand(
text(source, "payerWalletId"), text(source, "payeeWalletId"),
decimal(source, "amount"), text(source, "note"), text(source, "timestamp")), actor);
return ActionOutcome.completed(created, created.getId());
}
case "02:wallet-sign":
return completed(payments.sign(paymentId, actor));
case "02:package-payment":
case "02:send-payment":
case "03:package-bank-request":
case "03:send-to-central-bank":
return completed(payments.get(paymentId, actor));
case "03:verify-payer-signature":
return completed(payments.verifyPayerSignature(paymentId, actor));
case "03:compliance-check":
return completed(payments.lockComplianceResources(paymentId, actor));
case "03:generate-compliance-report":
return completed(payments.generateComplianceReport(paymentId, actor));
case "03:generate-compliance-digest":
return completed(payments.generateComplianceDigest(paymentId, actor));
case "03:bank-sign":
return completed(payments.signCompliance(paymentId, actor));
default:
throw validation("不支持的支付实验动作:" + key);
}
}
private Map<String, Object> paymentSource(PaymentActionRequest request,
ExperimentAttemptView attempt,
PaymentActor actor) {
String payeeWalletId = required(request.getPayeeWalletId(), "收款钱包标识");
PaymentContext context = payments.context(payeeWalletId, actor);
if (request.getPayerWalletId() != null
&& !context.getPayer().getWalletId().equals(request.getPayerWalletId().trim())) {
throw validation("付款钱包不属于当前用户");
}
BigDecimal amount = money(request.getAmount());
String note = request.getNote() == null ? "" : request.getNote().trim();
if (note.length() > 256) throw validation("支付备注最多256个字符");
String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp");
Map<String, Object> output = new LinkedHashMap<String, Object>();
output.put("payerWalletId", context.getPayer().getWalletId());
output.put("payeeWalletId", context.getPayee().getWalletId());
output.put("amount", amount.toPlainString());
output.put("note", note);
output.put("timestamp", timestamp);
output.put("sourceText", "PAY|" + context.getPayer().getWalletId() + "|"
+ context.getPayee().getWalletId() + "|" + amount.toPlainString() + "|"
+ note + "|" + timestamp);
return output;
}
private void requirePrevious(ExperimentAttemptView attempt, int index) {
if (index == 0) return;
String[] previous = ACTIONS.get(index - 1).split(":", 2);
for (ExperimentActionView<?> action : attempt.getActions()) {
if (previous[0].equals(action.getStepCode()) && previous[1].equals(action.getActionCode())
&& action.getActionStatus() == ActionStatus.COMPLETED) return;
}
throw validation("请先完成动作:" + ACTIONS.get(index - 1));
}
@SuppressWarnings("unchecked")
private Map<String, Object> prior(ExperimentAttemptView attempt, String step, String actionCode) {
for (ExperimentActionView<?> action : attempt.getActions()) {
if (step.equals(action.getStepCode()) && actionCode.equals(action.getActionCode())
&& action.getActionStatus() == ActionStatus.COMPLETED
&& action.getOutput() instanceof Map) {
return (Map<String, Object>) action.getOutput();
}
}
throw validation("缺少前置动作结果:" + step + ":" + actionCode);
}
private Map<String, Object> value(String name, Object value) {
Map<String, Object> output = new LinkedHashMap<String, Object>();
output.put(name, value);
return output;
}
private ActionOutcome<Object> completed(Object output) {
return ActionOutcome.completed(output);
}
private UUID businessId(String value) {
if (value == null || value.trim().isEmpty()) return null;
try {
return UUID.fromString(value);
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "支付实验绑定的业务标识无效");
}
}
private String text(Map<String, Object> values, String name) {
Object value = values.get(name);
if (value == null) throw validation("缺少派生参数:" + name);
return value.toString();
}
private BigDecimal decimal(Map<String, Object> values, String name) {
try {
return new BigDecimal(text(values, name));
} catch (NumberFormatException exception) {
throw validation("派生金额无效:" + name);
}
}
private BigDecimal money(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 String required(String value, String name) {
if (value == null || value.trim().isEmpty()) throw validation(name + "不能为空");
return value.trim();
}
private String fingerprint(String key, PaymentActionRequest request, ExperimentSubject subject) {
Map<String, Object> values = new LinkedHashMap<String, Object>();
values.put("action", key);
values.put("payerWalletId", request.getPayerWalletId());
values.put("payeeWalletId", request.getPayeeWalletId());
values.put("amount", request.getAmount());
values.put("note", request.getNote());
values.put("userId", subject.getUserId());
try {
byte[] json = objectMapper.writeValueAsString(values).getBytes(StandardCharsets.UTF_8);
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json);
StringBuilder result = new StringBuilder();
for (byte value : digest) result.append(String.format("%02x", value & 0xff));
return result.toString();
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "无法生成动作幂等指纹");
}
}
private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
}
}

@ -85,10 +85,42 @@ public final class PaymentOrder {
} }
public void sign(String signature) { require(PaymentStatus.REQUEST_PREPARED); payerSignature = required(signature); status = PaymentStatus.PAYER_SIGNED; } public void sign(String signature) { require(PaymentStatus.REQUEST_PREPARED); payerSignature = required(signature); status = PaymentStatus.PAYER_SIGNED; }
public void lockForCompliance(int selectedCoinCount) {
require(PaymentStatus.PAYER_SIGNED);
if (selectedCoinCount <= 0) throw new IllegalArgumentException("coin count must be positive");
coinCount = selectedCoinCount;
status = PaymentStatus.COMPLIANCE_LOCKED;
}
public void recordComplianceReport(String report) {
require(PaymentStatus.COMPLIANCE_LOCKED);
complianceReport = required(report);
}
public void recordComplianceDigest(String digest) {
require(PaymentStatus.COMPLIANCE_LOCKED);
complianceDigest = required(digest);
}
public void acceptByPayerBankSignature(String signature) {
require(PaymentStatus.COMPLIANCE_LOCKED);
complianceReport = required(complianceReport);
complianceDigest = required(complianceDigest);
payerBankSignature = required(signature);
status = PaymentStatus.PAYER_BANK_ACCEPTED;
}
public void acceptByPayerBank(String report, String digest, String signature, int selectedCoinCount) { 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"); lockForCompliance(selectedCoinCount);
complianceReport = required(report); complianceDigest = required(digest); payerBankSignature = required(signature); recordComplianceReport(report);
coinCount = selectedCoinCount; status = PaymentStatus.PAYER_BANK_ACCEPTED; recordComplianceDigest(digest);
acceptByPayerBankSignature(signature);
}
public void releasePaymentResources() {
if (status != PaymentStatus.COMPLIANCE_LOCKED && status != PaymentStatus.PAYER_BANK_ACCEPTED) {
throw new IllegalStateException("payment resources are not locked");
}
complianceReport = null;
complianceDigest = null;
payerBankSignature = null;
coinCount = 0;
status = PaymentStatus.PAYER_SIGNED;
} }
public void settle(String transactionId, String clearingNo, String originalText, String digest, String signature) { 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); require(PaymentStatus.PAYER_BANK_ACCEPTED); this.transactionId = required(transactionId); this.clearingNo = required(clearingNo);

@ -3,6 +3,7 @@ package com.yau.digitalrmb.payment.domain.model;
public enum PaymentStatus { public enum PaymentStatus {
REQUEST_PREPARED, REQUEST_PREPARED,
PAYER_SIGNED, PAYER_SIGNED,
COMPLIANCE_LOCKED,
PAYER_BANK_ACCEPTED, PAYER_BANK_ACCEPTED,
CENTRAL_SETTLED, CENTRAL_SETTLED,
PAYEE_CREDITED, PAYEE_CREDITED,

@ -7,13 +7,19 @@ import com.yau.digitalrmb.payment.domain.model.PaymentContext;
import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult; import com.yau.digitalrmb.payment.domain.model.PaymentCreditResult;
import com.yau.digitalrmb.payment.domain.model.PaymentNotification; import com.yau.digitalrmb.payment.domain.model.PaymentNotification;
import com.yau.digitalrmb.payment.domain.model.PaymentOrder; 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.model.PayerBankProcessingResult;
import java.util.List; import java.util.List;
public interface PaymentResourceRepository { public interface PaymentResourceRepository {
PaymentParticipant loadPayer(PaymentActor actor);
PaymentParticipant loadPayee(String payeeWalletId);
PaymentContext loadContext(PaymentActor actor, String payeeWalletId); PaymentContext loadContext(PaymentActor actor, String payeeWalletId);
String signWithWallet(String walletId, String digest); String signWithWallet(String walletId, String digest);
boolean verifyWalletSignature(String walletId, String digest, String signature); boolean verifyWalletSignature(String walletId, String digest, String signature);
List<PaymentCoin> lockForCompliance(PaymentOrder order, PaymentActor actor);
void releasePaymentResources(PaymentOrder order, PaymentActor actor);
String signComplianceDigest(PaymentActor actor, String digest);
PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor); PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor);
CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor); CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor);
PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor); PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor);

@ -48,12 +48,21 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
} }
@Override @Override
public PaymentContext loadContext(PaymentActor actor, String payeeWalletId) { public PaymentParticipant loadPayer(PaymentActor actor) {
WalletPrerequisiteReference payerReference = walletProjection.ensureForSubject( WalletPrerequisiteReference reference = walletProjection.referenceForSubject(
new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId())); new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId()));
WalletPrerequisiteReference payeeReference = walletProjection.ensureForWallet(payeeWalletId); return loadParticipant(reference);
PaymentParticipant payer = loadParticipant(payerReference); }
PaymentParticipant payee = loadParticipant(payeeReference);
@Override
public PaymentParticipant loadPayee(String payeeWalletId) {
return loadParticipant(walletProjection.referenceForWallet(payeeWalletId));
}
@Override
public PaymentContext loadContext(PaymentActor actor, String payeeWalletId) {
PaymentParticipant payer = loadPayer(actor);
PaymentParticipant payee = loadPayee(payeeWalletId);
if (payer.getWalletId().equals(payee.getWalletId())) throw validation("付款钱包和收款钱包不能相同"); if (payer.getWalletId().equals(payee.getWalletId())) throw validation("付款钱包和收款钱包不能相同");
if (payer.getBankCode().equals(payee.getBankCode())) throw validation("本实验仅支持商业银行A向商业银行B的跨行支付"); if (payer.getBankCode().equals(payee.getBankCode())) throw validation("本实验仅支持商业银行A向商业银行B的跨行支付");
return new PaymentContext(payer, payee); return new PaymentContext(payer, payee);
@ -80,6 +89,14 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
@Override @Override
public PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor) { public PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor) {
List<PaymentCoin> coins = lockForCompliance(order, actor);
String report = complianceReport(order, coins);
String digest = cryptography.sm3(report);
return new PayerBankProcessingResult(report, digest, signComplianceDigest(actor, digest), coins);
}
@Override
public List<PaymentCoin> lockForCompliance(PaymentOrder order, PaymentActor actor) {
WalletSnapshot payer = lockWallet(order.getPayerWalletId()); WalletSnapshot payer = lockWallet(order.getPayerWalletId());
if (!"ACTIVE".equals(payer.status) || payer.balance.subtract(payer.frozenAmount).compareTo(order.getAmount()) < 0) { if (!"ACTIVE".equals(payer.status) || payer.balance.subtract(payer.frozenAmount).compareTo(order.getAmount()) < 0) {
throw validation("付款钱包可用余额不足或状态异常"); throw validation("付款钱包可用余额不足或状态异常");
@ -90,14 +107,35 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
"WHERE wallet_id=? AND balance-frozen_amount>=?", "WHERE wallet_id=? AND balance-frozen_amount>=?",
order.getAmount(), order.getPayerWalletId(), order.getAmount()); order.getAmount(), order.getPayerWalletId(), order.getAmount());
if (frozen != 1) throw validation("付款钱包可用余额已变化,请重新发起支付"); if (frozen != 1) throw validation("付款钱包可用余额已变化,请重新发起支付");
String report = "COMPLIANCE|" + order.getPaymentNo() + "|" + order.getPayerWalletId() + "|" + return coins;
order.getPayeeWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getPaymentDigest() + }
"|SIGNATURE_VALID|CONTRACT_VALID|" + coins.size() + "|" + order.getPayerBankCode();
String digest = cryptography.sm3(report); @Override
public void releasePaymentResources(PaymentOrder order, PaymentActor actor) {
List<PaymentCoin> coins = findCoins(order);
for (PaymentCoin coin : coins) {
if (!"RESERVED".equals(coin.getStatus())) continue;
if (!ownershipLocks.release(coin.getCurrencyId(), "WALLET", order.getPayerWalletId(),
"PAYMENT", order.getId().toString())) {
throw validation("付款币串锁定状态已变化:" + coin.getCurrencyId());
}
}
jdbc.update("DELETE FROM payment_coin_reservation WHERE payment_id=? AND status='RESERVED'",
order.getId().toString());
int released = jdbc.update("UPDATE digital_wallet SET frozen_amount=frozen_amount-?," +
"updated_at=CURRENT_TIMESTAMP WHERE wallet_id=? AND frozen_amount>=?",
order.getAmount(), order.getPayerWalletId(), order.getAmount());
if (released != 1) throw validation("付款钱包冻结资金释放失败");
}
@Override
public String signComplianceDigest(PaymentActor actor, String digest) {
InstitutionKeySubject subject = subject(actor); InstitutionKeySubject subject = subject(actor);
String signature = keyService.signCommercialBank(subject, digest); String signature = keyService.signCommercialBank(subject, digest);
if (!keyService.verifyCommercialBank(subject, digest, signature)) throw validation("商业银行A合规报告签名验证失败"); if (!keyService.verifyCommercialBank(subject, digest, signature)) {
return new PayerBankProcessingResult(report, digest, signature, coins); throw validation("商业银行A合规报告签名验证失败");
}
return signature;
} }
@Override @Override
@ -276,6 +314,12 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository
return results; return results;
} }
private String complianceReport(PaymentOrder order, List<PaymentCoin> coins) {
return "COMPLIANCE|" + order.getPaymentNo() + "|" + order.getPayerWalletId() + "|" +
order.getPayeeWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getPaymentDigest() +
"|SIGNATURE_VALID|CONTRACT_VALID|" + coins.size() + "|" + order.getPayerBankCode();
}
private void updatePayerUsage(PaymentOrder order) { private void updatePayerUsage(PaymentOrder order) {
List<ContractSnapshot> values = jdbc.query("SELECT daily_used_amount,daily_counter_date,annual_used_amount,annual_counter_year " + 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", "FROM wallet_contract WHERE wallet_id=? FOR UPDATE",

@ -0,0 +1,19 @@
package com.yau.digitalrmb.payment.interfaces.dto;
import java.math.BigDecimal;
public class PaymentActionRequest {
private String payerWalletId;
private String payeeWalletId;
private BigDecimal amount;
private String note;
public String getPayerWalletId() { return payerWalletId; }
public void setPayerWalletId(String payerWalletId) { this.payerWalletId = payerWalletId; }
public String getPayeeWalletId() { return payeeWalletId; }
public void setPayeeWalletId(String payeeWalletId) { this.payeeWalletId = payeeWalletId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getNote() { return note; }
public void setNote(String note) { this.note = note; }
}

@ -1,17 +1,22 @@
package com.yau.digitalrmb.payment.interfaces.rest; 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.query.PaymentOrderView;
import com.yau.digitalrmb.payment.application.service.PaymentApplicationService; import com.yau.digitalrmb.payment.application.service.PaymentApplicationService;
import com.yau.digitalrmb.payment.application.service.PaymentTrainingActionService;
import com.yau.digitalrmb.payment.domain.model.PaymentActor; import com.yau.digitalrmb.payment.domain.model.PaymentActor;
import com.yau.digitalrmb.payment.domain.model.PaymentContext; import com.yau.digitalrmb.payment.domain.model.PaymentContext;
import com.yau.digitalrmb.payment.interfaces.dto.CreatePaymentRequest; import com.yau.digitalrmb.payment.interfaces.dto.CreatePaymentRequest;
import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest;
import com.yau.digitalrmb.security.application.CurrentUser; import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService; import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.web.TraceIdFilter; import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC; import org.slf4j.MDC;
@ -32,9 +37,13 @@ import java.util.UUID;
public class PaymentController { public class PaymentController {
private final PaymentApplicationService service; private final PaymentApplicationService service;
private final CurrentUserService currentUserService; private final CurrentUserService currentUserService;
private final ExperimentAttemptService attempts;
private final PaymentTrainingActionService actions;
public PaymentController(PaymentApplicationService service, CurrentUserService currentUserService) { public PaymentController(PaymentApplicationService service, CurrentUserService currentUserService,
ExperimentAttemptService attempts, PaymentTrainingActionService actions) {
this.service = service; this.currentUserService = currentUserService; this.service = service; this.currentUserService = currentUserService;
this.attempts = attempts; this.actions = actions;
} }
@GetMapping("/context") @GetMapping("/context")
@ -44,17 +53,44 @@ public class PaymentController {
@PostMapping @PostMapping
@Operation(summary = "用户支付数字货币:步骤二,发送支付请求") @Operation(summary = "用户支付数字货币:步骤二,发送支付请求")
public ApiResponse<PaymentOrderView> create(@Valid @RequestBody CreatePaymentRequest request) { public ApiResponse<PaymentOrderView> create(@Valid @RequestBody CreatePaymentRequest request) {
return ok(service.create(new CreatePaymentCommand(request.getPayerWalletId(), request.getPayeeWalletId(), CurrentUser user = currentUserService.getCurrentUser();
request.getAmount(), request.getNote()), actor())); ExperimentSubject subject = subject(user);
ExperimentAttemptView attempt = attempts.create(ExperimentModule.PAYMENT, subject);
PaymentActionRequest input = actionRequest(request);
run(attempt, "01", "refresh-payer", new PaymentActionRequest(), user);
run(attempt, "01", "refresh-payee", input, user);
run(attempt, "02", "generate-timestamp", new PaymentActionRequest(), user);
run(attempt, "02", "concatenate-payment", input, user);
run(attempt, "02", "generate-digest", new PaymentActionRequest(), user);
ExperimentAttemptView result = attempts.detail(attempt.getAttemptId(), subject);
return ok(service.get(UUID.fromString(result.getBusinessId()), actor(user)));
} }
@PostMapping("/{id}/sign") @PostMapping("/{id}/sign")
@Operation(summary = "用户支付数字货币:步骤二,使用付款钱包私钥签名支付请求") @Operation(summary = "用户支付数字货币:步骤二,使用付款钱包私钥签名支付请求")
public ApiResponse<PaymentOrderView> sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); } public ApiResponse<PaymentOrderView> sign(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.sign(id, actor(user)));
run(attempt, "02", "wallet-sign", new PaymentActionRequest(), user);
run(attempt, "02", "package-payment", new PaymentActionRequest(), user);
run(attempt, "02", "send-payment", new PaymentActionRequest(), user);
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/payer-bank-process") @PostMapping("/{id}/payer-bank-process")
@Operation(summary = "用户支付数字货币步骤三商业银行A验签、合规校验并冻结数字货币") @Operation(summary = "用户支付数字货币步骤三商业银行A验签、合规校验并冻结数字货币")
public ApiResponse<PaymentOrderView> payerBankProcess(@PathVariable UUID id) { return ok(service.processPayerBank(id, actor())); } public ApiResponse<PaymentOrderView> payerBankProcess(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.processPayerBank(id, actor(user)));
String[] actionCodes = {"verify-payer-signature", "compliance-check", "generate-compliance-report",
"generate-compliance-digest", "bank-sign", "package-bank-request", "send-to-central-bank"};
for (String actionCode : actionCodes) {
run(attempt, "03", actionCode, new PaymentActionRequest(), user);
}
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/central-bank-settle") @PostMapping("/{id}/central-bank-settle")
@Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算") @Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算")
@ -76,13 +112,47 @@ public class PaymentController {
@Operation(summary = "查询当前用户的支付回执或到账通知") @Operation(summary = "查询当前用户的支付回执或到账通知")
public ApiResponse<PaymentOrderView> notificationView(@PathVariable UUID id) { return ok(service.notifications(id, actor())); } public ApiResponse<PaymentOrderView> notificationView(@PathVariable UUID id) { return ok(service.notifications(id, actor())); }
private PaymentActor actor() { private PaymentActor actor() { return actor(currentUserService.getCurrentUser()); }
CurrentUser user = currentUserService.getCurrentUser();
private PaymentActor actor(CurrentUser user) {
try { return new PaymentActor(user.getUserId(), user.getName(), Long.parseLong(user.getSchoolId()), Long.parseLong(user.getClassId())); } try { return new PaymentActor(user.getUserId(), user.getName(), Long.parseLong(user.getSchoolId()), Long.parseLong(user.getClassId())); }
catch (RuntimeException exception) { catch (RuntimeException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户缺少支付实验所需的用户、学校或班级信息"); throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户缺少支付实验所需的用户、学校或班级信息");
} }
} }
private ExperimentSubject subject(CurrentUser user) {
try {
return new ExperimentSubject(user.getUserId(), Long.parseLong(user.getSchoolId()),
Long.parseLong(user.getClassId()), "");
} catch (RuntimeException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户缺少支付实验所需的用户、学校或班级信息");
}
}
private void run(ExperimentAttemptView attempt, String step, String action,
PaymentActionRequest request, CurrentUser user) {
actions.execute(attempt.getAttemptId(), step, action, request, subject(user), actor(user));
}
private ExperimentAttemptView matchingAttempt(UUID paymentId, CurrentUser user) {
try {
ExperimentAttemptView attempt = attempts.current(ExperimentModule.PAYMENT, subject(user));
return paymentId.toString().equals(attempt.getBusinessId()) ? attempt : null;
} catch (BusinessException exception) {
if (exception.getErrorCode() == ErrorCode.RESOURCE_NOT_FOUND) return null;
throw exception;
}
}
private PaymentActionRequest actionRequest(CreatePaymentRequest source) {
PaymentActionRequest request = new PaymentActionRequest();
request.setPayerWalletId(source.getPayerWalletId());
request.setPayeeWalletId(source.getPayeeWalletId());
request.setAmount(source.getAmount());
request.setNote(source.getNote());
return request;
}
private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
} }

@ -0,0 +1,110 @@
package com.yau.digitalrmb.payment.interfaces.rest;
import com.yau.digitalrmb.payment.application.service.PaymentTrainingActionService;
import com.yau.digitalrmb.payment.domain.model.PaymentActor;
import com.yau.digitalrmb.payment.interfaces.dto.PaymentActionRequest;
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 com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import 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.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/payment/attempts")
@Tag(name = "数字货币支付实验动作", description = "模块五可恢复实验及前端独立按钮动作")
public class PaymentTrainingAttemptController {
private final ExperimentAttemptService attempts;
private final PaymentTrainingActionService actions;
private final CurrentUserService users;
public PaymentTrainingAttemptController(ExperimentAttemptService attempts,
PaymentTrainingActionService actions,
CurrentUserService users) {
this.attempts = attempts;
this.actions = actions;
this.users = users;
}
@PostMapping
@Operation(summary = "开始新的支付实验")
public ApiResponse<ExperimentAttemptView> create() {
return ok(attempts.create(ExperimentModule.PAYMENT, subject(users.getCurrentUser())));
}
@GetMapping("/current")
@Operation(summary = "恢复当前支付实验")
public ApiResponse<ExperimentAttemptView> current() {
return ok(attempts.current(ExperimentModule.PAYMENT, subject(users.getCurrentUser())));
}
@GetMapping("/{attemptId}")
@Operation(summary = "查询支付实验详情和已保存动作")
public ApiResponse<ExperimentAttemptView> detail(@PathVariable UUID attemptId) {
return ok(attempts.detail(attemptId, subject(users.getCurrentUser())));
}
@PostMapping("/{attemptId}/cancel")
@Operation(summary = "取消支付实验")
public ApiResponse<ExperimentAttemptView> cancel(@PathVariable UUID attemptId) {
return ok(attempts.cancel(attemptId, subject(users.getCurrentUser())));
}
@PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}")
@Operation(summary = "执行一个支付实验按钮动作")
public ApiResponse<ExperimentActionView<?>> execute(@PathVariable UUID attemptId,
@PathVariable String stepCode,
@PathVariable String actionCode,
@RequestBody(required = false) PaymentActionRequest request) {
CurrentUser user = users.getCurrentUser();
return ok(actions.execute(attemptId, stepCode, actionCode,
request == null ? new PaymentActionRequest() : request, subject(user), actor(user)));
}
private ExperimentSubject subject(CurrentUser user) {
return new ExperimentSubject(required(user.getUserId(), "用户 ID"),
numeric(user.getSchoolId(), "学校 ID"), numeric(user.getClassId(), "班级 ID"), "");
}
private PaymentActor actor(CurrentUser user) {
return new PaymentActor(required(user.getUserId(), "用户 ID"), required(user.getName(), "用户姓名"),
numeric(user.getSchoolId(), "学校 ID"), numeric(user.getClassId(), "班级 ID"));
}
private long numeric(String value, String name) {
try {
long result = Long.parseLong(required(value, name));
if (result <= 0) throw new NumberFormatException(name);
return result;
} catch (NumberFormatException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据中的" + name + "无效");
}
}
private String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据缺少" + name);
}
return value.trim();
}
private <T> ApiResponse<T> ok(T data) {
return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -42,6 +42,14 @@ public class WalletPrerequisiteProjectionService {
source.walletId, institution.bankCode, institution.organizationId); source.walletId, institution.bankCode, institution.organizationId);
} }
@Transactional(readOnly = true)
public WalletPrerequisiteReference referenceForSubject(InstitutionKeySubject subject) {
SourceWallet source = loadCompletedSource(subject);
Institution institution = loadInstitution(subject);
return new WalletPrerequisiteReference(source.userId, source.schoolId, source.classId,
source.walletId, institution.bankCode, institution.organizationId);
}
@Transactional @Transactional
public WalletPrerequisiteReference ensureForWallet(String walletId) { public WalletPrerequisiteReference ensureForWallet(String walletId) {
if (blank(walletId)) throw prerequisite(); if (blank(walletId)) throw prerequisite();
@ -56,6 +64,20 @@ public class WalletPrerequisiteProjectionService {
return ensureForSubject(subjects.get(0)); return ensureForSubject(subjects.get(0));
} }
@Transactional(readOnly = true)
public WalletPrerequisiteReference referenceForWallet(String walletId) {
if (blank(walletId)) throw prerequisite();
List<InstitutionKeySubject> subjects = jdbc.query(
"SELECT DISTINCT user_id,school_id,class_id FROM central_wallet_activation " +
"WHERE wallet_id=? AND wallet_activated=TRUE AND final_sent=TRUE " +
"AND cb_final_signature IS NOT NULL AND deleted=FALSE",
(rs, row) -> new InstitutionKeySubject(rs.getString("user_id"),
rs.getLong("school_id"), rs.getLong("class_id")), walletId.trim());
if (subjects.isEmpty()) throw prerequisite();
if (subjects.size() != 1) throw conflict("钱包标识对应多个实验主体");
return referenceForSubject(subjects.get(0));
}
private SourceWallet loadCompletedSource(InstitutionKeySubject subject) { private SourceWallet loadCompletedSource(InstitutionKeySubject subject) {
List<Activation> activations = jdbc.query( List<Activation> activations = jdbc.query(
"SELECT wallet_id,cb_final_signature,final_time FROM central_wallet_activation " + "SELECT wallet_id,cb_final_signature,final_time FROM central_wallet_activation " +

@ -106,6 +106,9 @@ class PaymentControllerTest {
@Test @Test
void completesTheSixPrototypeStepsAcrossTwoBanks() throws Exception { void completesTheSixPrototypeStepsAcrossTwoBanks() throws Exception {
int walletsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet", Integer.class);
int certificatesBefore = jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate", Integer.class);
int bindingsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding", Integer.class);
mockMvc.perform(get("/api/v1/payments/context").param("payeeWalletId", PAYEE_WALLET).with(payer())) mockMvc.perform(get("/api/v1/payments/context").param("payeeWalletId", PAYEE_WALLET).with(payer()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.code").value(200))
@ -113,8 +116,11 @@ class PaymentControllerTest {
.andExpect(jsonPath("$.data.payer.organizationId").value("ORG_PAYMENT_A")) .andExpect(jsonPath("$.data.payer.organizationId").value("ORG_PAYMENT_A"))
.andExpect(jsonPath("$.data.payee.walletId").value(PAYEE_WALLET)) .andExpect(jsonPath("$.data.payee.walletId").value(PAYEE_WALLET))
.andExpect(jsonPath("$.data.payee.organizationId").value("ORG_PAYMENT_B")); .andExpect(jsonPath("$.data.payee.organizationId").value("ORG_PAYMENT_B"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id=?", Integer.class, PAYEE_WALLET)) assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet", Integer.class)).isEqualTo(walletsBefore);
.isEqualTo(1); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate", Integer.class))
.isEqualTo(certificatesBefore);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding", Integer.class))
.isEqualTo(bindingsBefore);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_order", Integer.class)).isZero(); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_order", Integer.class)).isZero();
String created = mockMvc.perform(post("/api/v1/payments").with(payer()).contentType(MediaType.APPLICATION_JSON) 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\":\"实验支付\"}")) .content("{\"payerWalletId\":\"" + PAYER_WALLET + "\",\"payeeWalletId\":\"" + PAYEE_WALLET + "\",\"amount\":200.00,\"note\":\"实验支付\"}"))
@ -128,6 +134,14 @@ class PaymentControllerTest {
mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer())) 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(status().isOk()).andExpect(jsonPath("$.data.status").value("PAYER_BANK_ACCEPTED"))
.andExpect(jsonPath("$.data.coinCount").value(2)); .andExpect(jsonPath("$.data.coinCount").value(2));
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00");
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("200.00");
assertThat(jdbc.queryForObject("SELECT status FROM central_bank_currency_ownership " +
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo("PAYMENT_LOCKED");
assertThat(jdbc.queryForObject("SELECT lock_business_id FROM central_bank_currency_ownership " +
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo(id);
mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer())) mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CENTRAL_SETTLED")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CENTRAL_SETTLED"))
.andExpect(jsonPath("$.data.clearingNo").value(org.hamcrest.Matchers.startsWith("ACS_PAY_REQ_"))); .andExpect(jsonPath("$.data.clearingNo").value(org.hamcrest.Matchers.startsWith("ACS_PAY_REQ_")));
@ -213,6 +227,33 @@ class PaymentControllerTest {
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo(secondId); "WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo(secondId);
} }
@Test
void cancellingAfterComplianceReleasesFundsCoinsAndReservations() throws Exception {
String id = createSignedPayment(PAYER_WALLET, PAYEE_WALLET, payer());
mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PAYER_BANK_ACCEPTED"));
String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt " +
"WHERE module_code='PAYMENT' AND business_id=? ORDER BY attempt_no DESC LIMIT 1", String.class, id);
mockMvc.perform(post("/api/v1/payment/attempts/{id}/cancel", attemptId).with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.attemptStatus").value("CANCELLED"));
mockMvc.perform(get("/api/v1/payments/{id}", id).with(payer()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("PAYER_SIGNED"))
.andExpect(jsonPath("$.data.coinCount").value(0));
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?",
BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM central_bank_currency_ownership " +
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isEqualTo("AVAILABLE");
assertThat(jdbc.queryForObject("SELECT lock_business_id FROM central_bank_currency_ownership " +
"WHERE currency_id='DC_PAYMENT_1'", String.class)).isNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_coin_reservation WHERE payment_id=?",
Integer.class, id)).isZero();
}
private String createSignedPayment(String payerWalletId, String payeeWalletId, private String createSignedPayment(String payerWalletId, String payeeWalletId,
RequestPostProcessor actor) throws Exception { RequestPostProcessor actor) throws Exception {
String created = mockMvc.perform(post("/api/v1/payments").with(actor).contentType(MediaType.APPLICATION_JSON) String created = mockMvc.perform(post("/api/v1/payments").with(actor).contentType(MediaType.APPLICATION_JSON)
@ -259,6 +300,10 @@ class PaymentControllerTest {
} }
private void cleanPaymentData() { private void cleanPaymentData() {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN (SELECT id FROM " +
"training_experiment_attempt WHERE module_code='PAYMENT' AND user_id IN (?,?))", PAYER_USER, PAYEE_USER);
jdbc.update("DELETE FROM training_experiment_attempt WHERE module_code='PAYMENT' AND user_id IN (?,?)",
PAYER_USER, PAYEE_USER);
jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? AND module_code='CURRENCY_PAYMENT'", PAYER_USER); 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_step_log");
jdbc.update("DELETE FROM payment_notification"); jdbc.update("DELETE FROM payment_notification");

@ -0,0 +1,84 @@
package com.yau.digitalrmb.payment.interfaces.rest;
import com.jayway.jsonpath.JsonPath;
import com.yau.digitalrmb.payment.application.service.PaymentTrainingActionService;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
import com.yau.digitalrmb.training.attempt.domain.AttemptStatus;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Collections;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
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
@AutoConfigureMockMvc
@ActiveProfiles("test")
class PaymentTrainingAttemptControllerTest {
private static final String USER_ID = "00000000-0000-0000-0000-000000000489";
@Autowired private MockMvc mockMvc;
@MockBean private PaymentTrainingActionService actions;
@MockBean private CurrentUserService users;
@Test
void exposesEveryPaymentActionThroughComplianceProcessing() throws Exception {
when(users.getCurrentUser()).thenReturn(user());
String created = mockMvc.perform(post("/api/v1/payment/attempts").with(jwtForUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.moduleCode").value("PAYMENT"))
.andReturn().getResponse().getContentAsString();
UUID attemptId = UUID.fromString(JsonPath.read(created, "$.data.attemptId"));
when(actions.execute(any(UUID.class), anyString(), anyString(), any(), any(), any()))
.thenAnswer(invocation -> new ExperimentActionView<Object>(invocation.getArgument(0),
ExperimentModule.PAYMENT, invocation.getArgument(1), invocation.getArgument(2),
ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, invocation.getArgument(1),
invocation.getArgument(2), Instant.now(), Collections.singletonMap("safe", true)));
String[][] actionCodes = {
{"01", "refresh-payer"}, {"01", "refresh-payee"},
{"02", "generate-timestamp"}, {"02", "concatenate-payment"},
{"02", "generate-digest"}, {"02", "wallet-sign"},
{"02", "package-payment"}, {"02", "send-payment"},
{"03", "verify-payer-signature"}, {"03", "compliance-check"},
{"03", "generate-compliance-report"}, {"03", "generate-compliance-digest"},
{"03", "bank-sign"}, {"03", "package-bank-request"},
{"03", "send-to-central-bank"}
};
for (String[] action : actionCodes) {
mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}",
attemptId, action[0], action[1]).with(jwtForUser())
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.stepCode").value(action[0]))
.andExpect(jsonPath("$.data.actionCode").value(action[1]));
}
}
private CurrentUser user() {
return new CurrentUser("3001", "延安大学", null, null, null, null, 4L,
USER_ID, "payment", "支付用户", "2001", "测试班", "payment");
}
private org.springframework.test.web.servlet.request.RequestPostProcessor jwtForUser() {
return jwt().jwt(token -> token.subject(USER_ID).claim("userId", USER_ID));
}
}
Loading…
Cancel
Save