From 754cb2620b02d58c6b0729b4de78708f6d6bda0f Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 18 Aug 2026 15:18:55 +0800 Subject: [PATCH] feat: align payment request and compliance actions --- .../command/CreatePaymentCommand.java | 9 +- .../service/PaymentApplicationService.java | 112 ++++++++- .../PaymentAttemptCancellationHandler.java | 38 +++ .../service/PaymentTrainingActionService.java | 237 ++++++++++++++++++ .../payment/domain/model/PaymentOrder.java | 38 ++- .../payment/domain/model/PaymentStatus.java | 1 + .../repository/PaymentResourceRepository.java | 6 + .../JdbcPaymentResourceRepository.java | 66 ++++- .../interfaces/dto/PaymentActionRequest.java | 19 ++ .../interfaces/rest/PaymentController.java | 86 ++++++- .../PaymentTrainingAttemptController.java | 110 ++++++++ .../WalletPrerequisiteProjectionService.java | 22 ++ .../rest/PaymentControllerTest.java | 49 +++- .../PaymentTrainingAttemptControllerTest.java | 84 +++++++ 14 files changed, 851 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/payment/application/service/PaymentAttemptCancellationHandler.java create mode 100644 src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java create mode 100644 src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java create mode 100644 src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptController.java create mode 100644 src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java diff --git a/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java b/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java index 9e44fd4..456986a 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/command/CreatePaymentCommand.java @@ -7,12 +7,19 @@ public final class CreatePaymentCommand { private final String payeeWalletId; private final BigDecimal amount; private final String note; + private final String requestTimestamp; 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 getPayeeWalletId() { return payeeWalletId; } public BigDecimal getAmount() { return amount; } public String getNote() { return note; } + public String getRequestTimestamp() { return requestTimestamp; } } diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java index 69fac22..4b0e14a 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java @@ -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.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.PaymentOrder; 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.PayerBankProcessingResult; import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository; @@ -25,6 +27,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.UUID; @Service @@ -51,6 +54,14 @@ public class PaymentApplicationService { return resourceRepository.loadContext(actor, payeeWalletId); } + public PaymentParticipant payer(PaymentActor actor) { + return resourceRepository.loadPayer(actor); + } + + public PaymentParticipant payee(String payeeWalletId) { + return resourceRepository.loadPayee(payeeWalletId); + } + @Transactional public PaymentOrderView create(CreatePaymentCommand command, PaymentActor actor) { if (command == null) throw validation("支付请求不能为空"); @@ -59,7 +70,8 @@ public class PaymentApplicationService { if (!context.getPayer().getWalletId().equals(command.getPayerWalletId())) throw validation("付款钱包不属于当前用户"); Instant now = clock.instant(); 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 note = command.getNote() == null ? "" : command.getNote().trim(); String original = "PAY|" + context.getPayer().getWalletId() + "|" + context.getPayee().getWalletId() + "|" + @@ -81,6 +93,77 @@ public class PaymentApplicationService { 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 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 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 public PaymentOrderView processPayerBank(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -95,6 +178,20 @@ public class PaymentApplicationService { 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 public PaymentOrderView settleAtCentralBank(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -170,6 +267,19 @@ public class PaymentApplicationService { return normalized; } 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 forbidden() { return new BusinessException(ErrorCode.FORBIDDEN, "无权访问该支付订单"); } } diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentAttemptCancellationHandler.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentAttemptCancellationHandler.java new file mode 100644 index 0000000..098e5b6 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentAttemptCancellationHandler.java @@ -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, "支付实验绑定的业务标识无效"); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java new file mode 100644 index 0000000..7a4c442 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java @@ -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 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 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 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 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 output = new LinkedHashMap(); + 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 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) action.getOutput(); + } + } + throw validation("缺少前置动作结果:" + step + ":" + actionCode); + } + + private Map value(String name, Object value) { + Map output = new LinkedHashMap(); + output.put(name, value); + return output; + } + + private ActionOutcome 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 values, String name) { + Object value = values.get(name); + if (value == null) throw validation("缺少派生参数:" + name); + return value.toString(); + } + + private BigDecimal decimal(Map 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 values = new LinkedHashMap(); + 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java index dec9e20..8a94a04 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentOrder.java @@ -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 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) { - 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; + lockForCompliance(selectedCoinCount); + recordComplianceReport(report); + 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) { require(PaymentStatus.PAYER_BANK_ACCEPTED); this.transactionId = required(transactionId); this.clearingNo = required(clearingNo); diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java index 5b3d11c..3e260f4 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/PaymentStatus.java @@ -3,6 +3,7 @@ package com.yau.digitalrmb.payment.domain.model; public enum PaymentStatus { REQUEST_PREPARED, PAYER_SIGNED, + COMPLIANCE_LOCKED, PAYER_BANK_ACCEPTED, CENTRAL_SETTLED, PAYEE_CREDITED, diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java index 8222eaf..23ae453 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java @@ -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.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 java.util.List; public interface PaymentResourceRepository { + PaymentParticipant loadPayer(PaymentActor actor); + PaymentParticipant loadPayee(String payeeWalletId); PaymentContext loadContext(PaymentActor actor, String payeeWalletId); String signWithWallet(String walletId, String digest); boolean verifyWalletSignature(String walletId, String digest, String signature); + List lockForCompliance(PaymentOrder order, PaymentActor actor); + void releasePaymentResources(PaymentOrder order, PaymentActor actor); + String signComplianceDigest(PaymentActor actor, String digest); PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor); CentralSettlementResult settleAtCentralBank(PaymentOrder order, PaymentActor actor); PaymentCreditResult creditPayee(PaymentOrder order, PaymentActor actor); diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java index 0aad7e3..876bb59 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -48,12 +48,21 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository } @Override - public PaymentContext loadContext(PaymentActor actor, String payeeWalletId) { - WalletPrerequisiteReference payerReference = walletProjection.ensureForSubject( + public PaymentParticipant loadPayer(PaymentActor actor) { + WalletPrerequisiteReference reference = walletProjection.referenceForSubject( new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId())); - WalletPrerequisiteReference payeeReference = walletProjection.ensureForWallet(payeeWalletId); - PaymentParticipant payer = loadParticipant(payerReference); - PaymentParticipant payee = loadParticipant(payeeReference); + return loadParticipant(reference); + } + + @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.getBankCode().equals(payee.getBankCode())) throw validation("本实验仅支持商业银行A向商业银行B的跨行支付"); return new PaymentContext(payer, payee); @@ -80,6 +89,14 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor) { + List 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 lockForCompliance(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("付款钱包可用余额不足或状态异常"); @@ -90,14 +107,35 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository "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); + return coins; + } + + @Override + public void releasePaymentResources(PaymentOrder order, PaymentActor actor) { + List 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); String signature = keyService.signCommercialBank(subject, digest); - if (!keyService.verifyCommercialBank(subject, digest, signature)) throw validation("商业银行A合规报告签名验证失败"); - return new PayerBankProcessingResult(report, digest, signature, coins); + if (!keyService.verifyCommercialBank(subject, digest, signature)) { + throw validation("商业银行A合规报告签名验证失败"); + } + return signature; } @Override @@ -276,6 +314,12 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return results; } + private String complianceReport(PaymentOrder order, List 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) { List values = jdbc.query("SELECT daily_used_amount,daily_counter_date,annual_used_amount,annual_counter_year " + "FROM wallet_contract WHERE wallet_id=? FOR UPDATE", diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java new file mode 100644 index 0000000..1045d00 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java @@ -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; } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java index b0eda1c..8234adc 100644 --- a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java @@ -1,17 +1,22 @@ 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.application.service.PaymentTrainingActionService; 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.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.ExperimentAttemptView; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.MDC; @@ -32,9 +37,13 @@ import java.util.UUID; public class PaymentController { private final PaymentApplicationService service; 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.attempts = attempts; this.actions = actions; } @GetMapping("/context") @@ -44,17 +53,44 @@ public class PaymentController { @PostMapping @Operation(summary = "用户支付数字货币:步骤二,发送支付请求") public ApiResponse create(@Valid @RequestBody CreatePaymentRequest request) { - return ok(service.create(new CreatePaymentCommand(request.getPayerWalletId(), request.getPayeeWalletId(), - request.getAmount(), request.getNote()), actor())); + CurrentUser user = currentUserService.getCurrentUser(); + 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") @Operation(summary = "用户支付数字货币:步骤二,使用付款钱包私钥签名支付请求") - public ApiResponse sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); } + public ApiResponse 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") @Operation(summary = "用户支付数字货币:步骤三,商业银行A验签、合规校验并冻结数字货币") - public ApiResponse payerBankProcess(@PathVariable UUID id) { return ok(service.processPayerBank(id, actor())); } + public ApiResponse 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") @Operation(summary = "用户支付数字货币:步骤四,中央银行结算、权属变更和跨行清算") @@ -76,13 +112,47 @@ public class PaymentController { @Operation(summary = "查询当前用户的支付回执或到账通知") public ApiResponse notificationView(@PathVariable UUID id) { return ok(service.notifications(id, actor())); } - private PaymentActor actor() { - CurrentUser user = currentUserService.getCurrentUser(); + private PaymentActor actor() { return actor(currentUserService.getCurrentUser()); } + + private PaymentActor actor(CurrentUser user) { 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 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 ApiResponse ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } } diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptController.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptController.java new file mode 100644 index 0000000..1e00246 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptController.java @@ -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 create() { + return ok(attempts.create(ExperimentModule.PAYMENT, subject(users.getCurrentUser()))); + } + + @GetMapping("/current") + @Operation(summary = "恢复当前支付实验") + public ApiResponse current() { + return ok(attempts.current(ExperimentModule.PAYMENT, subject(users.getCurrentUser()))); + } + + @GetMapping("/{attemptId}") + @Operation(summary = "查询支付实验详情和已保存动作") + public ApiResponse detail(@PathVariable UUID attemptId) { + return ok(attempts.detail(attemptId, subject(users.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/cancel") + @Operation(summary = "取消支付实验") + public ApiResponse cancel(@PathVariable UUID attemptId) { + return ok(attempts.cancel(attemptId, subject(users.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}") + @Operation(summary = "执行一个支付实验按钮动作") + public ApiResponse> 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 ApiResponse ok(T data) { + return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java index fdd87fa..9162bc3 100644 --- a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java @@ -42,6 +42,14 @@ public class WalletPrerequisiteProjectionService { 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 public WalletPrerequisiteReference ensureForWallet(String walletId) { if (blank(walletId)) throw prerequisite(); @@ -56,6 +64,20 @@ public class WalletPrerequisiteProjectionService { return ensureForSubject(subjects.get(0)); } + @Transactional(readOnly = true) + public WalletPrerequisiteReference referenceForWallet(String walletId) { + if (blank(walletId)) throw prerequisite(); + List 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) { List activations = jdbc.query( "SELECT wallet_id,cb_final_signature,final_time FROM central_wallet_activation " + diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index 9bd5960..6492e10 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -106,6 +106,9 @@ class PaymentControllerTest { @Test 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())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) @@ -113,8 +116,11 @@ class PaymentControllerTest { .andExpect(jsonPath("$.data.payer.organizationId").value("ORG_PAYMENT_A")) .andExpect(jsonPath("$.data.payee.walletId").value(PAYEE_WALLET)) .andExpect(jsonPath("$.data.payee.organizationId").value("ORG_PAYMENT_B")); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id=?", Integer.class, PAYEE_WALLET)) - .isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet", Integer.class)).isEqualTo(walletsBefore); + 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(); 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\":\"实验支付\"}")) @@ -128,6 +134,14 @@ class PaymentControllerTest { 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)); + 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())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CENTRAL_SETTLED")) .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); } + @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, RequestPostProcessor actor) throws Exception { String created = mockMvc.perform(post("/api/v1/payments").with(actor).contentType(MediaType.APPLICATION_JSON) @@ -259,6 +300,10 @@ class PaymentControllerTest { } 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 payment_step_log"); jdbc.update("DELETE FROM payment_notification"); diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java new file mode 100644 index 0000000..672b783 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentTrainingAttemptControllerTest.java @@ -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(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)); + } +}