From d4f8443deb352f074f2fd5e920721125766f25ad Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 29 Sep 2026 16:41:10 +0800 Subject: [PATCH] fix: validate module four and five training inputs --- .../service/ExchangeApplicationService.java | 101 ++++++++++++- .../ExchangeTrainingActionService.java | 71 +++++++-- .../ExchangeResourceRepository.java | 2 + .../JdbcExchangeResourceRepository.java | 28 +++- .../interfaces/dto/ExchangeActionRequest.java | 11 +- .../interfaces/rest/ExchangeController.java | 2 +- .../application/InstitutionKeyService.java | 39 +++-- .../service/PaymentApplicationService.java | 77 ++++++++++ .../service/PaymentTrainingActionService.java | 73 ++++++++- .../model/TeachingPaymentWalletIdentity.java | 5 +- .../repository/PaymentResourceRepository.java | 6 + .../JdbcPaymentResourceRepository.java | 55 ++++++- .../TeachingPaymentPayeeWhitelist.java | 33 +++- .../persistence/TeachingPaymentPayer.java | 26 ++-- .../interfaces/dto/PaymentActionRequest.java | 9 ++ .../interfaces/rest/PaymentController.java | 2 +- .../WalletPrerequisiteProjectionService.java | 33 ++-- .../wallet/WalletRuntimeResetService.java | 26 +++- .../rest/ExchangeControllerTest.java | 94 +++++++++++- .../rest/PaymentControllerTest.java | 143 +++++++++++++++++- 20 files changed, 748 insertions(+), 88 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java index 29be4f3..5ba8ea2 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java @@ -18,6 +18,7 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.exception.TrainingInputException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.beans.factory.annotation.Autowired; @@ -116,6 +117,24 @@ public class ExchangeApplicationService { return ExchangeOrderView.from(order, null); } + @Transactional + public ExchangeOrderView sign(UUID id, String signingOriginalText, String digest, + String privateKey, ExchangeActor actor) { + ExchangeOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != ExchangeStatus.MESSAGE_PREPARED) { + if (order.getStatus().ordinal() > ExchangeStatus.MESSAGE_PREPARED.ordinal()) return current(order); + throw validation("当前兑换订单尚不能签名"); + } + String expectedDigest = cryptography.sm3(signingOriginalText); + if (!expectedDigest.equals(digest)) throw validation("签名原文摘要不一致"); + String signature = resourceRepository.signWithWallet(order.getWalletId(), privateKey, digest); + if (signature == null) throw inputValidation("密钥不匹配"); + order.sign(signingOriginalText, digest, signature); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "03", "使用钱包私钥签名", signature, actor); + return ExchangeOrderView.from(order, null); + } + public ExchangeOrderView verifyWalletSignature(UUID id, ExchangeActor actor) { ExchangeOrder order = owned(id, actor); if (order.getWalletSignature() == null || !resourceRepository.verifyWalletSignature( @@ -284,6 +303,11 @@ public class ExchangeApplicationService { @Transactional public ExchangeOrderView verifyCentralBankConfirmation(UUID id, ExchangeActor actor) { + return verifyCentralBankConfirmation(id, null, actor); + } + + @Transactional + public ExchangeOrderView verifyCentralBankConfirmation(UUID id, String publicKey, ExchangeActor actor) { ExchangeOrder order = ownedForUpdate(id, actor); if (order.getStatus() == ExchangeStatus.CONFIRMATION_VERIFIED || order.getStatus() == ExchangeStatus.SUCCESS) return current(order); @@ -299,7 +323,12 @@ public class ExchangeApplicationService { if (!order.getConfirmationDigest().equals(recalculatedDigest)) { throw validation("央行权属变更确认摘要与原文不一致"); } - if (!keyService.verifyCentralBank(subject, order.getConfirmationDigest(), order.getCentralBankSignature())) { + boolean verified = publicKey == null + ? keyService.verifyCentralBank(subject, order.getConfirmationDigest(), order.getCentralBankSignature()) + : keyService.verifyCentralBank(subject, publicKey, + order.getConfirmationDigest(), order.getCentralBankSignature()); + if (!verified) { + if (publicKey != null) throw inputValidation("密钥不匹配"); throw validation("央行权属变更确认签名验证失败"); } order.verifyCentralBankConfirmation(); @@ -309,6 +338,75 @@ public class ExchangeApplicationService { return current(order); } + public ExchangeOrderView verifyBankSignature(UUID id, String publicKey, ExchangeActor actor) { + ExchangeOrder order = owned(id, actor); + InstitutionKeySubject subject = keySubject(actor); + if (order.getTransferDigest() == null || order.getBankSignature() == null + || !keyService.verifyCommercialBank(subject, publicKey, + order.getTransferDigest(), order.getBankSignature())) { + throw inputValidation("密钥不匹配"); + } + return current(order); + } + + public ExchangeOrderView verifyWalletSignature(UUID id, String publicKey, ExchangeActor actor) { + ExchangeOrder order = owned(id, actor); + if (order.getWalletSignature() == null || !resourceRepository.verifyWalletSignature( + order.getWalletId(), publicKey, order.getDigest(), order.getWalletSignature())) { + throw inputValidation("密钥不匹配"); + } + return current(order); + } + + /** 重新计算央行确权原文摘要,仅返回计算结果,不改变订单状态。 */ + public String recalculateConfirmationDigest(UUID id, ExchangeActor actor) { + ExchangeOrder order = owned(id, actor); + if (order.getConfirmationOriginalText() == null) { + throw validation("央行权属变更确认原文尚未生成"); + } + return cryptography.sm3(order.getConfirmationOriginalText()); + } + + /** 验证已生成的央行确权签名,仅返回签名,不提前推进商业银行验签状态。 */ + public String verifiedCentralBankSignature(UUID id, ExchangeActor actor) { + ExchangeOrder order = owned(id, actor); + if (order.getStatus() != ExchangeStatus.CENTRAL_CONFIRMED + && order.getStatus() != ExchangeStatus.CONFIRMATION_VERIFIED + && order.getStatus() != ExchangeStatus.SUCCESS) { + throw validation("央行权属变更确认尚未生成"); + } + if (order.getConfirmationOriginalText() == null + || order.getConfirmationDigest() == null + || order.getCentralBankSignature() == null) { + throw validation("央行权属变更确认签名验证失败"); + } + String recalculatedDigest = cryptography.sm3(order.getConfirmationOriginalText()); + if (!order.getConfirmationDigest().equals(recalculatedDigest)) { + throw validation("央行权属变更确认摘要与原文不一致"); + } + if (!keyService.verifyCentralBank(keySubject(actor), order.getConfirmationDigest(), + order.getCentralBankSignature())) { + throw validation("央行权属变更确认签名验证失败"); + } + return order.getCentralBankSignature(); + } + + public String verifiedCentralBankSignature(UUID id, String privateKey, ExchangeActor actor) { + ExchangeOrder order = owned(id, actor); + if (order.getStatus() != ExchangeStatus.CENTRAL_CONFIRMED + && order.getStatus() != ExchangeStatus.CONFIRMATION_VERIFIED + && order.getStatus() != ExchangeStatus.SUCCESS) { + throw validation("央行权属变更确认尚未生成"); + } + if (order.getConfirmationDigest() == null || order.getCentralBankSignature() == null) { + throw validation("央行权属变更确认签名验证失败"); + } + String signature = keyService.signCentralBank(keySubject(actor), privateKey, + order.getConfirmationDigest()); + if (signature == null) throw inputValidation("密钥不匹配"); + return order.getCentralBankSignature(); + } + @Transactional public ExchangeOrderView creditWallet(UUID id, ExchangeActor actor) { ExchangeOrder order = ownedForUpdate(id, actor); @@ -357,6 +455,7 @@ public class ExchangeApplicationService { } private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } + private TrainingInputException inputValidation(String message) { return new TrainingInputException(message); } private String timestamp(String value) { if (value == null || !value.matches("\\d{14}")) { diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java index b2da92a..769f8c2 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java @@ -83,6 +83,19 @@ public class ExchangeTrainingActionService { public ExperimentActionView execute(UUID attemptId, String stepCode, String actionCode, ExchangeActionRequest request, ExperimentSubject subject, ExchangeActor actor) { + return executeInternal(attemptId, stepCode, actionCode, request, subject, actor, true); + } + + /** 仅供后端聚合接口同步动作历史,不能暴露为学生提交参数。 */ + public ExperimentActionView executeTrusted(UUID attemptId, String stepCode, String actionCode, + ExchangeActionRequest request, ExperimentSubject subject, + ExchangeActor actor) { + return executeInternal(attemptId, stepCode, actionCode, request, subject, actor, false); + } + + private ExperimentActionView executeInternal(UUID attemptId, String stepCode, String actionCode, + ExchangeActionRequest request, ExperimentSubject subject, + ExchangeActor actor, boolean validateStudentInput) { String key = stepCode + ":" + actionCode; int index = ACTIONS.indexOf(key); if (index < 0) throw validation("不支持的兑换实验动作:" + key); @@ -99,7 +112,7 @@ public class ExchangeTrainingActionService { fingerprint(key, input, subject), Object.class, () -> { try { - return run(key, input, attempt, boundExchangeId, actor); + return run(key, input, attempt, boundExchangeId, actor, validateStudentInput); } catch (TrainingInputException exception) { if (scoreService == null) throw exception; int errors = scoreService.recordError(subject.getUserId()); @@ -114,7 +127,7 @@ public class ExchangeTrainingActionService { private ActionOutcome run(String key, ExchangeActionRequest request, ExperimentAttemptView attempt, UUID exchangeId, - ExchangeActor actor) { + ExchangeActor actor, boolean validateStudentInput) { switch (key) { case "01:refresh-wallet": return completed(exchanges.initializeContext(actor)); @@ -142,13 +155,17 @@ public class ExchangeTrainingActionService { return completed(value("sourceText", source)); } case "03:generate-digest": { + if (validateStudentInput) requireAlgorithm(request, "SM3"); String source = text(prior(attempt, "03", "concatenate-sign-source"), "sourceText"); return completed(value("digest", cryptography.sm3(source))); } case "03:wallet-sign": { + if (validateStudentInput) requireAlgorithm(request, "SM2-SIGN"); String source = text(prior(attempt, "03", "concatenate-sign-source"), "sourceText"); String digest = text(prior(attempt, "03", "generate-digest"), "digest"); - return completed(exchanges.sign(exchangeId, source, digest, actor)); + return completed(validateStudentInput + ? exchanges.sign(exchangeId, source, digest, request.getPrivateKey(), actor) + : exchanges.sign(exchangeId, source, digest, actor)); } case "03:package-request": case "03:send-request": @@ -164,7 +181,10 @@ public class ExchangeTrainingActionService { case "05:close-notification": return completed(exchanges.get(exchangeId, actor)); case "04:verify-wallet-signature": - return completed(exchanges.verifyWalletSignature(exchangeId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? exchanges.verifyWalletSignature(exchangeId, request.getPublicKey(), actor) + : exchanges.verifyWalletSignature(exchangeId, actor)); case "04:check-account-balance": return completed(exchanges.checkAccountBalance(exchangeId, actor)); case "04:check-wallet-limits": @@ -181,7 +201,10 @@ public class ExchangeTrainingActionService { case "05:generate-transfer-request": return completed(exchanges.prepareTransfer(exchangeId, actor)); case "05:verify-bank-signature": - return completed(exchanges.verifyBankSignature(exchangeId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? exchanges.verifyBankSignature(exchangeId, request.getPublicKey(), actor) + : exchanges.verifyBankSignature(exchangeId, actor)); case "05:recalculate-transfer-digest": { ExchangeOrderView order = exchanges.get(exchangeId, actor); return completed(value("digest", cryptography.sm3(order.getTransferOriginalText()))); @@ -199,15 +222,20 @@ public class ExchangeTrainingActionService { case "05:execute-ownership-transfer": return completed(exchanges.executeOwnershipTransfer(exchangeId, actor)); case "05:generate-confirmation-digest": { - ExchangeOrderView order = exchanges.get(exchangeId, actor); - return completed(value("digest", order.getConfirmationDigest())); + if (validateStudentInput) requireAlgorithm(request, "SM3"); + return completed(value("digest", exchanges.recalculateConfirmationDigest(exchangeId, actor))); } case "05:central-bank-sign": { - ExchangeOrderView order = exchanges.get(exchangeId, actor); - return completed(value("signature", order.getCentralBankSignature())); + if (validateStudentInput) requireAlgorithm(request, "SM2-SIGN"); + return completed(value("signature", validateStudentInput + ? exchanges.verifiedCentralBankSignature(exchangeId, request.getPrivateKey(), actor) + : exchanges.verifiedCentralBankSignature(exchangeId, actor))); } case "05:verify-central-bank-signature": - return completed(exchanges.verifyCentralBankConfirmation(exchangeId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? exchanges.verifyCentralBankConfirmation(exchangeId, request.getPublicKey(), actor) + : exchanges.verifyCentralBankConfirmation(exchangeId, actor)); case "05:record-wallet-ledger": return completed(exchanges.creditWallet(exchangeId, actor)); case "05:user-confirm": @@ -300,6 +328,9 @@ public class ExchangeTrainingActionService { values.put("walletId", request.getWalletId()); values.put("bankAccountId", request.getBankAccountId()); values.put("amount", request.getAmount()); + values.put("algorithm", request.getAlgorithm()); + values.put("privateKeyFingerprint", secretFingerprint(request.getPrivateKey())); + values.put("publicKeyFingerprint", secretFingerprint(request.getPublicKey())); values.put("userId", subject.getUserId()); try { byte[] json = objectMapper.writeValueAsString(values).getBytes(StandardCharsets.UTF_8); @@ -320,6 +351,26 @@ public class ExchangeTrainingActionService { return new TrainingInputException(message); } + private void requireAlgorithm(ExchangeActionRequest request, String expected) { + if (request.getAlgorithm() == null + || !expected.equalsIgnoreCase(request.getAlgorithm().trim())) { + throw inputValidation("算法选择不正确"); + } + } + + private String secretFingerprint(String value) { + if (value == null || value.trim().isEmpty()) return null; + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.trim().getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + for (byte item : digest) result.append(String.format("%02x", item & 0xff)); + return result.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "无法生成密钥指纹"); + } + } + private int stepNumber(String stepCode) { try { return Integer.parseInt(stepCode); } catch (NumberFormatException ignored) { return 0; } diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java b/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java index 7764a34..1504ac5 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java @@ -15,7 +15,9 @@ public interface ExchangeResourceRepository { ExchangeContext loadContext(ExchangeActor actor); void initializeContext(ExchangeActor actor); String signWithWallet(String walletId, String digest); + String signWithWallet(String walletId, String privateKey, String digest); boolean verifyWalletSignature(String walletId, String digest, String signature); + boolean verifyWalletSignature(String walletId, String publicKey, String digest, String signature); DebitHoldResult holdDebit(ExchangeOrder order, ExchangeActor actor); void releaseDebitHold(ExchangeOrder order, ExchangeActor actor); BankProcessingResult settleDebitHold(ExchangeOrder order, ExchangeActor actor); diff --git a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java index 2b1dc42..d98da3b 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java @@ -124,14 +124,38 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor return cryptography.sign(cipher.decrypt(values.get(0)), digest); } + @Override + public String signWithWallet(String walletId, String privateKey, String digest) { + List values = walletPublicKeys(walletId); + if (values.isEmpty() || privateKey == null || privateKey.trim().isEmpty()) return null; + try { + String signature = cryptography.sign(privateKey.trim(), digest); + return cryptography.verify(values.get(0), digest, signature) ? signature : null; + } catch (RuntimeException exception) { + return null; + } + } + @Override public boolean verifyWalletSignature(String walletId, String digest, String signature) { - List values = jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " + + List values = walletPublicKeys(walletId); + return !values.isEmpty() && cryptography.verify(values.get(0), digest, signature); + } + + @Override + public boolean verifyWalletSignature(String walletId, String publicKey, String digest, String signature) { + List values = walletPublicKeys(walletId); + return !values.isEmpty() && publicKey != null + && values.get(0).equalsIgnoreCase(publicKey.trim()) + && cryptography.verify(publicKey.trim(), digest, signature); + } + + private List walletPublicKeys(String walletId) { + return jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " + "JOIN digital_wallet w ON w.wallet_id=cert.wallet_id " + "WHERE cert.wallet_id=? AND cert.status='VALID' AND cert.filing_status='REGISTERED' " + "AND w.status='ACTIVE'", (rs, row) -> rs.getString(1), walletId); - return !values.isEmpty() && cryptography.verify(values.get(0), digest, signature); } @Override diff --git a/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java b/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java index 0bd3f70..80bf012 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java +++ b/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java @@ -4,11 +4,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; -@Schema(description = "兑换实验单步动作输入;时间戳、原文、摘要和签名均由服务端生成") +@Schema(description = "兑换实验单步动作输入;时间戳、原文、摘要和签名结果均由服务端生成") public class ExchangeActionRequest { private String walletId; private String bankAccountId; private BigDecimal amount; + private String algorithm; + private String privateKey; + private String publicKey; public String getWalletId() { return walletId; } public void setWalletId(String walletId) { this.walletId = walletId; } @@ -16,4 +19,10 @@ public class ExchangeActionRequest { public void setBankAccountId(String bankAccountId) { this.bankAccountId = bankAccountId; } public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; } + public String getAlgorithm() { return algorithm; } + public void setAlgorithm(String algorithm) { this.algorithm = algorithm; } + public String getPrivateKey() { return privateKey; } + public void setPrivateKey(String privateKey) { this.privateKey = privateKey; } + public String getPublicKey() { return publicKey; } + public void setPublicKey(String publicKey) { this.publicKey = publicKey; } } diff --git a/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java index c904978..e9b3107 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java +++ b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java @@ -151,7 +151,7 @@ public class ExchangeController { private void run(ExperimentAttemptView attempt, String step, String action, ExchangeActionRequest request, CurrentUser user) { - actions.execute(attempt.getAttemptId(), step, action, request, subject(user), actor(user)); + actions.executeTrusted(attempt.getAttemptId(), step, action, request, subject(user), actor(user)); } private ExperimentAttemptView matchingAttempt(UUID exchangeId, CurrentUser user) { diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java index 750bea6..f9038a6 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionKeyService.java @@ -58,11 +58,7 @@ public class InstitutionKeyService { @Transactional(readOnly = true) public String signCommercialBank(InstitutionKeySubject subject, String providedPrivateKey, String message) { - String storedPrivateKey = privateKey(subject, BANK_SECOND_KEY); - if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey)) { - return null; - } - return cryptography.sign(storedPrivateKey, message); + return signWithRegisteredKey(subject, BANK_SECOND_KEY, providedPrivateKey, message); } @Transactional(readOnly = true) @@ -115,11 +111,22 @@ public class InstitutionKeyService { @Transactional(readOnly = true) public String signCentralBank(InstitutionKeySubject subject, String providedPrivateKey, String message) { - String storedPrivateKey = privateKey(subject, CENTRAL_FIRST_KEY); - if (!storedPrivateKey.equalsIgnoreCase(providedPrivateKey)) { - return null; + return signWithRegisteredKey(subject, CENTRAL_FIRST_KEY, providedPrivateKey, message); + } + + @Transactional(readOnly = true) + public boolean matchesCentralBankPublicKey(InstitutionKeySubject subject, String providedPublicKey) { + return providedPublicKey != null + && required(subject, CENTRAL_FIRST_KEY).getPublicKey().equalsIgnoreCase(providedPublicKey.trim()); + } + + @Transactional(readOnly = true) + public boolean verifyCentralBank(InstitutionKeySubject subject, String publicKey, + String message, String signature) { + if (!matchesCentralBankPublicKey(subject, publicKey)) { + return false; } - return cryptography.sign(storedPrivateKey, message); + return cryptography.verify(publicKey.trim(), message, signature); } @Transactional @@ -170,6 +177,20 @@ public class InstitutionKeyService { return privateKeyCipher.decrypt(required(subject, keyId).getEncryptedPrivateKey()); } + private String signWithRegisteredKey(InstitutionKeySubject subject, String keyId, + String providedPrivateKey, String message) { + if (providedPrivateKey == null || providedPrivateKey.trim().isEmpty()) { + return null; + } + try { + String signature = cryptography.sign(providedPrivateKey.trim(), message); + return cryptography.verify(required(subject, keyId).getPublicKey(), message, signature) + ? signature : null; + } catch (RuntimeException exception) { + return null; + } + } + private InstitutionSm2KeyEntity required(InstitutionKeySubject subject, String keyId) { InstitutionSm2KeyEntity key = find(subject, keyId); if (key == null) { 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 40f137e..0431ef8 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 @@ -18,6 +18,7 @@ import com.yau.digitalrmb.payment.domain.repository.PaymentOrderRepository; import com.yau.digitalrmb.payment.domain.repository.PaymentResourceRepository; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.exception.TrainingInputException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -94,6 +95,21 @@ public class PaymentApplicationService { return view(order, actor); } + @Transactional + public PaymentOrderView sign(UUID id, String privateKey, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.REQUEST_PREPARED) { + return atOrAfter(order, PaymentStatus.REQUEST_PREPARED, actor); + } + String signature = resourceRepository.signWithWallet( + order.getPayerWalletId(), privateKey, order.getPaymentDigest()); + if (signature == null) throw inputValidation("密钥不匹配"); + order.sign(signature); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "02-SIGN", "用户支付数字货币:付款钱包SM2签名", signature, actor); + return view(order, actor); + } + public PaymentOrderView verifyPayerSignature(UUID id, PaymentActor actor) { PaymentOrder order = owned(id, actor); if (order.getPayerSignature() == null || !resourceRepository.verifyWalletSignature( @@ -103,6 +119,15 @@ public class PaymentApplicationService { return view(order, actor); } + public PaymentOrderView verifyPayerSignature(UUID id, String publicKey, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getPayerSignature() == null || !resourceRepository.verifyWalletSignature( + order.getPayerWalletId(), publicKey, order.getPaymentDigest(), order.getPayerSignature())) { + throw inputValidation("密钥不匹配"); + } + return view(order, actor); + } + @Transactional public PaymentOrderView lockComplianceResources(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -167,6 +192,21 @@ public class PaymentApplicationService { return view(order, actor); } + @Transactional + public PaymentOrderView signCompliance(UUID id, String privateKey, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.COMPLIANCE_LOCKED || order.getComplianceDigest() == null) { + throw validation("请先生成合规报告摘要"); + } + String signature = resourceRepository.signComplianceDigest(actor, privateKey, order.getComplianceDigest()); + if (signature == null) throw inputValidation("密钥不匹配"); + order.acceptByPayerBankSignature(signature); + 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); @@ -204,6 +244,17 @@ public class PaymentApplicationService { return view(order, actor); } + public PaymentOrderView verifyPayerBankSignature(UUID id, String publicKey, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getStatus().ordinal() < PaymentStatus.PAYER_BANK_ACCEPTED.ordinal()) { + throw validation("请先完成付款银行合规处理"); + } + if (!resourceRepository.verifyPayerBankSignature(order, actor, publicKey)) { + throw inputValidation("密钥不匹配"); + } + return view(order, actor); + } + @Transactional public PaymentOrderView transferOwnership(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -256,6 +307,20 @@ public class PaymentApplicationService { return view(order, actor); } + @Transactional + public PaymentOrderView signSettlement(UUID id, String privateKey, PaymentActor actor) { + PaymentOrder order = ownedForUpdate(id, actor); + if (order.getStatus() != PaymentStatus.SETTLEMENT_DIGESTED) { + return atOrAfter(order, PaymentStatus.SETTLEMENT_DIGESTED, actor); + } + String signature = resourceRepository.signSettlementDigest( + order, actor, privateKey, order.getSettlementDigest()); + if (signature == null) throw inputValidation("密钥不匹配"); + order.signSettlement(signature); + orderRepository.save(order, actor.getUsername()); + return view(order, actor); + } + @Transactional public PaymentOrderView clearAtCentralBank(UUID id, PaymentActor actor) { PaymentOrder order = ownedForUpdate(id, actor); @@ -278,6 +343,17 @@ public class PaymentApplicationService { return view(order, actor); } + public PaymentOrderView verifyCentralBankSignature(UUID id, String publicKey, PaymentActor actor) { + PaymentOrder order = owned(id, actor); + if (order.getStatus().ordinal() < PaymentStatus.CENTRAL_SETTLED.ordinal()) { + throw validation("请先完成中央银行结算"); + } + if (!resourceRepository.verifyCentralBankSignature(order, actor, publicKey)) { + throw inputValidation("密钥不匹配"); + } + return view(order, actor); + } + public PaymentOrderView prepareCredit(UUID id, PaymentActor actor) { PaymentOrder order = owned(id, actor); if (order.getStatus() != PaymentStatus.CENTRAL_SETTLED) { @@ -401,5 +477,6 @@ public class PaymentApplicationService { return value; } private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); } + private TrainingInputException inputValidation(String message) { return new TrainingInputException(message); } private BusinessException forbidden() { return new BusinessException(ErrorCode.FORBIDDEN, "无权访问该支付订单"); } } diff --git a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java index 762c42b..a80bb34 100644 --- a/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/payment/application/service/PaymentTrainingActionService.java @@ -75,6 +75,19 @@ public class PaymentTrainingActionService { public ExperimentActionView execute(UUID attemptId, String stepCode, String actionCode, PaymentActionRequest request, ExperimentSubject subject, PaymentActor actor) { + return executeInternal(attemptId, stepCode, actionCode, request, subject, actor, true); + } + + /** 仅供后端聚合接口同步动作历史,不能暴露为学生提交参数。 */ + public ExperimentActionView executeTrusted(UUID attemptId, String stepCode, String actionCode, + PaymentActionRequest request, ExperimentSubject subject, + PaymentActor actor) { + return executeInternal(attemptId, stepCode, actionCode, request, subject, actor, false); + } + + private ExperimentActionView executeInternal(UUID attemptId, String stepCode, String actionCode, + PaymentActionRequest request, ExperimentSubject subject, + PaymentActor actor, boolean validateStudentInput) { String key = stepCode + ":" + actionCode; int index = ACTIONS.indexOf(key); if (index < 0) throw validation("不支持的支付实验动作:" + key); @@ -91,7 +104,7 @@ public class PaymentTrainingActionService { fingerprint(key, input, subject), Object.class, () -> { try { - return run(key, input, attempt, boundPaymentId, actor); + return run(key, input, attempt, boundPaymentId, actor, validateStudentInput); } catch (TrainingInputException exception) { if (scoreService == null) throw exception; int errors = scoreService.recordError(subject.getUserId()); @@ -106,7 +119,7 @@ public class PaymentTrainingActionService { private ActionOutcome run(String key, PaymentActionRequest request, ExperimentAttemptView attempt, UUID paymentId, - PaymentActor actor) { + PaymentActor actor, boolean validateStudentInput) { switch (key) { case "01:refresh-payer": return completed(payments.payer(actor)); @@ -121,6 +134,7 @@ public class PaymentTrainingActionService { case "02:concatenate-payment": return completed(paymentSource(request, attempt, actor)); case "02:generate-digest": { + if (validateStudentInput) requireAlgorithm(request, "SM3"); Map source = prior(attempt, "02", "concatenate-payment"); PaymentOrderView created = payments.create(new CreatePaymentCommand( text(source, "payerWalletId"), text(source, "payeeWalletId"), @@ -128,7 +142,10 @@ public class PaymentTrainingActionService { return ActionOutcome.completed(created, created.getId()); } case "02:wallet-sign": - return completed(payments.sign(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-SIGN"); + return completed(validateStudentInput + ? payments.sign(paymentId, request.getPrivateKey(), actor) + : payments.sign(paymentId, actor)); case "02:package-payment": case "02:send-payment": case "03:package-bank-request": @@ -138,29 +155,46 @@ public class PaymentTrainingActionService { case "06:advance-receipt-flow": return completed(payments.get(paymentId, actor)); case "03:verify-payer-signature": - return completed(payments.verifyPayerSignature(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? payments.verifyPayerSignature(paymentId, request.getPublicKey(), actor) + : 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": + if (validateStudentInput) requireAlgorithm(request, "SM3"); return completed(payments.generateComplianceDigest(paymentId, actor)); case "03:bank-sign": - return completed(payments.signCompliance(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-SIGN"); + return completed(validateStudentInput + ? payments.signCompliance(paymentId, request.getPrivateKey(), actor) + : payments.signCompliance(paymentId, actor)); case "04:verify-payer-bank-signature": - return completed(payments.verifyPayerBankSignature(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? payments.verifyPayerBankSignature(paymentId, request.getPublicKey(), actor) + : payments.verifyPayerBankSignature(paymentId, actor)); case "04:reserve-and-transfer-ownership": return completed(payments.transferOwnership(paymentId, actor)); case "04:concatenate-settlement": return completed(payments.concatenateSettlement(paymentId, actor)); case "04:generate-settlement-digest": + if (validateStudentInput) requireAlgorithm(request, "SM3"); return completed(payments.generateSettlementDigest(paymentId, actor)); case "04:central-bank-sign": - return completed(payments.signSettlement(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-SIGN"); + return completed(validateStudentInput + ? payments.signSettlement(paymentId, request.getPrivateKey(), actor) + : payments.signSettlement(paymentId, actor)); case "04:acs-clear": return completed(payments.clearAtCentralBank(paymentId, actor)); case "05:verify-central-bank-signature": - return completed(payments.verifyCentralBankSignature(paymentId, actor)); + if (validateStudentInput) requireAlgorithm(request, "SM2-VERIFY"); + return completed(validateStudentInput + ? payments.verifyCentralBankSignature(paymentId, request.getPublicKey(), actor) + : payments.verifyCentralBankSignature(paymentId, actor)); case "05:prepare-credit": return completed(payments.prepareCredit(paymentId, actor)); case "05:execute-credit": @@ -282,6 +316,9 @@ public class PaymentTrainingActionService { values.put("payeeWalletId", request.getPayeeWalletId()); values.put("amount", request.getAmount()); values.put("note", request.getNote()); + values.put("algorithm", request.getAlgorithm()); + values.put("privateKeyFingerprint", secretFingerprint(request.getPrivateKey())); + values.put("publicKeyFingerprint", secretFingerprint(request.getPublicKey())); values.put("userId", subject.getUserId()); try { byte[] json = objectMapper.writeValueAsString(values).getBytes(StandardCharsets.UTF_8); @@ -302,6 +339,26 @@ public class PaymentTrainingActionService { return new TrainingInputException(message); } + private void requireAlgorithm(PaymentActionRequest request, String expected) { + if (request.getAlgorithm() == null + || !expected.equalsIgnoreCase(request.getAlgorithm().trim())) { + throw inputValidation("算法选择不正确"); + } + } + + private String secretFingerprint(String value) { + if (value == null || value.trim().isEmpty()) return null; + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.trim().getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + for (byte item : digest) result.append(String.format("%02x", item & 0xff)); + return result.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, "无法生成密钥指纹"); + } + } + private int stepNumber(String stepCode) { try { return Integer.parseInt(stepCode); } catch (NumberFormatException ignored) { return 0; } diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/model/TeachingPaymentWalletIdentity.java b/src/main/java/com/yau/digitalrmb/payment/domain/model/TeachingPaymentWalletIdentity.java index 610ca5d..94faf99 100644 --- a/src/main/java/com/yau/digitalrmb/payment/domain/model/TeachingPaymentWalletIdentity.java +++ b/src/main/java/com/yau/digitalrmb/payment/domain/model/TeachingPaymentWalletIdentity.java @@ -10,9 +10,8 @@ import java.util.List; import java.util.UUID; /** - * Separates the prototype's fixed wallet identifiers from learner-owned - * persistence identifiers. The latter are deterministic per training subject - * so retries reuse the same isolated runtime without exposing it to the UI. + * 将原型固定的钱包标识与学员独立持有的持久化标识分离。 + * 持久化标识根据实训主体确定性生成,使重试能够复用同一隔离运行态,且不会暴露给界面。 */ public final class TeachingPaymentWalletIdentity { public static final String PAYER_DISPLAY_WALLET_ID = diff --git a/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java index 5f9fd66..b704caa 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 @@ -16,15 +16,21 @@ public interface PaymentResourceRepository { PaymentParticipant loadPayee(String payeeWalletId, PaymentActor actor); PaymentContext loadContext(PaymentActor actor, String payeeWalletId); String signWithWallet(String walletId, String digest); + String signWithWallet(String walletId, String privateKey, String digest); boolean verifyWalletSignature(String walletId, String digest, String signature); + boolean verifyWalletSignature(String walletId, String publicKey, String digest, String signature); List lockForCompliance(PaymentOrder order, PaymentActor actor); void releasePaymentResources(PaymentOrder order, PaymentActor actor); String signComplianceDigest(PaymentActor actor, String digest); + String signComplianceDigest(PaymentActor actor, String privateKey, String digest); void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor); + boolean verifyPayerBankSignature(PaymentOrder order, PaymentActor actor, String publicKey); String transferOwnership(PaymentOrder order, PaymentActor actor); String signSettlementDigest(PaymentOrder order, PaymentActor actor, String digest); + String signSettlementDigest(PaymentOrder order, PaymentActor actor, String privateKey, String digest); void recordCentralSettlement(PaymentOrder order, PaymentActor actor); void verifyCentralBankSignature(PaymentOrder order, PaymentActor actor); + boolean verifyCentralBankSignature(PaymentOrder order, PaymentActor actor, String publicKey); void validateCredit(PaymentOrder order); PayerBankProcessingResult processPayerBank(PaymentOrder order, PaymentActor actor); CentralSettlementResult settleAtCentralBank(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 9b6cde9..7555fa3 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 @@ -59,9 +59,8 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository WalletPrerequisiteReference reference = teachingPaymentPayer.ensure( new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId()), actor.getUsername()); - // The teaching wallet is a built-in case wallet. The payment order still - // belongs to the current learner so the existing action-chain ownership - // and notification flow remains intact. + // 教学钱包是内置案例钱包;支付订单仍归当前学员所有, + // 以保持现有动作链的归属校验和通知流程不变。 return loadParticipant(reference, actor.getUserId(), TeachingPaymentPayer.WALLET_ID, "00A1B2C3D4E5F678"); } @@ -101,13 +100,37 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return cryptography.sign(walletCipher.decrypt(values.get(0)), digest); } + @Override + public String signWithWallet(String walletId, String privateKey, String digest) { + List values = walletPublicKeys(walletId); + if (values.isEmpty() || privateKey == null || privateKey.trim().isEmpty()) return null; + try { + String signature = cryptography.sign(privateKey.trim(), digest); + return cryptography.verify(values.get(0), digest, signature) ? signature : null; + } catch (RuntimeException exception) { + return null; + } + } + @Override public boolean verifyWalletSignature(String walletId, String digest, String signature) { - List values = jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " + + List values = walletPublicKeys(walletId); + return !values.isEmpty() && cryptography.verify(values.get(0), digest, signature); + } + + @Override + public boolean verifyWalletSignature(String walletId, String publicKey, String digest, String signature) { + List values = walletPublicKeys(walletId); + return !values.isEmpty() && publicKey != null + && values.get(0).equalsIgnoreCase(publicKey.trim()) + && cryptography.verify(publicKey.trim(), digest, signature); + } + + private List walletPublicKeys(String walletId) { + return jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " + "JOIN digital_wallet w ON w.wallet_id=cert.wallet_id WHERE cert.wallet_id=? " + "AND cert.status='VALID' AND cert.filing_status='REGISTERED' AND w.status='ACTIVE'", (rs, row) -> rs.getString(1), walletId); - return !values.isEmpty() && cryptography.verify(values.get(0), digest, signature); } @Override @@ -161,6 +184,11 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return signature; } + @Override + public String signComplianceDigest(PaymentActor actor, String privateKey, String digest) { + return keyService.signCommercialBank(subject(actor), privateKey, digest); + } + @Override public void verifyPayerBankSignature(PaymentOrder order, PaymentActor actor) { InstitutionKeySubject subject = subject(actor); @@ -169,6 +197,12 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository } } + @Override + public boolean verifyPayerBankSignature(PaymentOrder order, PaymentActor actor, String publicKey) { + return keyService.verifyCommercialBank(subject(actor), publicKey, + order.getComplianceDigest(), order.getPayerBankSignature()); + } + @Override public String transferOwnership(PaymentOrder order, PaymentActor actor) { List coins = findCoins(order); @@ -199,6 +233,11 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository return signature; } + @Override + public String signSettlementDigest(PaymentOrder order, PaymentActor actor, String privateKey, String digest) { + return keyService.signCentralBank(subject(actor), privateKey, digest); + } + @Override public void recordCentralSettlement(PaymentOrder order, PaymentActor actor) { jdbc.update("INSERT INTO payment_ownership_transfer (transaction_id,payment_id,from_wallet_id,to_wallet_id," + @@ -221,6 +260,12 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository } } + @Override + public boolean verifyCentralBankSignature(PaymentOrder order, PaymentActor actor, String publicKey) { + return keyService.verifyCentralBank(subject(actor), publicKey, + order.getSettlementDigest(), order.getCentralBankSignature()); + } + @Override public void validateCredit(PaymentOrder order) { WalletSnapshot payer = wallet(order.getPayerWalletId()); diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java index ba652e7..0abdedc 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java @@ -14,11 +14,12 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.util.List; /** - * Provides the real, backend-managed receiving wallets used by the payment - * training when a learner has no second account to act as the payee. + * 为支付实训提供由后端实际管理的收款钱包, + * 用于学员没有第二个账户充当收款方的场景。 */ @Component public class TeachingPaymentPayeeWhitelist { @@ -73,9 +74,12 @@ public class TeachingPaymentPayeeWhitelist { } private void ensureWallet(PayeeDefinition payee) { - List wallets = jdbc.query("SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?", + List wallets = jdbc.query( + "SELECT user_id,wallet_type,status,balance " + + "FROM digital_wallet WHERE wallet_id=?", (rs, row) -> new WalletRow(rs.getString("user_id"), rs.getString("wallet_type"), - rs.getString("status")), payee.walletId); + rs.getString("status"), rs.getBigDecimal("balance")), + payee.walletId); if (wallets.isEmpty()) { try { jdbc.update("INSERT INTO digital_wallet (wallet_id,user_id,wallet_type,status,balance,frozen_amount," + @@ -84,9 +88,10 @@ public class TeachingPaymentPayeeWhitelist { "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", payee.walletId, payee.userId, payee.walletType); } catch (DuplicateKeyException exception) { List afterConflict = jdbc.query( - "SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?", + "SELECT user_id,wallet_type,status,balance " + + "FROM digital_wallet WHERE wallet_id=?", (rs, row) -> new WalletRow(rs.getString("user_id"), rs.getString("wallet_type"), - rs.getString("status")), payee.walletId); + rs.getString("status"), rs.getBigDecimal("balance")), payee.walletId); if (afterConflict.isEmpty()) throw conflict("教学收款方的钱包标识或用户标识已被占用"); ensureWallet(payee); } @@ -97,6 +102,18 @@ public class TeachingPaymentPayeeWhitelist { || !"ACTIVE".equals(wallet.status)) { throw conflict("教学收款方钱包状态或归属异常"); } + if (wallet.balance != null && wallet.balance.signum() < 0) { + Integer paymentCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM payment_order WHERE payer_wallet_id=? OR payee_wallet_id=?", + Integer.class, payee.walletId, payee.walletId); + if (paymentCount != null && paymentCount == 0) { + // 旧版本重置可能遗留负余额;教学收款钱包没有订单引用时可以安全恢复到初始余额。 + jdbc.update("UPDATE digital_wallet SET balance=0,frozen_amount=0,updated_at=CURRENT_TIMESTAMP " + + "WHERE wallet_id=? AND balance<0", payee.walletId); + } else { + throw conflict("教学收款方钱包余额异常,请先重新实训清理支付订单"); + } + } } private void ensureCertificate(PayeeDefinition payee) { @@ -193,11 +210,13 @@ public class TeachingPaymentPayeeWhitelist { private final String userId; private final String walletType; private final String status; + private final BigDecimal balance; - private WalletRow(String userId, String walletType, String status) { + private WalletRow(String userId, String walletType, String status, BigDecimal balance) { this.userId = userId; this.walletType = walletType; this.status = status; + this.balance = balance; } } diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayer.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayer.java index b43ae4a..4d3e4cd 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayer.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayer.java @@ -3,7 +3,6 @@ package com.yau.digitalrmb.payment.infrastructure.persistence; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; -import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; import com.yau.digitalrmb.payment.domain.model.TeachingPaymentWalletIdentity; import com.yau.digitalrmb.payment.domain.model.TeachingPaymentWalletIdentity.SubjectWallets; import com.yau.digitalrmb.shared.api.ErrorCode; @@ -20,10 +19,8 @@ import java.nio.charset.StandardCharsets; import java.util.UUID; /** - * Module five's built-in payer. The prototype deliberately uses a teaching - * case, not the learner's personal wallet from module three. Every learner gets - * an isolated persistence wallet while the API keeps displaying the prototype's - * fixed user-A wallet identifier. + * 模块五的内置付款方。原型有意使用教学案例钱包,而不是模块三中学员的个人钱包。 + * 每名学员都拥有独立的持久化钱包,同时接口继续展示原型固定的用户 A 钱包标识。 */ @Component public class TeachingPaymentPayer { @@ -32,6 +29,9 @@ public class TeachingPaymentPayer { private static final String BANK_NAME = "中国银行(BKCHCNBJ00001)"; private static final String CARD_NUMBER = "6216610100001234567"; private static final String WALLET_TYPE = "一类钱包"; + /** 模块五案例背景中公开给学员使用的钱包私钥。 */ + private static final String CASE_WALLET_PRIVATE_KEY = + "F1E2D3C4B5A6F7E8D9C0B1A2F3E4D5C6B7A8F9E0D1C2B3A4F5E6D7C8B9A0"; private final JdbcTemplate jdbc; private final InstitutionIdentityCryptography cryptography; @@ -106,17 +106,23 @@ public class TeachingPaymentPayer { private void ensureCertificate(SubjectWallets wallets) { String walletId = wallets.payerWalletId(); + String publicKey = cryptography.derivePublicKey(CASE_WALLET_PRIVATE_KEY); Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate WHERE wallet_id=?", Integer.class, walletId); - if (count != null && count > 0) return; - InstitutionSm2KeyPair pair = cryptography.generateSm2KeyPair(); + if (count != null && count > 0) { + jdbc.update("UPDATE wallet_certificate SET public_key=?,encrypted_private_key=? " + + "WHERE wallet_id=? AND status='VALID' AND filing_status='REGISTERED' " + + "AND public_key<>?", + publicKey, walletCipher.encrypt(CASE_WALLET_PRIVATE_KEY), walletId, publicKey); + return; + } try { jdbc.update("INSERT INTO wallet_certificate " + "(certificate_serial,wallet_id,public_key,encrypted_private_key,filing_status," + "central_bank_root_signature,status,issued_at) VALUES (?,?,?,?," + - "'REGISTERED','TEACHING_PAYMENT_ROOT','VALID',CURRENT_TIMESTAMP)", - wallets.payerCertificateSerial(), walletId, pair.getPublicKey(), - walletCipher.encrypt(pair.getPrivateKey())); + "'REGISTERED','TEACHING_PAYMENT_ROOT','VALID',CURRENT_TIMESTAMP)", + wallets.payerCertificateSerial(), walletId, publicKey, + walletCipher.encrypt(CASE_WALLET_PRIVATE_KEY)); } catch (DuplicateKeyException exception) { if (jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate WHERE wallet_id=?", Integer.class, walletId) == 0) throw exception; 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 index 1045d00..6452b62 100644 --- a/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/dto/PaymentActionRequest.java @@ -7,6 +7,9 @@ public class PaymentActionRequest { private String payeeWalletId; private BigDecimal amount; private String note; + private String algorithm; + private String privateKey; + private String publicKey; public String getPayerWalletId() { return payerWalletId; } public void setPayerWalletId(String payerWalletId) { this.payerWalletId = payerWalletId; } @@ -16,4 +19,10 @@ public class PaymentActionRequest { public void setAmount(BigDecimal amount) { this.amount = amount; } public String getNote() { return note; } public void setNote(String note) { this.note = note; } + public String getAlgorithm() { return algorithm; } + public void setAlgorithm(String algorithm) { this.algorithm = algorithm; } + public String getPrivateKey() { return privateKey; } + public void setPrivateKey(String privateKey) { this.privateKey = privateKey; } + public String getPublicKey() { return publicKey; } + public void setPublicKey(String publicKey) { this.publicKey = publicKey; } } 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 1a2ac09..b98bc80 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 @@ -163,7 +163,7 @@ public class PaymentController { private void run(ExperimentAttemptView attempt, String step, String action, PaymentActionRequest request, CurrentUser user) { - actions.execute(attempt.getAttemptId(), step, action, request, subject(user), actor(user)); + actions.executeTrusted(attempt.getAttemptId(), step, action, request, subject(user), actor(user)); } private ExperimentAttemptView matchingAttempt(UUID paymentId, CurrentUser user) { 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 22b94f1..c3f8a9d 100644 --- a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java @@ -35,10 +35,9 @@ public class WalletPrerequisiteProjectionService { } /** - * Module four follows the module-three prototype: step four is complete when - * the commercial bank sends the confirmation whose JSON status is ACTIVATED. - * Older module-five data may still require the central final-confirmation row, - * so this compatibility path is intentionally limited to the exchange module. + * 模块四遵循模块三原型:商业银行发送确认且 JSON 状态为 ACTIVATED 时,步骤四完成。 + * 较早的模块五数据可能仍要求央行最终确认记录, + * 因此该兼容路径仅限于兑换模块。 */ @Transactional public WalletPrerequisiteReference ensureForModule4(InstitutionKeySubject subject) { @@ -99,16 +98,12 @@ public class WalletPrerequisiteProjectionService { subject.getUserId(), subject.getSchoolId(), subject.getClassId()); Activation activation; if (!activations.isEmpty()) { - // Prefer the complete step-five record when it exists. It contains the - // central bank's final confirmation signature used by the shared wallet. + // 如果步骤五记录完整,优先使用该记录;其中包含共享钱包所需的央行最终确认签名。 activation = activations.get(0); } else if (allowModule3Step4) { - // The prototype finishes module three step four when the commercial - // bank sends the activation confirmation. The confirmation JSON itself - // carries status=ACTIVATED, while older deployments do not materialize - // a matching central_wallet_activation row. Use that sent confirmation - // as the module-four source instead of requiring an invisible central-bank - // action that is not present in the prototype. + // 原型在商业银行发送激活确认时完成模块三步骤四。确认 JSON 本身带有 status=ACTIVATED, + // 但旧部署可能没有生成对应的 central_wallet_activation 记录。 + // 因此使用已发送的确认作为模块四来源,不再要求原型中不存在的隐藏央行动作。 List sentConfirmations = jdbc.query( "SELECT wallet_id,activate_signature,activate_timestamp " + "FROM smart_contract_generation " + @@ -249,9 +244,9 @@ public class WalletPrerequisiteProjectionService { validateWallet(value, source); return; } - // Module 3 was redone after this wallet was first projected here, and the stale wallet - // was never actually used downstream, so retire it and let the fresh module 3 result - // through instead of surfacing a spurious "共享钱包与模块三最终结果不一致" conflict. + // 该钱包首次投影后模块三被重新实训,旧钱包从未真正被下游使用, + // 因此将其作废并直接沿用最新的模块三结果,避免暴露 + // “共享钱包与模块三最终结果不一致”的虚假冲突。 retireUnusedWalletChain(value.walletId); } try { @@ -377,9 +372,9 @@ public class WalletPrerequisiteProjectionService { || source.application.accountBalance.signum() < 0) { return; } - // Only accounts no downstream experiment has touched follow a re-run module 3 (guarded by the - // NOT EXISTS clauses, which cover every balance/freeze mutation path). A matching card never - // rewrites the row, so runtime balances must not be reset from an opening snapshot. + // 只有未被下游实训修改过的账户才允许跟随模块三重跑(由 NOT EXISTS 条件保护, + // 覆盖所有余额和冻结金额变更路径)。已有匹配卡片时不会重写记录, + // 因此运行态余额不能从开户快照重新覆盖。 jdbc.update("UPDATE simulated_bank_account SET bank_name=?,card_number=?,card_last4=?,balance=?," + "updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND user_id=? AND bank_code=? " + "AND status='ACTIVE' " + @@ -411,7 +406,7 @@ public class WalletPrerequisiteProjectionService { try { return Timestamp.valueOf(LocalDateTime.parse(value.trim(), MODULE_TIMESTAMP)); } catch (DateTimeParseException ignored) { - // Module 3 historical rows may use display text; initialization time is the safe fallback. + // 模块三历史记录可能使用展示文本,此时以初始化时间作为安全兜底。 } } return Timestamp.valueOf(LocalDateTime.now()); diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java index 975e783..407a92f 100644 --- a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java @@ -2,6 +2,8 @@ package com.yau.digitalrmb.shared.wallet; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.payment.domain.model.TeachingPaymentWalletIdentity; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.payment.domain.model.TeachingPaymentWalletIdentity.SubjectWallets; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -31,6 +33,10 @@ public class WalletRuntimeResetService { List paymentIds = jdbc.queryForList( "SELECT id FROM payment_order WHERE payer_user_id=? OR payee_user_id=?", String.class, userId, userId); + List creditedPaymentIds = jdbc.queryForList( + "SELECT id FROM payment_order WHERE (payer_user_id=? OR payee_user_id=?) " + + "AND status IN ('PAYEE_CREDITED','SUCCESS') AND payee_balance_after IS NOT NULL", + String.class, userId, userId); List ownWalletIds = jdbc.queryForList( "SELECT wallet_id FROM digital_wallet WHERE user_id=?", String.class, userId); SubjectWallets teachingWallets = TeachingPaymentWalletIdentity.forSubject( @@ -42,7 +48,7 @@ public class WalletRuntimeResetService { } int restoredCoins = restoreCoinsToBankVault(exchangeIds, paymentIds, ownWalletIds); - reverseCounterpartyBalances(paymentIds, userId); + reverseCounterpartyBalances(creditedPaymentIds, userId); deleteExchangeRows(exchangeIds, userId); deletePaymentRows(paymentIds); deleteTeachingCoins(teachingWallets.payerCurrencyIds()); @@ -119,8 +125,22 @@ public class WalletRuntimeResetService { "WHERE wallet_id=?", flow.amount, flow.payerWalletId); } if (!userId.equals(flow.payeeUserId)) { - jdbc.update("UPDATE digital_wallet SET balance=balance-?,updated_at=CURRENT_TIMESTAMP " + - "WHERE wallet_id=?", flow.amount, flow.payeeWalletId); + int updated = jdbc.update("UPDATE digital_wallet SET balance=balance-?,updated_at=CURRENT_TIMESTAMP " + + "WHERE wallet_id=? AND balance>=?", flow.amount, flow.payeeWalletId, flow.amount); + if (updated != 1) { + BigDecimal currentBalance = jdbc.queryForObject( + "SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, + flow.payeeWalletId); + if (currentBalance != null && currentBalance.signum() < 0) { + // 兼容旧版本已经产生的负余额:重置时将异常值归零,不能再次扣减或阻断重置。 + jdbc.update("UPDATE digital_wallet SET balance=0, frozen_amount=0," + + "updated_at=CURRENT_TIMESTAMP WHERE wallet_id=? AND balance<0", + flow.payeeWalletId); + } else { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "收款钱包余额不足,拒绝重置回滚以避免产生负余额"); + } + } } } } diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java index 7d1ee70..2550d1e 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyPairResult; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; @@ -43,12 +44,16 @@ class ExchangeControllerTest { @Autowired private InstitutionKeyService keyService; @Autowired private WalletPrerequisiteProjectionService walletProjection; @Autowired private ExchangeApplicationService exchangeService; + private InstitutionSm2KeyPair walletKey; + private InstitutionKeyPairResult bankKey; + private InstitutionKeyPairResult centralKey; @BeforeEach void seedPrototypePrerequisites() { jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " + "(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID); jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID); + jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=? AND serial_number=4", USER_ID); jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name," + "role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,4,0,?)", USER_ID, "exchange-test", "unused", "exchange-test", "2999", "兑换测试班", "1999", "延安大学", "999"); @@ -73,14 +78,14 @@ class ExchangeControllerTest { "(990001,'BKCHCNBJ00001',?,1999,2999,'20260812090000','BKCHCNBJ00001|20260812090000'," + "'ISSUED','ORG_EXCHANGE_TEST',1,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", USER_ID); InstitutionKeySubject subject = new InstitutionKeySubject(USER_ID, 1999L, 2999L); - keyService.commercialBankKey(subject, "test"); - keyService.centralBankKey(subject, "test"); + bankKey = keyService.commercialBankKey(subject, "test"); + centralKey = keyService.centralBankKey(subject, "test"); String issuanceSignature = keyService.signCentralBank(subject, "ISSUANCE_ORIGINAL"); String controlOriginalText = "200.00|ORG_EXCHANGE_TEST|TXN_EXCHANGE_TEST"; String controlDigest = cryptography.sm3(controlOriginalText); String controlSignature = keyService.signCentralBank(subject, controlDigest); seedIssuanceProofData(controlOriginalText, controlDigest, controlSignature, issuanceSignature); - InstitutionSm2KeyPair walletKey = cryptography.generateSm2KeyPair(); + walletKey = cryptography.generateSm2KeyPair(); jdbc.update("INSERT INTO simulated_bank_account (account_id,user_id,bank_code,bank_name,card_number,card_last4," + "balance,frozen_amount,status,created_at,updated_at) VALUES " + "('BANK_ACCOUNT_EXCHANGE_TEST',?,'BKCHCNBJ00001','中国银行北京分行','6216610100001234567'," + @@ -333,6 +338,57 @@ class ExchangeControllerTest { Integer.class, exchangeId)).isEqualTo(1); } + @Test + void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingTheExchangeAction() throws Exception { + String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String walletId = JsonPath.read(context, "$.data.walletId"); + String accountId = JsonPath.read(context, "$.data.bankAccountId"); + String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(user())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + + executeAction(attemptId, "01", "refresh-wallet", "COMPLETED"); + executeAction(attemptId, "01", "confirm-binding", "COMPLETED"); + executeAction(attemptId, "01", "refresh-institution", "COMPLETED"); + executeAction(attemptId, "02", "generate-timestamp", "COMPLETED"); + executeAction(attemptId, "02", "concatenate-withdrawal", "COMPLETED", + "{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId + + "\",\"amount\":200.00}"); + executeAction(attemptId, "02", "output-withdrawal-request", "COMPLETED"); + executeAction(attemptId, "03", "generate-sign-timestamp", "COMPLETED"); + executeAction(attemptId, "03", "concatenate-sign-source", "COMPLETED"); + + mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/03/actions/generate-digest", attemptId) + .with(user()).contentType(MediaType.APPLICATION_JSON) + .content("{\"algorithm\":\"SM2-SIGN\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("算法选择不正确"))); + assertThat(actionStatus(attemptId, "03", "generate-digest")).isEqualTo("FAILED"); + assertThat(jdbc.queryForMap("SELECT current_step_code,current_action_code " + + "FROM training_experiment_attempt WHERE id=?", attemptId)) + .containsEntry("current_step_code", "03") + .containsEntry("current_action_code", "generate-digest"); + + executeAction(attemptId, "03", "generate-digest", "COMPLETED"); + assertThat(actionStatus(attemptId, "03", "generate-digest")).isEqualTo("COMPLETED"); + mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/03/actions/wallet-sign", attemptId) + .with(user()).contentType(MediaType.APPLICATION_JSON) + .content("{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"00\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("密钥不匹配"))); + assertThat(actionStatus(attemptId, "03", "wallet-sign")).isEqualTo("FAILED"); + assertThat(jdbc.queryForMap("SELECT current_step_code,current_action_code " + + "FROM training_experiment_attempt WHERE id=?", attemptId)) + .containsEntry("current_step_code", "03") + .containsEntry("current_action_code", "wallet-sign"); + assertThat(jdbc.queryForObject("SELECT completion_status FROM stu_module_score_details " + + "WHERE user_id=? AND serial_number=4", String.class, USER_ID)).isEqualTo("2"); + + executeAction(attemptId, "03", "wallet-sign", "COMPLETED"); + assertThat(actionStatus(attemptId, "03", "wallet-sign")).isEqualTo("COMPLETED"); + } + @Test void rejectsCoinWhenCentralBankControlSignatureIsTampered() throws Exception { String[] values = createHeldExchange(); @@ -456,13 +512,43 @@ class ExchangeControllerTest { } private void executeAction(String attemptId, String step, String action, String expectedStatus) throws Exception { + executeAction(attemptId, step, action, expectedStatus, actionBody(step, action)); + } + + private void executeAction(String attemptId, String step, String action, String expectedStatus, + String body) throws Exception { mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}", attemptId, step, action).with(user()) - .contentType(MediaType.APPLICATION_JSON).content("{}")) + .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.actionStatus").value(expectedStatus)); } + private String actionStatus(String attemptId, String step, String action) { + return jdbc.queryForObject("SELECT status FROM training_experiment_action " + + "WHERE attempt_id=? AND step_code=? AND action_code=?", + String.class, attemptId, step, action); + } + + private String actionBody(String step, String action) { + String key = step + ":" + action; + switch (key) { + case "03:generate-digest": return "{\"algorithm\":\"SM3\"}"; + case "03:wallet-sign": return "{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"" + + walletKey.getPrivateKey() + "\"}"; + case "04:verify-wallet-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + walletKey.getPublicKey() + "\"}"; + case "05:verify-bank-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + bankKey.getPublicKey() + "\"}"; + case "05:generate-confirmation-digest": return "{\"algorithm\":\"SM3\"}"; + case "05:central-bank-sign": return "{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"" + + centralKey.getPrivateKey() + "\"}"; + case "05:verify-central-bank-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + centralKey.getPublicKey() + "\"}"; + default: return "{}"; + } + } + private void seedIssuanceProofData(String controlOriginalText, String controlDigest, String controlSignature, String issuanceSignature) { jdbc.update("INSERT INTO control_system_signature (id,transaction_identifier_id,control_id,amount," + 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 b2940c0..31e4669 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 @@ -3,6 +3,7 @@ package com.yau.digitalrmb.payment.interfaces.rest; import com.jayway.jsonpath.JsonPath; import com.yau.digitalrmb.shared.wallet.WalletPrivateKeyCipher; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyPairResult; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; @@ -25,6 +26,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.math.BigDecimal; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; @@ -56,6 +58,9 @@ class PaymentControllerTest { @Autowired private WalletPrivateKeyCipher walletCipher; @Autowired private InstitutionKeyService keyService; @Autowired private WalletRuntimeResetService walletRuntimeResetService; + private InstitutionSm2KeyPair payerWalletKey; + private InstitutionKeyPairResult bankKey; + private InstitutionKeyPairResult centralKey; @BeforeEach void seedPrerequisites() { @@ -77,12 +82,12 @@ class PaymentControllerTest { "VALUES (?,?,?,?,?,?,?,'ISSUED',?,1,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", 991002L, "BKCHCNBJ00002", PAYEE_USER, 3001L, 2001L, "20260814090000", "BANK_B", "ORG_PAYMENT_B"); InstitutionKeySubject subject = new InstitutionKeySubject(PAYER_USER, 3001L, 2001L); - keyService.commercialBankKey(subject, "test"); - keyService.centralBankKey(subject, "test"); + bankKey = keyService.commercialBankKey(subject, "test"); + centralKey = keyService.centralBankKey(subject, "test"); InstitutionKeySubject payeeSubject = new InstitutionKeySubject(PAYEE_USER, 3001L, 2001L); keyService.commercialBankKey(payeeSubject, "test"); keyService.centralBankKey(payeeSubject, "test"); - InstitutionSm2KeyPair payerWalletKey = cryptography.generateSm2KeyPair(); + payerWalletKey = cryptography.generateSm2KeyPair(); InstitutionSm2KeyPair payeeWalletKey = cryptography.generateSm2KeyPair(); WalletOpeningTestData.insertCompletedWallet(jdbc, 991200L, PAYER_USER, 3001L, 2001L, PAYER_WALLET, payerWalletKey.getPrivateKey(), payerWalletKey.getPublicKey(), @@ -214,6 +219,51 @@ class PaymentControllerTest { PAYEE_STORAGE_WALLET)).isEqualTo("ACTIVE"); } + @Test + void rejectsWrongAlgorithmAndWalletPrivateKeyWithoutAdvancingThePaymentAction() throws Exception { + mockMvc.perform(get("/api/v1/payments/context").with(payer())).andExpect(status().isOk()); + loadPayerWalletKeyFromStorage(); + assertThat(payerWalletKey.getPrivateKey()).isEqualTo( + "F1E2D3C4B5A6F7E8D9C0B1A2F3E4D5C6B7A8F9E0D1C2B3A4F5E6D7C8B9A0"); + String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + executeAction(attemptId, "01", "refresh-payer", payer()); + executeAction(attemptId, "01", "refresh-payee", payer()); + executeAction(attemptId, "02", "generate-timestamp", payer()); + executeAction(attemptId, "02", "concatenate-payment", + "{\"amount\":200.00,\"note\":\"校验测试\"}", payer()); + + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/02/actions/generate-digest", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"algorithm\":\"SM2-SIGN\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("算法选择不正确"))); + assertThat(actionStatus(attemptId, "02", "generate-digest")).isEqualTo("FAILED"); + assertThat(jdbc.queryForMap("SELECT current_step_code,current_action_code " + + "FROM training_experiment_attempt WHERE id=?", attemptId)) + .containsEntry("current_step_code", "02") + .containsEntry("current_action_code", "generate-digest"); + + executeAction(attemptId, "02", "generate-digest", payer()); + assertThat(actionStatus(attemptId, "02", "generate-digest")).isEqualTo("COMPLETED"); + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/02/actions/wallet-sign", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"00\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("密钥不匹配"))); + assertThat(actionStatus(attemptId, "02", "wallet-sign")).isEqualTo("FAILED"); + assertThat(jdbc.queryForMap("SELECT current_step_code,current_action_code " + + "FROM training_experiment_attempt WHERE id=?", attemptId)) + .containsEntry("current_step_code", "02") + .containsEntry("current_action_code", "wallet-sign"); + assertThat(jdbc.queryForObject("SELECT completion_status FROM stu_module_score_details " + + "WHERE user_id=? AND serial_number=5", String.class, PAYER_USER)).isEqualTo("2"); + + executeAction(attemptId, "02", "wallet-sign", payer()); + assertThat(actionStatus(attemptId, "02", "wallet-sign")).isEqualTo("COMPLETED"); + } + @Test void repairsTheLegacyTeachingPayerTypeWithoutResettingItsBalance() throws Exception { mockMvc.perform(get("/api/v1/payments/context").with(payer())).andExpect(status().isOk()); @@ -322,6 +372,47 @@ class PaymentControllerTest { BigDecimal.class, PAYEE_TEACHING_WALLETS.payerWalletId())).isEqualByComparingTo("10000.00"); } + @Test + void resetDeletesUncreditedPaymentWithoutReducingPayeeBalance() throws Exception { + String created = mockMvc.perform(post("/api/v1/payments").with(payer()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"payerWalletId\":\"" + PAYER_WALLET + "\",\"payeeWalletId\":\"" + + PAYEE_WALLET + "\",\"amount\":2000.00,\"note\":\"未入账订单\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("REQUEST_PREPARED")) + .andReturn().getResponse().getContentAsString(); + String paymentId = JsonPath.read(created, "$.data.id"); + + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYEE_WALLET)).isEqualByComparingTo("0.00"); + + walletRuntimeResetService.reset(new InstitutionKeySubject(PAYER_USER, 3001L, 2001L)); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_order WHERE id=?", + Integer.class, paymentId)).isZero(); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYEE_WALLET)).isEqualByComparingTo("0.00"); + } + + @Test + void refreshPayeeRepairsLegacyNegativeBalanceWhenNoPaymentRemains() throws Exception { + String created = mockMvc.perform(post("/api/v1/payment/attempts").with(payer())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(created, "$.data.attemptId"); + executeAction(attemptId, "01", "refresh-payer", payer()); + jdbc.update("UPDATE digital_wallet SET balance=-2000.00,frozen_amount=0 WHERE wallet_id=?", + PAYEE_STORAGE_WALLET); + + mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/01/actions/refresh-payee", attemptId) + .with(payer()).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.output.balance").value(0.00)) + .andExpect(jsonPath("$.data.output.availableBalance").value(0.00)); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYEE_STORAGE_WALLET)).isEqualByComparingTo("0.00"); + } + @Test void refreshActionsProjectCompletedModuleThreeWalletsWhenSharedRowsAreMissing() throws Exception { clearSharedWalletProjection(); @@ -500,13 +591,56 @@ class PaymentControllerTest { private void executeAction(String attemptId, String step, String action, RequestPostProcessor actor) throws Exception { + executeAction(attemptId, step, action, actionBody(step, action), actor); + } + + private void executeAction(String attemptId, String step, String action, String body, + RequestPostProcessor actor) throws Exception { mockMvc.perform(post("/api/v1/payment/attempts/{id}/steps/{step}/actions/{action}", attemptId, step, action).with(actor) - .contentType(MediaType.APPLICATION_JSON).content("{}")) + .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.actionStatus").value("COMPLETED")); } + private String actionStatus(String attemptId, String step, String action) { + return jdbc.queryForObject("SELECT status FROM training_experiment_action " + + "WHERE attempt_id=? AND step_code=? AND action_code=?", + String.class, attemptId, step, action); + } + + private void loadPayerWalletKeyFromStorage() { + Map certificate = jdbc.queryForMap( + "SELECT public_key,encrypted_private_key FROM wallet_certificate " + + "WHERE wallet_id=? AND status='VALID' AND filing_status='REGISTERED'", + PAYER_STORAGE_WALLET); + payerWalletKey = new InstitutionSm2KeyPair( + walletCipher.decrypt((String) certificate.get("encrypted_private_key")), + (String) certificate.get("public_key")); + } + + private String actionBody(String step, String action) { + String key = step + ":" + action; + switch (key) { + case "02:generate-digest": return "{\"algorithm\":\"SM3\"}"; + case "02:wallet-sign": return "{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"" + + payerWalletKey.getPrivateKey() + "\"}"; + case "03:verify-payer-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + payerWalletKey.getPublicKey() + "\"}"; + case "03:generate-compliance-digest": return "{\"algorithm\":\"SM3\"}"; + case "03:bank-sign": return "{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"" + + bankKey.getPrivateKey() + "\"}"; + case "04:verify-payer-bank-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + bankKey.getPublicKey() + "\"}"; + case "04:generate-settlement-digest": return "{\"algorithm\":\"SM3\"}"; + case "04:central-bank-sign": return "{\"algorithm\":\"SM2-SIGN\",\"privateKey\":\"" + + centralKey.getPrivateKey() + "\"}"; + case "05:verify-central-bank-signature": return "{\"algorithm\":\"SM2-VERIFY\",\"publicKey\":\"" + + centralKey.getPublicKey() + "\"}"; + default: return "{}"; + } + } + private void insertUser(String id, String account, String schoolId, String classId) { jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,role_id,is_deleted,zy_user_id) " + "VALUES (?,?,?,?,?,'支付测试班',?,'延安大学',4,0,?)", id, account, "unused", account, classId, schoolId, account); @@ -563,6 +697,7 @@ class PaymentControllerTest { } private void cleanPaymentData() { + jdbc.update("DELETE FROM stu_module_score_details WHERE user_id=? AND serial_number=5", PAYER_USER); 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 (?,?)",