fix: validate module four and five training inputs

chore/issuance-comments-zh
chenyuan 2 weeks ago
parent 2f0ae18116
commit d4f8443deb

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

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

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

@ -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<String> 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<String> values = jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " +
List<String> 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<String> values = walletPublicKeys(walletId);
return !values.isEmpty() && publicKey != null
&& values.get(0).equalsIgnoreCase(publicKey.trim())
&& cryptography.verify(publicKey.trim(), digest, signature);
}
private List<String> 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

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

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

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

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

@ -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<Object> 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<String, Object> 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; }

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

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

@ -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<String> 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<String> values = jdbc.query("SELECT cert.public_key FROM wallet_certificate cert " +
List<String> 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<String> values = walletPublicKeys(walletId);
return !values.isEmpty() && publicKey != null
&& values.get(0).equalsIgnoreCase(publicKey.trim())
&& cryptography.verify(publicKey.trim(), digest, signature);
}
private List<String> 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<PaymentCoin> 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());

@ -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<WalletRow> wallets = jdbc.query("SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?",
List<WalletRow> 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<WalletRow> 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;
}
}

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

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

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

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

@ -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<String> paymentIds = jdbc.queryForList(
"SELECT id FROM payment_order WHERE payer_user_id=? OR payee_user_id=?",
String.class, userId, userId);
List<String> 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<String> 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,
"收款钱包余额不足,拒绝重置回滚以避免产生负余额");
}
}
}
}
}

@ -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," +

@ -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<String, Object> 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 (?,?)",

Loading…
Cancel
Save