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 c17a89c..29be4f3 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 @@ -234,7 +234,9 @@ public class ExchangeApplicationService { @Transactional public void cancel(UUID id, ExchangeActor actor) { ExchangeOrder order = ownedForUpdate(id, actor); - if (order.getStatus() == ExchangeStatus.SUCCESS) { + if (order.getStatus() == ExchangeStatus.CENTRAL_CONFIRMED + || order.getStatus() == ExchangeStatus.CONFIRMATION_VERIFIED + || order.getStatus() == ExchangeStatus.SUCCESS) { throw validation("币串权属已转移,不能取消兑换实验"); } if (order.getStatus() == ExchangeStatus.COINS_RESERVED) { @@ -249,9 +251,11 @@ public class ExchangeApplicationService { } @Transactional - public ExchangeOrderView confirmOwnership(UUID id, ExchangeActor actor) { + public ExchangeOrderView executeOwnershipTransfer(UUID id, ExchangeActor actor) { ExchangeOrder order = ownedForUpdate(id, actor); - if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order); + if (order.getStatus() == ExchangeStatus.CENTRAL_CONFIRMED + || order.getStatus() == ExchangeStatus.CONFIRMATION_VERIFIED + || order.getStatus() == ExchangeStatus.SUCCESS) return current(order); if (order.getStatus() != ExchangeStatus.COINS_RESERVED) throw validation("请先锁定等额商业银行币串"); List coins = resourceRepository.findReservedCoins(order); if (order.getTransferRequestNo() == null) prepareTransfer(order, actor); @@ -278,6 +282,55 @@ public class ExchangeApplicationService { return ExchangeOrderView.from(order, resourceRepository.findReservedCoins(order)); } + @Transactional + public ExchangeOrderView verifyCentralBankConfirmation(UUID id, ExchangeActor actor) { + ExchangeOrder order = ownedForUpdate(id, actor); + if (order.getStatus() == ExchangeStatus.CONFIRMATION_VERIFIED + || order.getStatus() == ExchangeStatus.SUCCESS) return current(order); + if (order.getStatus() != ExchangeStatus.CENTRAL_CONFIRMED) { + throw validation("央行权属变更确认尚未返回,商业银行不能执行验签"); + } + InstitutionKeySubject subject = keySubject(actor); + 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(subject, order.getConfirmationDigest(), order.getCentralBankSignature())) { + throw validation("央行权属变更确认签名验证失败"); + } + order.verifyCentralBankConfirmation(); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "05-3", "商业银行验证央行权属变更确认签名", + order.getConfirmationDigest(), actor); + return current(order); + } + + @Transactional + public ExchangeOrderView creditWallet(UUID id, ExchangeActor actor) { + ExchangeOrder order = ownedForUpdate(id, actor); + if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order); + if (order.getStatus() != ExchangeStatus.CONFIRMATION_VERIFIED) { + throw validation("商业银行尚未验证央行权属变更确认签名,不能增加钱包余额"); + } + BigDecimal walletBalanceAfter = resourceRepository.creditWallet(order, actor); + order.completeWalletCredit(walletBalanceAfter, clock.instant()); + orderRepository.save(order, actor.getUsername()); + resourceRepository.appendStepLog(order, "05-4", "商业银行增加钱包余额并登记流水", + walletBalanceAfter.toPlainString(), actor); + return current(order); + } + + @Transactional + public ExchangeOrderView confirmOwnership(UUID id, ExchangeActor actor) { + executeOwnershipTransfer(id, actor); + verifyCentralBankConfirmation(id, actor); + return creditWallet(id, actor); + } + public ExchangeOrderView get(UUID id, ExchangeActor actor) { ExchangeOrder order = owned(id, actor); return ExchangeOrderView.from(order, resourceRepository.findReservedCoins(order)); 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 8166b0d..a37fa02 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 @@ -47,8 +47,9 @@ public class ExchangeTrainingActionService { "05:generate-transfer-request", "05:receive-transfer-request", "05:reply-transfer-request", "05:verify-bank-signature", "05:recalculate-transfer-digest", "05:compare-transfer-digest", "05:verify-coin-ownership", "05:confirm-transfer", "05:execute-ownership-transfer", - "05:record-wallet-ledger", "05:generate-confirmation-digest", "05:central-bank-sign", - "05:package-confirmation", "05:return-confirmation", "05:notify-user", + "05:generate-confirmation-digest", "05:central-bank-sign", + "05:package-confirmation", "05:return-confirmation", "05:verify-central-bank-signature", + "05:record-wallet-ledger", "05:notify-user", "05:close-notification", "05:user-confirm"); private final ExperimentAttemptService attempts; @@ -132,7 +133,6 @@ public class ExchangeTrainingActionService { case "05:receive-transfer-request": case "05:reply-transfer-request": case "05:confirm-transfer": - case "05:record-wallet-ledger": case "05:package-confirmation": case "05:return-confirmation": case "05:notify-user": @@ -172,7 +172,7 @@ public class ExchangeTrainingActionService { case "05:verify-coin-ownership": return completed(exchanges.verifyCoinOwnership(exchangeId, actor)); case "05:execute-ownership-transfer": - return completed(exchanges.confirmOwnership(exchangeId, actor)); + return completed(exchanges.executeOwnershipTransfer(exchangeId, actor)); case "05:generate-confirmation-digest": { ExchangeOrderView order = exchanges.get(exchangeId, actor); return completed(value("digest", order.getConfirmationDigest())); @@ -181,6 +181,10 @@ public class ExchangeTrainingActionService { ExchangeOrderView order = exchanges.get(exchangeId, actor); return completed(value("signature", order.getCentralBankSignature())); } + case "05:verify-central-bank-signature": + return completed(exchanges.verifyCentralBankConfirmation(exchangeId, actor)); + case "05:record-wallet-ledger": + return completed(exchanges.creditWallet(exchangeId, actor)); case "05:user-confirm": return ActionOutcome.completedAttempt(exchanges.get(exchangeId, actor)); default: diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java index bdeeb61..a8e6a71 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java @@ -160,8 +160,21 @@ public final class ExchangeOrder { this.centralBankSignature = required(confirmation.getCentralBankSignature(), "央行权属变更签名"); this.confirmationOriginalText = required(confirmationOriginalText, "央行确权原文"); this.confirmationDigest = required(confirmationDigest, "央行确权摘要"); - this.walletBalanceAfter = confirmation.getWalletBalanceAfter(); - this.completedAt = Objects.requireNonNull(completedAt); this.status = ExchangeStatus.SUCCESS; + this.walletBalanceAfter = null; + this.completedAt = null; + this.status = ExchangeStatus.CENTRAL_CONFIRMED; + } + + public void verifyCentralBankConfirmation() { + requireStatus(ExchangeStatus.CENTRAL_CONFIRMED); + this.status = ExchangeStatus.CONFIRMATION_VERIFIED; + } + + public void completeWalletCredit(BigDecimal balanceAfter, Instant completedAt) { + requireStatus(ExchangeStatus.CONFIRMATION_VERIFIED); + this.walletBalanceAfter = Objects.requireNonNull(balanceAfter, "walletBalanceAfter"); + this.completedAt = Objects.requireNonNull(completedAt, "completedAt"); + this.status = ExchangeStatus.SUCCESS; } public void requireOwnedBy(String currentUserId) { diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java index df04313..4223ad6 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java @@ -6,6 +6,8 @@ public enum ExchangeStatus { BANK_PROCESSED, FUNDS_HELD, COINS_RESERVED, + CENTRAL_CONFIRMED, + CONFIRMATION_VERIFIED, SUCCESS, FAILED } 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 bc373f0..7764a34 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 @@ -9,6 +9,7 @@ import com.yau.digitalrmb.exchange.domain.model.OwnershipConfirmation; import com.yau.digitalrmb.exchange.domain.model.ReservedCoin; import java.util.List; +import java.math.BigDecimal; public interface ExchangeResourceRepository { ExchangeContext loadContext(ExchangeActor actor); @@ -25,5 +26,6 @@ public interface ExchangeResourceRepository { OwnershipConfirmation confirmOwnership(ExchangeOrder order, List coins, String confirmationDigest, String centralBankSignature, ExchangeActor actor); + BigDecimal creditWallet(ExchangeOrder order, ExchangeActor actor); void appendStepLog(ExchangeOrder order, String stepCode, String stepName, String output, 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 2161420..1edbf2f 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 @@ -302,6 +302,26 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor List balances = jdbc.query("SELECT balance FROM digital_wallet WHERE wallet_id=? AND status='ACTIVE' FOR UPDATE", (rs, row) -> rs.getBigDecimal(1), order.getWalletId()); if (balances.isEmpty()) throw validation("用户钱包不存在或状态异常"); + return new OwnershipConfirmation(transactionId, confirmationDigest, centralBankSignature, null); + } + + @Override + public BigDecimal creditWallet(ExchangeOrder order, ExchangeActor actor) { + if (order.getStatus() != com.yau.digitalrmb.exchange.domain.model.ExchangeStatus.CONFIRMATION_VERIFIED) { + throw validation("商业银行尚未验证央行确权签名,不能增加钱包余额"); + } + List credited = jdbc.query("SELECT balance_after FROM wallet_balance_ledger WHERE exchange_id=?", + (rs, row) -> rs.getBigDecimal(1), order.getId().toString()); + if (!credited.isEmpty()) return credited.get(0); + Integer validConfirmation = jdbc.queryForObject("SELECT COUNT(*) FROM currency_ownership_transfer " + + "WHERE exchange_id=? AND confirmation_digest=? AND central_bank_signature=? AND status='CONFIRMED'", + Integer.class, order.getId().toString(), order.getConfirmationDigest(), order.getCentralBankSignature()); + if (validConfirmation == null || validConfirmation != 1) { + throw validation("央行权属变更确认记录不存在或已经失效"); + } + List balances = jdbc.query("SELECT balance FROM digital_wallet WHERE wallet_id=? AND status='ACTIVE' FOR UPDATE", + (rs, row) -> rs.getBigDecimal(1), order.getWalletId()); + if (balances.isEmpty()) throw validation("用户钱包不存在或状态异常"); BigDecimal after = balances.get(0).add(order.getAmount()).setScale(2); jdbc.update("UPDATE digital_wallet SET balance=?,updated_at=CURRENT_TIMESTAMP WHERE wallet_id=?", after, order.getWalletId()); @@ -309,7 +329,9 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor "created_at,created_by) VALUES (?,?,?,'CREDIT',?,?,CURRENT_TIMESTAMP,?)", "LEDGER_" + order.getRequestNo(), order.getWalletId(), order.getId().toString(), order.getAmount(), after, actor.getUsername()); - return new OwnershipConfirmation(transactionId, confirmationDigest, centralBankSignature, after); + jdbc.update("UPDATE currency_ownership_transfer SET status='WALLET_CREDITED' WHERE exchange_id=? AND status='CONFIRMED'", + order.getId().toString()); + return after; } @Override 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 2884fb8..c904978 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 @@ -115,8 +115,9 @@ public class ExchangeController { String[] actionCodes = {"adjust-bank-ledger", "generate-transfer-request", "receive-transfer-request", "reply-transfer-request", "verify-bank-signature", "recalculate-transfer-digest", "compare-transfer-digest", "verify-coin-ownership", "confirm-transfer", - "execute-ownership-transfer", "record-wallet-ledger", "generate-confirmation-digest", - "central-bank-sign", "package-confirmation", "return-confirmation", "notify-user", + "execute-ownership-transfer", "generate-confirmation-digest", + "central-bank-sign", "package-confirmation", "return-confirmation", + "verify-central-bank-signature", "record-wallet-ledger", "notify-user", "close-notification", "user-confirm"}; for (String actionCode : actionCodes) { run(attempt, "05", actionCode, new ExchangeActionRequest(), user); diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java index 26a6e37..0f17a5d 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/InstitutionIdentifierStepService.java @@ -85,6 +85,10 @@ public class InstitutionIdentifierStepService { if (application.getDigest() == null) { throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成SM3摘要计算"); } + if (application.getStatus() != InstitutionIdentifierApplication.Status.DIGESTED) { + recordAndThrow(application, "INVALID_STEP", "SIGN", null, + "当前最新一轮状态:" + application.getStatus().name(), operator); + } long applicationId = application.getId(); InstitutionIdentifierApplication.Status expectedStatus = application.getStatus(); String signature = keyService.signCommercialBank(subject, privateKey, application.getDigest()); diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyOwnershipConfirmationView.java b/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyOwnershipConfirmationView.java index 12bd2c7..60ff75f 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyOwnershipConfirmationView.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyOwnershipConfirmationView.java @@ -15,9 +15,24 @@ public class DigitalCurrencyOwnershipConfirmationView { private final String confirmedByUserId; private final String confirmedBy; private final String signingKeyId; + private final String confirmationOriginalText; + private final String confirmationDigest; + private final String centralBankSignature; + private final Instant verifiedAt; + private final String verifiedByUserId; + private final String verifiedBy; + private final String receiptOriginalText; + private final String receiptDigest; + private final String receiptSignature; + private final String receiptSigningKeyId; + private final Instant receiptAt; public static DigitalCurrencyOwnershipConfirmationView from(DigitalCurrencyOwnershipConfirmation value) { return new DigitalCurrencyOwnershipConfirmationView(value.getBatchId(), value.getStatus(), value.getConfirmedAt(), - value.getConfirmedByUserId(), value.getConfirmedBy(), value.getSigningKeyId()); + value.getConfirmedByUserId(), value.getConfirmedBy(), value.getSigningKeyId(), + value.getConfirmationOriginalText(), value.getConfirmationDigest(), value.getCentralBankSignature(), + value.getVerifiedAt(), value.getVerifiedByUserId(), value.getVerifiedBy(), + value.getReceiptOriginalText(), value.getReceiptDigest(), value.getReceiptSignature(), + value.getReceiptSigningKeyId(), value.getReceiptAt()); } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java index a7e99e3..1d51ef8 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java @@ -6,6 +6,7 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.application.QuotaControlBitResult; import com.yau.digitalrmb.institutionidentity.application.QuotaControlBitService; +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.issuance.domain.model.DenominationItem; @@ -41,6 +42,7 @@ public class DigitalCurrencyGenerationModuleGateway { private final StandardCurrencyMapper standardCurrencyMapper; private final IssuanceSourceCurrencyUsageMapper sourceUsageMapper; private final JdbcTemplate jdbcTemplate; + private final InstitutionIdentityCryptography cryptography; @Autowired public DigitalCurrencyGenerationModuleGateway(InstitutionKeyService keyService, @@ -49,7 +51,8 @@ public class DigitalCurrencyGenerationModuleGateway { StandardCurrencyBatchMapper standardCurrencyBatchMapper, StandardCurrencyMapper standardCurrencyMapper, IssuanceSourceCurrencyUsageMapper sourceUsageMapper, - JdbcTemplate jdbcTemplate) { + JdbcTemplate jdbcTemplate, + InstitutionIdentityCryptography cryptography) { this.keyService = keyService; this.quotaService = quotaService; this.controlSignatureService = controlSignatureService; @@ -57,12 +60,13 @@ public class DigitalCurrencyGenerationModuleGateway { this.standardCurrencyMapper = standardCurrencyMapper; this.sourceUsageMapper = sourceUsageMapper; this.jdbcTemplate = jdbcTemplate; + this.cryptography = cryptography; } public DigitalCurrencyGenerationModuleGateway(InstitutionKeyService keyService, QuotaControlBitService quotaService, ControlSystemSignatureService controlSignatureService) { - this(keyService, quotaService, controlSignatureService, null, null, null, null); + this(keyService, quotaService, controlSignatureService, null, null, null, null, null); } public String signIssuanceDigest(InstitutionKeySubject subject, String digest) { @@ -78,6 +82,21 @@ public class DigitalCurrencyGenerationModuleGateway { return required(keyService.signCentralBank(subject, required(ownershipText, "数字货币确权原文")), "央行第一私钥签名"); } + public String digest(String originalText) { + if (cryptography == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "SM3 digest service is not configured"); + return cryptography.sm3(required(originalText, "ownership confirmation original text")); + } + + public boolean verifyOwnership(InstitutionKeySubject subject, String originalText, String signature) { + return keyService.verifyCentralBank(subject, required(originalText, "ownership original text"), + required(signature, "central bank ownership signature")); + } + + public String signReceipt(InstitutionKeySubject subject, String digest) { + return required(keyService.signCommercialBank(subject, required(digest, "ownership receipt digest")), + "commercial bank ownership receipt signature"); + } + public String commercialBankSigningKeyId() { return InstitutionKeyService.BANK_SECOND_KEY; } public String centralBankSigningKeyId() { return InstitutionKeyService.CENTRAL_FIRST_KEY; } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyOwnershipConfirmationService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyOwnershipConfirmationService.java index 2949fed..2c8f1e1 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyOwnershipConfirmationService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyOwnershipConfirmationService.java @@ -62,18 +62,111 @@ public class DigitalCurrencyOwnershipConfirmationService { confirmedCoins.add(coin.confirmOwnership(originalText, ownershipSignature, originalText + "|" + ownershipSignature, confirmedAt, actor.getUserId(), actor.getUsername())); } - ReserveDeductionNotification notification = notificationRepository.findByRequestId(requestId) - .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在")); - generationGateway.activateGeneratedCurrencies(subject, requestId, notification.getBankCode(), - batch.getOrganizationId(), confirmedCoins, actor); String keyId = generationGateway.centralBankSigningKeyId(); + String confirmationOriginalText = confirmationOriginalText(batch, confirmedCoins); + String confirmationDigest = generationGateway.digest(confirmationOriginalText); + String centralBankSignature = generationGateway.signOwnership(subject, confirmationDigest); DigitalCurrencyOwnershipConfirmation confirmation = new DigitalCurrencyOwnershipConfirmation(requestId, - batch.getBatchId(), "CONFIRMED", confirmedAt, actor.getUserId(), actor.getUsername(), keyId); - DigitalCurrencyProductionBatch confirmedBatch = batch.ownershipConfirmed(confirmedCoins); + batch.getBatchId(), "CENTRAL_CONFIRMED", confirmedAt, actor.getUserId(), actor.getUsername(), keyId, + confirmationOriginalText, confirmationDigest, centralBankSignature, + null, null, null, null, null, null, null, null); + DigitalCurrencyProductionBatch confirmedBatch = batch.ownershipSigned(confirmedCoins); productionRepository.confirmOwnership(confirmedBatch, confirmation); return DigitalCurrencyProductionBatchView.from(confirmedBatch, DigitalCurrencyOwnershipConfirmationView.from(confirmation)); } + @Transactional + public DigitalCurrencyProductionBatchView verifyCentralBankConfirmation(UUID requestId, IssuanceAuditActor actor, + InstitutionKeySubject subject) { + requireActor(actor); + DigitalCurrencyProductionBatch batch = requireBatch(requestId); + DigitalCurrencyOwnershipConfirmation confirmation = requireConfirmation(requestId); + if (isLegacyCompleted(batch, confirmation)) { + return view(batch, confirmation); + } + if (statusAtLeast(confirmation.getStatus(), "BANK_VERIFIED")) { + return view(batch, confirmation); + } + if (!"CENTRAL_CONFIRMED".equals(confirmation.getStatus())) { + throw validation("央行确权结果尚未生成,商业银行不能执行验签"); + } + String canonicalOriginalText = confirmationOriginalText(batch, batch.getDraftCoins()); + if (!canonicalOriginalText.equals(required(confirmation.getConfirmationOriginalText(), "央行确权原文"))) { + throw validation("央行确权原文与当前生产批次不一致"); + } + String confirmationDigest = required(confirmation.getConfirmationDigest(), "央行确权摘要"); + String recalculatedDigest = required(generationGateway.digest(canonicalOriginalText), "央行确权重算摘要"); + if (!confirmationDigest.equals(recalculatedDigest)) { + throw validation("央行确权摘要与当前生产批次不一致"); + } + if (!generationGateway.verifyOwnership(subject, confirmationDigest, + required(confirmation.getCentralBankSignature(), "央行最终确权签名"))) { + throw validation("央行最终确权签名验证失败"); + } + for (DraftDigitalCurrency coin : batch.getDraftCoins()) { + if (!generationGateway.verifyOwnership(subject, + required(coin.getOwnershipOriginalText(), "币串确权原文"), + required(coin.getOwnershipSignature(), "币串央行确权签名"))) { + throw validation("币串央行确权签名验证失败:" + coin.getCoinId()); + } + } + DigitalCurrencyOwnershipConfirmation verified = confirmation.verified( + Instant.now(), actor.getUserId(), actor.getUsername()); + productionRepository.confirmOwnership(batch, verified); + return view(batch, verified); + } + + @Transactional + public DigitalCurrencyProductionBatchView activateCurrencies(UUID requestId, IssuanceAuditActor actor, + InstitutionKeySubject subject) { + requireActor(actor); + DigitalCurrencyProductionBatch batch = requireBatch(requestId); + DigitalCurrencyOwnershipConfirmation confirmation = requireConfirmation(requestId); + if (isLegacyCompleted(batch, confirmation)) { + return view(batch, confirmation); + } + if (statusAtLeast(confirmation.getStatus(), "CURRENCIES_ACTIVATED")) { + return view(batch, confirmation); + } + if (!"BANK_VERIFIED".equals(confirmation.getStatus())) { + throw validation("商业银行尚未验证央行确权签名,不能激活本地币串"); + } + ReserveDeductionNotification notification = notificationRepository.findByRequestId(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "准备金扣减通知不存在")); + generationGateway.activateGeneratedCurrencies(subject, requestId, notification.getBankCode(), + batch.getOrganizationId(), batch.getDraftCoins(), actor); + DigitalCurrencyProductionBatch activatedBatch = batch.ownershipConfirmed(); + DigitalCurrencyOwnershipConfirmation activated = confirmation.currenciesActivated(); + productionRepository.confirmOwnership(activatedBatch, activated); + return view(activatedBatch, activated); + } + + @Transactional + public DigitalCurrencyProductionBatchView signReceipt(UUID requestId, IssuanceAuditActor actor, + InstitutionKeySubject subject) { + requireActor(actor); + DigitalCurrencyProductionBatch batch = requireBatch(requestId); + DigitalCurrencyOwnershipConfirmation confirmation = requireConfirmation(requestId); + if (isLegacyCompleted(batch, confirmation)) { + return view(batch, confirmation); + } + if ("RECEIPT_SENT".equals(confirmation.getStatus())) return view(batch, confirmation); + if (!"CURRENCIES_ACTIVATED".equals(confirmation.getStatus())) { + throw validation("本地币串尚未在商业银行激活,不能生成确权回执"); + } + Instant receiptAt = Instant.now(); + String originalText = "OWNERSHIP_RECEIPT|" + requestId + "|" + batch.getBatchId() + "|" + + batch.getDraftCoins().size() + "|AVAILABLE|" + receiptAt.toString(); + String digest = generationGateway.digest(originalText); + String signature = generationGateway.signReceipt(subject, digest); + DigitalCurrencyOwnershipConfirmation receipt = confirmation.receiptSent(originalText, digest, signature, + generationGateway.commercialBankSigningKeyId(), receiptAt); + DigitalCurrencyProductionBatch activatedBatch = "OWNERSHIP_CONFIRMED".equals(batch.getStatus()) + ? batch : batch.ownershipConfirmed(); + productionRepository.confirmOwnership(activatedBatch, receipt); + return view(activatedBatch, receipt); + } + @Transactional(readOnly = true) public Optional findOwnership(UUID requestId) { return productionRepository.findOwnershipConfirmationByRequestId(requestId) @@ -105,6 +198,52 @@ public class DigitalCurrencyOwnershipConfirmationService { return required(coin.getSourceCompleteCurrency(), "生成模块来源完整币串"); } + private String confirmationOriginalText(DigitalCurrencyProductionBatch batch, + List confirmedCoins) { + StringBuilder text = new StringBuilder("OWNERSHIP_CONFIRM|") + .append(batch.getRequestId()).append('|').append(batch.getBatchId()) + .append('|').append(batch.getOrganizationId()).append('|').append(batch.getCurrency()) + .append('|').append(batch.getTotalAmount().toPlainString()); + for (DraftDigitalCurrency coin : confirmedCoins) { + text.append('|').append(coin.getSequenceNo()).append(':').append(coin.getCoinId()) + .append(':').append(coin.getOwnershipSignature()); + } + return text.toString(); + } + + private DigitalCurrencyProductionBatch requireBatch(UUID requestId) { + return productionRepository.findByRequestId(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数字货币生产批次不存在")); + } + + private DigitalCurrencyOwnershipConfirmation requireConfirmation(UUID requestId) { + return productionRepository.findOwnershipConfirmationByRequestId(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "央行确权结果不存在")); + } + + private DigitalCurrencyProductionBatchView view(DigitalCurrencyProductionBatch batch, + DigitalCurrencyOwnershipConfirmation confirmation) { + return DigitalCurrencyProductionBatchView.from(batch, + DigitalCurrencyOwnershipConfirmationView.from(confirmation)); + } + + private boolean statusAtLeast(String actual, String expected) { + String[] states = {"CENTRAL_CONFIRMED", "BANK_VERIFIED", "CURRENCIES_ACTIVATED", "RECEIPT_SENT"}; + int actualIndex = java.util.Arrays.asList(states).indexOf(actual); + int expectedIndex = java.util.Arrays.asList(states).indexOf(expected); + return actualIndex >= expectedIndex && expectedIndex >= 0; + } + + private boolean isLegacyCompleted(DigitalCurrencyProductionBatch batch, + DigitalCurrencyOwnershipConfirmation confirmation) { + return "OWNERSHIP_CONFIRMED".equals(batch.getStatus()) + && "CONFIRMED".equals(confirmation.getStatus()); + } + + private BusinessException validation(String message) { + return new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } + private String required(String value, String field) { if (blank(value)) throw new BusinessException(ErrorCode.VALIDATION_ERROR, field + "不能为空"); return value; diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java index 768b92a..4cd6932 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java @@ -43,7 +43,9 @@ public class IssuanceTrainingActionService { "04:generate-reserve-deduction-request", "05:confirm-receipt", "05:query-account", "05:verify-deduction", "05:execute-deduction", "05:generate-balance-notice", "05:send-balance-notice", "06:confirm-production-receipt", - "06:generate-production-digest", "07:load-ownership-result"); + "06:generate-production-digest", "07:central-confirm-ownership", + "07:verify-central-bank-signature", "07:activate-bank-currencies", + "07:sign-bank-receipt", "07:load-ownership-result"); private final ExperimentAttemptService attempts; private final ObjectMapper objectMapper; @@ -167,8 +169,17 @@ public class IssuanceTrainingActionService { case "06:generate-production-digest": return completed(productionService.find(requestId) .orElseThrow(() -> validation("请先生成数字货币生产批次"))); - case "07:load-ownership-result": + case "07:central-confirm-ownership": return completed(ownershipService.confirm(requestId, actor, keySubject)); + case "07:verify-central-bank-signature": + return completed(ownershipService.verifyCentralBankConfirmation(requestId, actor, keySubject)); + case "07:activate-bank-currencies": + return completed(ownershipService.activateCurrencies(requestId, actor, keySubject)); + case "07:sign-bank-receipt": + return completed(ownershipService.signReceipt(requestId, actor, keySubject)); + case "07:load-ownership-result": + return completed(ownershipService.findProduction(requestId) + .orElseThrow(() -> validation("数字货币确权结果不存在"))); default: throw validation("不支持的发行实验动作:" + key); } diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyOwnershipConfirmation.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyOwnershipConfirmation.java index 46c4ebe..a03cb8a 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyOwnershipConfirmation.java +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyOwnershipConfirmation.java @@ -12,16 +12,72 @@ public final class DigitalCurrencyOwnershipConfirmation { private final String confirmedByUserId; private final String confirmedBy; private final String signingKeyId; + private final String confirmationOriginalText; + private final String confirmationDigest; + private final String centralBankSignature; + private final Instant verifiedAt; + private final String verifiedByUserId; + private final String verifiedBy; + private final String receiptOriginalText; + private final String receiptDigest; + private final String receiptSignature; + private final String receiptSigningKeyId; + private final Instant receiptAt; public DigitalCurrencyOwnershipConfirmation(UUID requestId, String batchId, String status, Instant confirmedAt, String confirmedByUserId, String confirmedBy, String signingKeyId) { - this.requestId = Objects.requireNonNull(requestId, "发行请求标识不能为空"); - this.batchId = Objects.requireNonNull(batchId, "生产批次不能为空"); - this.status = Objects.requireNonNull(status, "确权状态不能为空"); - this.confirmedAt = Objects.requireNonNull(confirmedAt, "确权时间不能为空"); - this.confirmedByUserId = Objects.requireNonNull(confirmedByUserId, "确权操作人 ID 不能为空"); - this.confirmedBy = Objects.requireNonNull(confirmedBy, "确权操作人名称不能为空"); - this.signingKeyId = Objects.requireNonNull(signingKeyId, "央行确权密钥标识不能为空"); + this(requestId, batchId, status, confirmedAt, confirmedByUserId, confirmedBy, signingKeyId, + null, null, null, null, null, null, null, null, null, null, null); + } + + public DigitalCurrencyOwnershipConfirmation(UUID requestId, String batchId, String status, Instant confirmedAt, + String confirmedByUserId, String confirmedBy, String signingKeyId, + String confirmationOriginalText, String confirmationDigest, + String centralBankSignature, Instant verifiedAt, + String verifiedByUserId, String verifiedBy, + String receiptOriginalText, String receiptDigest, + String receiptSignature, String receiptSigningKeyId, + Instant receiptAt) { + this.requestId = Objects.requireNonNull(requestId, "requestId"); + this.batchId = Objects.requireNonNull(batchId, "batchId"); + this.status = Objects.requireNonNull(status, "status"); + this.confirmedAt = Objects.requireNonNull(confirmedAt, "confirmedAt"); + this.confirmedByUserId = Objects.requireNonNull(confirmedByUserId, "confirmedByUserId"); + this.confirmedBy = Objects.requireNonNull(confirmedBy, "confirmedBy"); + this.signingKeyId = Objects.requireNonNull(signingKeyId, "signingKeyId"); + this.confirmationOriginalText = confirmationOriginalText; + this.confirmationDigest = confirmationDigest; + this.centralBankSignature = centralBankSignature; + this.verifiedAt = verifiedAt; + this.verifiedByUserId = verifiedByUserId; + this.verifiedBy = verifiedBy; + this.receiptOriginalText = receiptOriginalText; + this.receiptDigest = receiptDigest; + this.receiptSignature = receiptSignature; + this.receiptSigningKeyId = receiptSigningKeyId; + this.receiptAt = receiptAt; + } + + public DigitalCurrencyOwnershipConfirmation verified(Instant time, String userId, String username) { + return new DigitalCurrencyOwnershipConfirmation(requestId, batchId, "BANK_VERIFIED", confirmedAt, + confirmedByUserId, confirmedBy, signingKeyId, confirmationOriginalText, confirmationDigest, + centralBankSignature, time, userId, username, receiptOriginalText, receiptDigest, + receiptSignature, receiptSigningKeyId, receiptAt); + } + + public DigitalCurrencyOwnershipConfirmation currenciesActivated() { + return new DigitalCurrencyOwnershipConfirmation(requestId, batchId, "CURRENCIES_ACTIVATED", confirmedAt, + confirmedByUserId, confirmedBy, signingKeyId, confirmationOriginalText, confirmationDigest, + centralBankSignature, verifiedAt, verifiedByUserId, verifiedBy, receiptOriginalText, + receiptDigest, receiptSignature, receiptSigningKeyId, receiptAt); + } + + public DigitalCurrencyOwnershipConfirmation receiptSent(String originalText, String digest, String signature, + String receiptKeyId, Instant time) { + return new DigitalCurrencyOwnershipConfirmation(requestId, batchId, "RECEIPT_SENT", confirmedAt, + confirmedByUserId, confirmedBy, signingKeyId, confirmationOriginalText, confirmationDigest, + centralBankSignature, verifiedAt, verifiedByUserId, verifiedBy, originalText, digest, + signature, receiptKeyId, time); } public UUID getRequestId() { return requestId; } @@ -31,4 +87,15 @@ public final class DigitalCurrencyOwnershipConfirmation { public String getConfirmedByUserId() { return confirmedByUserId; } public String getConfirmedBy() { return confirmedBy; } public String getSigningKeyId() { return signingKeyId; } + public String getConfirmationOriginalText() { return confirmationOriginalText; } + public String getConfirmationDigest() { return confirmationDigest; } + public String getCentralBankSignature() { return centralBankSignature; } + public Instant getVerifiedAt() { return verifiedAt; } + public String getVerifiedByUserId() { return verifiedByUserId; } + public String getVerifiedBy() { return verifiedBy; } + public String getReceiptOriginalText() { return receiptOriginalText; } + public String getReceiptDigest() { return receiptDigest; } + public String getReceiptSignature() { return receiptSignature; } + public String getReceiptSigningKeyId() { return receiptSigningKeyId; } + public Instant getReceiptAt() { return receiptAt; } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyProductionBatch.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyProductionBatch.java index bd8c611..70594dd 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyProductionBatch.java +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/DigitalCurrencyProductionBatch.java @@ -81,4 +81,12 @@ public final class DigitalCurrencyProductionBatch { totalAmount, "OWNERSHIP_CONFIRMED", createdAt, createdByUserId, createdBy, quotaControlBitId, controlSystemSignatureId, completeControlBit, controlSystemSignature, confirmedCoins); } + + public DigitalCurrencyProductionBatch ownershipSigned(List signedCoins) { + java.util.ArrayList pendingCoins = new java.util.ArrayList(); + for (DraftDigitalCurrency coin : signedCoins) pendingCoins.add(coin.withStatus("OWNERSHIP_SIGNED")); + return new DigitalCurrencyProductionBatch(batchId, requestId, deductionTransactionId, organizationId, currency, + totalAmount, "OWNERSHIP_SIGNED", createdAt, createdByUserId, createdBy, quotaControlBitId, + controlSystemSignatureId, completeControlBit, controlSystemSignature, pendingCoins); + } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/DigitalCurrencyOwnershipConfirmationEntity.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/DigitalCurrencyOwnershipConfirmationEntity.java index 4080dad..da12b38 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/DigitalCurrencyOwnershipConfirmationEntity.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/DigitalCurrencyOwnershipConfirmationEntity.java @@ -19,4 +19,15 @@ public class DigitalCurrencyOwnershipConfirmationEntity { private String confirmedByUserId; private String confirmedBy; private String signingKeyId; + private String confirmationOriginalText; + private String confirmationDigest; + private String centralBankSignature; + private LocalDateTime verifiedAt; + private String verifiedByUserId; + private String verifiedBy; + private String receiptOriginalText; + private String receiptDigest; + private String receiptSignature; + private String receiptSigningKeyId; + private LocalDateTime receiptAt; } diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisDigitalCurrencyProductionRepository.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisDigitalCurrencyProductionRepository.java index caf58dc..315c295 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisDigitalCurrencyProductionRepository.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisDigitalCurrencyProductionRepository.java @@ -80,7 +80,19 @@ public class MybatisDigitalCurrencyProductionRepository implements DigitalCurren entity.setConfirmedByUserId(confirmation.getConfirmedByUserId()); entity.setConfirmedBy(confirmation.getConfirmedBy()); entity.setSigningKeyId(confirmation.getSigningKeyId()); - ownershipMapper.insert(entity); + entity.setConfirmationOriginalText(confirmation.getConfirmationOriginalText()); + entity.setConfirmationDigest(confirmation.getConfirmationDigest()); + entity.setCentralBankSignature(confirmation.getCentralBankSignature()); + entity.setVerifiedAt(local(confirmation.getVerifiedAt())); + entity.setVerifiedByUserId(confirmation.getVerifiedByUserId()); + entity.setVerifiedBy(confirmation.getVerifiedBy()); + entity.setReceiptOriginalText(confirmation.getReceiptOriginalText()); + entity.setReceiptDigest(confirmation.getReceiptDigest()); + entity.setReceiptSignature(confirmation.getReceiptSignature()); + entity.setReceiptSigningKeyId(confirmation.getReceiptSigningKeyId()); + entity.setReceiptAt(local(confirmation.getReceiptAt())); + if (ownershipMapper.selectById(entity.getRequestId()) == null) ownershipMapper.insert(entity); + else ownershipMapper.updateById(entity); } @Override @@ -94,7 +106,11 @@ public class MybatisDigitalCurrencyProductionRepository implements DigitalCurren } return Optional.of(new DigitalCurrencyOwnershipConfirmation(UUID.fromString(entity.getRequestId()), entity.getBatchId(), entity.getStatus(), entity.getConfirmedAt().toInstant(ZoneOffset.UTC), entity.getConfirmedByUserId(), - entity.getConfirmedBy(), entity.getSigningKeyId())); + entity.getConfirmedBy(), entity.getSigningKeyId(), entity.getConfirmationOriginalText(), + entity.getConfirmationDigest(), entity.getCentralBankSignature(), instant(entity.getVerifiedAt()), + entity.getVerifiedByUserId(), entity.getVerifiedBy(), entity.getReceiptOriginalText(), + entity.getReceiptDigest(), entity.getReceiptSignature(), entity.getReceiptSigningKeyId(), + instant(entity.getReceiptAt()))); } private DigitalCurrencyProductionBatchEntity toEntity(DigitalCurrencyProductionBatch batch) { @@ -140,4 +156,12 @@ public class MybatisDigitalCurrencyProductionRepository implements DigitalCurren entity.getCompleteCurrency(), entity.getConfirmedAt() == null ? null : entity.getConfirmedAt().toInstant(ZoneOffset.UTC), entity.getConfirmedByUserId(), entity.getConfirmedBy()); } + + private java.time.LocalDateTime local(java.time.Instant value) { + return value == null ? null : value.atOffset(ZoneOffset.UTC).toLocalDateTime(); + } + + private java.time.Instant instant(java.time.LocalDateTime value) { + return value == null ? null : value.toInstant(ZoneOffset.UTC); + } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java index 485bb5e..ab60cfc 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java +++ b/src/main/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceController.java @@ -178,7 +178,9 @@ public class CentralBankIssuanceController { CurrentUser currentUser = currentUserService.getCurrentUser(); IssuanceAuditActor actor = actor(currentUser); accessGuard.requireRequestOwner(id, actor); - runAction(id, "07", "load-ownership-result", currentUser); + String[] actions = {"central-confirm-ownership", "verify-central-bank-signature", + "activate-bank-currencies", "sign-bank-receipt", "load-ownership-result"}; + for (String action : actions) runAction(id, "07", action, currentUser); return ok(digitalCurrencyOwnershipConfirmationService.findProduction(id).orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "数字货币确权结果不存在"))); } diff --git a/src/main/resources/db/manual/issuance_generation_module_integration_upgrade.sql b/src/main/resources/db/manual/issuance_generation_module_integration_upgrade.sql index 3e8f1d0..402b5f1 100644 --- a/src/main/resources/db/manual/issuance_generation_module_integration_upgrade.sql +++ b/src/main/resources/db/manual/issuance_generation_module_integration_upgrade.sql @@ -22,6 +22,19 @@ ALTER TABLE draft_digital_currency ALTER TABLE digital_currency_ownership_confirmation ADD COLUMN signing_key_id VARCHAR(64) NULL; +ALTER TABLE digital_currency_ownership_confirmation + ADD COLUMN confirmation_original_text TEXT NULL, + ADD COLUMN confirmation_digest CHAR(64) NULL, + ADD COLUMN central_bank_signature VARCHAR(512) NULL, + ADD COLUMN verified_at TIMESTAMP NULL, + ADD COLUMN verified_by_user_id VARCHAR(36) NULL, + ADD COLUMN verified_by VARCHAR(64) NULL, + ADD COLUMN receipt_original_text TEXT NULL, + ADD COLUMN receipt_digest CHAR(64) NULL, + ADD COLUMN receipt_signature VARCHAR(512) NULL, + ADD COLUMN receipt_signing_key_id VARCHAR(64) NULL, + ADD COLUMN receipt_at TIMESTAMP NULL; + ALTER TABLE standard_currency ADD COLUMN issuance_request_id CHAR(36) NULL, ADD COLUMN reserved_at DATETIME NULL, diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 4c7595f..f3c4a01 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -306,6 +306,17 @@ CREATE TABLE IF NOT EXISTS digital_currency_ownership_confirmation ( confirmed_by_user_id VARCHAR(36) NOT NULL, confirmed_by VARCHAR(64) NOT NULL, signing_key_id VARCHAR(64) NOT NULL, + confirmation_original_text TEXT NULL, + confirmation_digest CHAR(64) NULL, + central_bank_signature VARCHAR(512) NULL, + verified_at TIMESTAMP NULL, + verified_by_user_id VARCHAR(36) NULL, + verified_by VARCHAR(64) NULL, + receipt_original_text TEXT NULL, + receipt_digest CHAR(64) NULL, + receipt_signature VARCHAR(512) NULL, + receipt_signing_key_id VARCHAR(64) NULL, + receipt_at TIMESTAMP NULL, CONSTRAINT fk_digital_currency_ownership_batch FOREIGN KEY (batch_id) REFERENCES digital_currency_production_batch(batch_id) ); diff --git a/src/test/java/com/yau/digitalrmb/businessknowledge/BusinessKnowledgeControllerTest.java b/src/test/java/com/yau/digitalrmb/businessknowledge/BusinessKnowledgeControllerTest.java index 9851769..451c030 100644 --- a/src/test/java/com/yau/digitalrmb/businessknowledge/BusinessKnowledgeControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/businessknowledge/BusinessKnowledgeControllerTest.java @@ -73,6 +73,6 @@ class BusinessKnowledgeControllerTest { .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487"))) .contentType(MediaType.APPLICATION_JSON).content(request)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("FORBIDDEN")); + .andExpect(jsonPath("$.code").value(403)); } } diff --git a/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java b/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java index db85e17..20a9ed2 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java @@ -27,6 +27,11 @@ class ExchangeOrderTest { new OwnershipConfirmation("TXN_1", "DIGEST", "CB_SIGNATURE", new BigDecimal("5000.00")), Instant.parse("2026-08-12T01:00:10Z")); + assertThat(order.getStatus()).isEqualTo(ExchangeStatus.CENTRAL_CONFIRMED); + assertThat(order.getWalletBalanceAfter()).isNull(); + order.verifyCentralBankConfirmation(); + assertThat(order.getStatus()).isEqualTo(ExchangeStatus.CONFIRMATION_VERIFIED); + order.completeWalletCredit(new BigDecimal("5000.00"), Instant.parse("2026-08-12T01:00:10Z")); assertThat(order.getStatus()).isEqualTo(ExchangeStatus.SUCCESS); assertThat(order.getCoinCount()).isEqualTo(50); assertThat(order.getWalletBalanceAfter()).isEqualByComparingTo("5000.00"); 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 0317ac0..9f16107 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 @@ -273,6 +273,73 @@ class ExchangeControllerTest { String.class, exchangeId)).isEqualTo("RELEASED"); } + @Test + void ownershipTransferDoesNotCreditWalletBeforeCommercialBankVerifiesCentralSignature() throws Exception { + String[] values = createHeldExchange(); + String attemptId = values[0]; + String exchangeId = values[1]; + executeAction(attemptId, "05", "receive-debit-voucher", "COMPLETED"); + executeAction(attemptId, "05", "reserve-coins", "COMPLETED"); + String[] actions = {"adjust-bank-ledger", "generate-transfer-request", "receive-transfer-request", + "reply-transfer-request", "verify-bank-signature", "recalculate-transfer-digest", + "compare-transfer-digest", "verify-coin-ownership", "confirm-transfer", + "execute-ownership-transfer"}; + for (String action : actions) executeAction(attemptId, "05", action, "COMPLETED"); + + assertThat(jdbc.queryForObject("SELECT status FROM currency_exchange_order WHERE id=?", + String.class, exchangeId)).isEqualTo("CENTRAL_CONFIRMED"); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id='WALLET_EXCHANGE_TEST'", + java.math.BigDecimal.class)).isEqualByComparingTo("0.00"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_balance_ledger WHERE exchange_id=?", + Integer.class, exchangeId)).isZero(); + + assertThatThrownBy(() -> exchangeService.creditWallet(java.util.UUID.fromString(exchangeId), + new ExchangeActor(USER_ID, "test", 1999L, 2999L))).isInstanceOf(RuntimeException.class); + + String[] confirmationActions = {"generate-confirmation-digest", "central-bank-sign", + "package-confirmation", "return-confirmation", "verify-central-bank-signature"}; + for (String action : confirmationActions) executeAction(attemptId, "05", action, "COMPLETED"); + assertThat(jdbc.queryForObject("SELECT status FROM currency_exchange_order WHERE id=?", + String.class, exchangeId)).isEqualTo("CONFIRMATION_VERIFIED"); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id='WALLET_EXCHANGE_TEST'", + java.math.BigDecimal.class)).isEqualByComparingTo("0.00"); + + executeAction(attemptId, "05", "record-wallet-ledger", "COMPLETED"); + assertThat(jdbc.queryForObject("SELECT status FROM currency_exchange_order WHERE id=?", + String.class, exchangeId)).isEqualTo("SUCCESS"); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id='WALLET_EXCHANGE_TEST'", + java.math.BigDecimal.class)).isEqualByComparingTo("200.00"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_balance_ledger WHERE exchange_id=?", + Integer.class, exchangeId)).isEqualTo(1); + } + + @Test + void rejectsCentralConfirmationWhenItsOriginalTextNoLongerMatchesTheSignedDigest() throws Exception { + String[] values = createHeldExchange(); + String attemptId = values[0]; + String exchangeId = values[1]; + executeAction(attemptId, "05", "receive-debit-voucher", "COMPLETED"); + executeAction(attemptId, "05", "reserve-coins", "COMPLETED"); + String[] actions = {"adjust-bank-ledger", "generate-transfer-request", "receive-transfer-request", + "reply-transfer-request", "verify-bank-signature", "recalculate-transfer-digest", + "compare-transfer-digest", "verify-coin-ownership", "confirm-transfer", + "execute-ownership-transfer"}; + for (String action : actions) executeAction(attemptId, "05", action, "COMPLETED"); + jdbc.update("UPDATE currency_exchange_order SET confirmation_original_text=? WHERE id=?", + "TRANSFER_CONFIRM|TAMPERED", exchangeId); + + assertThatThrownBy(() -> exchangeService.verifyCentralBankConfirmation( + java.util.UUID.fromString(exchangeId), new ExchangeActor(USER_ID, "test", 1999L, 2999L))) + .isInstanceOf(RuntimeException.class); + + assertThat(jdbc.queryForObject("SELECT status FROM currency_exchange_order WHERE id=?", + String.class, exchangeId)).isEqualTo("CENTRAL_CONFIRMED"); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id='WALLET_EXCHANGE_TEST'", + java.math.BigDecimal.class)).isEqualByComparingTo("0.00"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_balance_ledger WHERE exchange_id=?", + Integer.class, exchangeId)).isZero(); + } + @Test void cancellingAfterCoinReservationReleasesBothCoinLocksAndDebitHold() throws Exception { String[] values = createHeldExchange(); diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java index 2b2936e..94b0ea2 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java @@ -70,9 +70,10 @@ class ExchangeTrainingAttemptControllerTest { {"05", "verify-bank-signature"}, {"05", "recalculate-transfer-digest"}, {"05", "compare-transfer-digest"}, {"05", "verify-coin-ownership"}, {"05", "confirm-transfer"}, {"05", "execute-ownership-transfer"}, - {"05", "record-wallet-ledger"}, {"05", "generate-confirmation-digest"}, - {"05", "central-bank-sign"}, {"05", "package-confirmation"}, - {"05", "return-confirmation"}, {"05", "notify-user"}, + {"05", "generate-confirmation-digest"}, {"05", "central-bank-sign"}, + {"05", "package-confirmation"}, {"05", "return-confirmation"}, + {"05", "verify-central-bank-signature"}, {"05", "record-wallet-ledger"}, + {"05", "notify-user"}, {"05", "close-notification"}, {"05", "user-confirm"} }; for (String[] action : actions) { diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationControllerTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationControllerTest.java index c7f9a2d..cbf4ccc 100644 --- a/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/CurrencyGenerationVerificationControllerTest.java @@ -96,7 +96,7 @@ class CurrencyGenerationVerificationControllerTest { mvc.perform(post(BASE_PATH + "/steps/verify-signature").header("Authorization", authorization) .contentType("application/json").content(step(verificationId, "publicKey", bankPublicKey))) - .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SIGNATURE_VERIFIED")); + .andExpect(status().isOk()).andExpect(jsonPath("$.data.valid").value(true)); String userInput = "用户输入的请求原文"; mvc.perform(post(BASE_PATH + "/steps/calculate-digest").header("Authorization", authorization) .contentType("application/json").content(digestStep(verificationId, userInput))) @@ -110,11 +110,11 @@ class CurrencyGenerationVerificationControllerTest { .contentType("application/json").content("{\"requestId\":" + requestId + "}")) .andExpect(status().isOk()).andExpect(jsonPath("$.data.verificationId").value(verificationId)) .andExpect(jsonPath("$.data.status").value("RECEIVED")) - .andExpect(jsonPath("$.data.signatureValid").value(org.hamcrest.Matchers.nullValue())) - .andExpect(jsonPath("$.data.recomputedDigest").value(org.hamcrest.Matchers.nullValue())); + .andExpect(jsonPath("$.data.signatureValid").doesNotExist()) + .andExpect(jsonPath("$.data.recomputedDigest").doesNotExist()); mvc.perform(post(BASE_PATH + "/steps/verify-signature").header("Authorization", authorization) .contentType("application/json").content(step(verificationId, "publicKey", bankPublicKey))) - .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SIGNATURE_VERIFIED")); + .andExpect(status().isOk()).andExpect(jsonPath("$.data.valid").value(true)); mvc.perform(post(BASE_PATH + "/steps/calculate-digest").header("Authorization", authorization) .contentType("application/json").content(digestStep(verificationId, "ORG_3A4B5C6D7E8F|1589.85|SYS_DC_002|20260806190100"))) @@ -124,8 +124,7 @@ class CurrencyGenerationVerificationControllerTest { .andExpect(jsonPath("$.data.status").doesNotExist()); mvc.perform(post(BASE_PATH + "/steps/match-digest").header("Authorization", authorization) .contentType("application/json").content(step(verificationId))) - .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("DIGEST_MATCHED")) - .andExpect(jsonPath("$.data.digestMatches").value(true)); + .andExpect(status().isOk()).andExpect(jsonPath("$.data.valid").value(true)); mvc.perform(post(BASE_PATH + "/steps/verify-quota").header("Authorization", authorization) .contentType("application/json").content(step(verificationId))) .andExpect(status().isOk()) @@ -153,8 +152,7 @@ class CurrencyGenerationVerificationControllerTest { mvc.perform(post(BASE_PATH + "/steps/match-digest").header("Authorization", authorization) .contentType("application/json").content(step(verificationId))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("QUOTA_VERIFIED")) - .andExpect(jsonPath("$.data.digestMatches").value(true)); + .andExpect(jsonPath("$.data.valid").value(true)); mvc.perform(post(BASE_PATH + "/steps/confirm").header("Authorization", authorization) .contentType("application/json").content(confirmStep(verificationId, requestMapper.selectById(requestId).getRequestDigest(), centralPrivateKey))) .andExpect(status().isOk()) @@ -183,8 +181,8 @@ class CurrencyGenerationVerificationControllerTest { .andExpect(jsonPath("$.data.quotaSufficient").value(true)); mvc.perform(post(BASE_PATH + "/steps/package-response").header("Authorization", authorization) .contentType("application/json").content(step(verificationId))) - .andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PACKAGED")) - .andExpect(jsonPath("$.data.confirmationMessage").value(org.hamcrest.Matchers.allOf( + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers.containsString("\"requestId\":\"REQ_"), org.hamcrest.Matchers.containsString("\"status\":\"CONFIRMED\""), org.hamcrest.Matchers.containsString("\"cbSignature\":\"CB_SIG_" + centralBankSignature + "\""), @@ -195,12 +193,11 @@ class CurrencyGenerationVerificationControllerTest { mvc.perform(post(BASE_PATH + "/steps/receive-confirmation").header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.confirmationStatus").value("已确认")) - .andExpect(jsonPath("$.data.requestId").value( - org.hamcrest.Matchers.matchesPattern("REQ_\\d{8}_\\d{3}"))) - .andExpect(jsonPath("$.data.status").value("CONFIRMED")) - .andExpect(jsonPath("$.data.cbSignature").value("CB_SIG_" + centralBankSignature)) - .andExpect(jsonPath("$.data.confirmTime").value(confirmationTime)) - .andExpect(jsonPath("$.data.length()").value(5)); + .andExpect(jsonPath("$.data.requestId").doesNotExist()) + .andExpect(jsonPath("$.data.status").doesNotExist()) + .andExpect(jsonPath("$.data.cbSignature").doesNotExist()) + .andExpect(jsonPath("$.data.confirmTime").doesNotExist()) + .andExpect(jsonPath("$.data.length()").value(1)); mvc.perform(get(BASE_PATH + "/current").header("Authorization", authorization)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.verificationId").value(verificationId)) .andExpect(jsonPath("$.data.status").value("RESPONSE_RETURNED")); @@ -227,11 +224,10 @@ class CurrencyGenerationVerificationControllerTest { mvc.perform(post(BASE_PATH + "/steps/receive-confirmation").header("Authorization", authorization)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.confirmationStatus").value("待确认")) - .andExpect(jsonPath("$.data.requestId").value( - org.hamcrest.Matchers.matchesPattern("REQ_\\d{8}_\\d{3}"))) - .andExpect(jsonPath("$.data.status").value(org.hamcrest.Matchers.nullValue())) - .andExpect(jsonPath("$.data.cbSignature").value(org.hamcrest.Matchers.nullValue())) - .andExpect(jsonPath("$.data.confirmTime").value(org.hamcrest.Matchers.nullValue())); + .andExpect(jsonPath("$.data.requestId").doesNotExist()) + .andExpect(jsonPath("$.data.status").doesNotExist()) + .andExpect(jsonPath("$.data.cbSignature").doesNotExist()) + .andExpect(jsonPath("$.data.confirmTime").doesNotExist()); assertThat(scoreMapper.selectList(null)).isEmpty(); } diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java index aa42908..2eeef8d 100644 --- a/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/InstitutionIdentifierControllerTest.java @@ -319,7 +319,7 @@ class InstitutionIdentifierControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"" + malformedPrivateKey + "\"}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "签名必须选择商业银行第二私钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); @@ -382,7 +382,7 @@ class InstitutionIdentifierControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"bankCode\":\"BKCHCNBJ20001|20260730143218\"}")) .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -392,7 +392,7 @@ class InstitutionIdentifierControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG\"}")) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.code").value(404)) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "当前没有待签名的本轮机构标识数据"))); @@ -516,13 +516,12 @@ class InstitutionIdentifierControllerTest { mvc.perform(post(BASE_PATH + "/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("PACKAGED")); + .andExpect(jsonPath("$.data.message").isNotEmpty()); mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("SUBMITTED")) - .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); + .andExpect(jsonPath("$.data.status").value("SUBMITTED")); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) @@ -598,9 +597,7 @@ class InstitutionIdentifierControllerTest { mvc.perform(post(BASE_PATH + "/applications/feedback") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.applicationStatus").value("FEEDBACKED")) - .andExpect(jsonPath("$.data.feedbackStatus").value(2)) - .andExpect(jsonPath("$.data.feedbackStatusText").value("已反馈")); + .andExpect(jsonPath("$.data.status").value("FEEDBACK_SENT")); mvc.perform(get(BASE_PATH + "/applications/commercial-bank") .header("Authorization", authorization)) @@ -632,8 +629,9 @@ class InstitutionIdentifierControllerTest { Number.class, issuedOrg); java.util.Map storedRequest = jdbcTemplate.queryForMap( "SELECT institution_identifier, amount, delivery_node_code, request_timestamp, " - + "request_original_text, request_digest FROM currency_generation_request WHERE id = ?", - currencyRequestId.longValue()); + + "request_original_text, request_digest FROM currency_generation_request " + + "WHERE institution_identifier = ? AND deleted = FALSE", + issuedOrg); assertThat(storedRequest.get("institution_identifier")).isEqualTo(issuedOrg); assertThat(storedRequest.get("amount").toString()).isEqualTo("1589.85"); assertThat(storedRequest.get("delivery_node_code")).isEqualTo("SYS_DC_001"); @@ -700,8 +698,7 @@ class InstitutionIdentifierControllerTest { mvc.perform(post(BASE_PATH + "/currency-requests/steps/package") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("PACKAGED")) - .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.allOf( + .andExpect(jsonPath("$.data.message").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers.containsString("\"requestId\":\"REQ_20260730_"), org.hamcrest.Matchers.containsString("\"orgId\":\"" + issuedOrg + "\""), org.hamcrest.Matchers.containsString("\"amount\":\"2000.00\""), @@ -709,13 +706,13 @@ class InstitutionIdentifierControllerTest { org.hamcrest.Matchers.containsString("\"timestamp\":\"20260730160000\""), org.hamcrest.Matchers.containsString("\"digest\":"), org.hamcrest.Matchers.containsString("\"signature\":")))) - .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.not( + .andExpect(jsonPath("$.data.message").value(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("\"originalText\"")))) - .andExpect(jsonPath("$.data.requestMessage").value(org.hamcrest.Matchers.not( + .andExpect(jsonPath("$.data.message").value(org.hamcrest.Matchers.not( org.hamcrest.Matchers.containsString("\"keyId\"")))); - jdbcTemplate.update("UPDATE currency_generation_request SET deleted = TRUE WHERE id = ?", - currencyRequestId.longValue()); + jdbcTemplate.update("UPDATE currency_generation_request SET deleted = TRUE " + + "WHERE institution_identifier = ?", issuedOrg); mvc.perform(get(BASE_PATH + "/currency-requests/page") .header("Authorization", authorization)) .andExpect(status().isNotFound()); @@ -758,8 +755,7 @@ class InstitutionIdentifierControllerTest { mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("SUBMITTED")) - .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); + .andExpect(jsonPath("$.data.status").value("SUBMITTED")); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) @@ -767,7 +763,7 @@ class InstitutionIdentifierControllerTest { .content("{\"publicKey\":\"" + centralBankPublicKey() + "\",\"digest\":\"" + applicationMapper.selectById(applicationId).getDigest() + "\"}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "解密验证必须选择当前主体备案的商业银行第二公钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); @@ -824,7 +820,7 @@ class InstitutionIdentifierControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"privateKey\":\"WRONG\"}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( "签名确权必须使用中央银行第一私钥"))) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("第1次错误"))); @@ -868,8 +864,7 @@ class InstitutionIdentifierControllerTest { mvc.perform(post(BASE_PATH + "/steps/send") .header("Authorization", authorization)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("SUBMITTED")) - .andExpect(jsonPath("$.data.sentToCentralBank").value(true)); + .andExpect(jsonPath("$.data.status").value("SUBMITTED")); mvc.perform(post(BASE_PATH + "/applications/decryption-verification") .header("Authorization", authorization) diff --git a/src/test/java/com/yau/digitalrmb/institutionidentity/TransactionInformationIdentifierControllerTest.java b/src/test/java/com/yau/digitalrmb/institutionidentity/TransactionInformationIdentifierControllerTest.java index cc603df..45ff6ea 100644 --- a/src/test/java/com/yau/digitalrmb/institutionidentity/TransactionInformationIdentifierControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/institutionidentity/TransactionInformationIdentifierControllerTest.java @@ -764,7 +764,7 @@ class TransactionInformationIdentifierControllerTest { "\"transactionIdentifier\":\"TXN_ABC\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( - "步骤七尚未完成商业银行接收"))); + "步骤七尚未发送控制系统签名反馈"))); assertThat(quotaControlBitMapper.selectList(null)).isEmpty(); } @@ -811,7 +811,10 @@ class TransactionInformationIdentifierControllerTest { .contentType("application/json") .content("{\"originalText\":\"1589.85|ORG_3A4B5C6D7E8F|TXN_" + "7A8B9C0D1E2F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1A2B3C4D5E6F\"}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString( + "当前没有额度请求记录"))); assertThat(transactionMapper.selectList(null)).isEmpty(); } diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java index fc13151..ffe627c 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java @@ -25,7 +25,10 @@ import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.ArgumentMatchers.anyList; @@ -73,7 +76,7 @@ class DigitalCurrencyProductionAndOwnershipServiceTest { } @Test - void signsEveryCoinWithTheCentralBankFirstKeyAndPersistsTheOperator() { + void centralConfirmationSignsCoinsButDoesNotActivateThemBeforeCommercialBankVerification() { UUID requestId = UUID.randomUUID(); DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); ReserveDeductionNotificationRepository notificationRepository = mock(ReserveDeductionNotificationRepository.class); @@ -90,6 +93,7 @@ class DigitalCurrencyProductionAndOwnershipServiceTest { .thenReturn(Optional.empty()); when(notificationRepository.findByRequestId(requestId)).thenReturn(Optional.of(notification(requestId))); when(gateway.centralBankSigningKeyId()).thenReturn(InstitutionKeyService.CENTRAL_FIRST_KEY); + when(gateway.digest(org.mockito.ArgumentMatchers.anyString())).thenReturn("BATCH_DIGEST"); when(gateway.signOwnership(org.mockito.ArgumentMatchers.eq(subject), org.mockito.ArgumentMatchers.anyString())) .thenReturn("OWNERSHIP_SIGNATURE"); DigitalCurrencyOwnershipConfirmationService service = @@ -97,8 +101,12 @@ class DigitalCurrencyProductionAndOwnershipServiceTest { DigitalCurrencyProductionBatchView result = service.confirm(requestId, actor, subject); - assertThat(result.getStatus()).isEqualTo("OWNERSHIP_CONFIRMED"); + assertThat(result.getStatus()).isEqualTo("OWNERSHIP_SIGNED"); + assertThat(result.getOwnershipConfirmation().getStatus()).isEqualTo("CENTRAL_CONFIRMED"); assertThat(result.getOwnershipConfirmation().getSigningKeyId()).isEqualTo(InstitutionKeyService.CENTRAL_FIRST_KEY); + assertThat(result.getOwnershipConfirmation().getConfirmationOriginalText()).contains("MINT_001", "DC_001"); + assertThat(result.getOwnershipConfirmation().getConfirmationDigest()).isEqualTo("BATCH_DIGEST"); + assertThat(result.getOwnershipConfirmation().getCentralBankSignature()).isEqualTo("OWNERSHIP_SIGNATURE"); assertThat(result.getDraftCoins().get(0).getOwnershipSignature()).isEqualTo("OWNERSHIP_SIGNATURE"); assertThat(result.getDraftCoins().get(0).getOwnershipOriginalText()).isEqualTo("SOURCE_COMPLETE_CURRENCY"); assertThat(result.getDraftCoins().get(0).getCompleteCurrency()) @@ -109,13 +117,224 @@ class DigitalCurrencyProductionAndOwnershipServiceTest { ArgumentCaptor confirmationCaptor = ArgumentCaptor.forClass(DigitalCurrencyOwnershipConfirmation.class); verify(repository).confirmOwnership(batchCaptor.capture(), confirmationCaptor.capture()); - verify(gateway).activateGeneratedCurrencies(org.mockito.ArgumentMatchers.eq(subject), - org.mockito.ArgumentMatchers.eq(requestId), org.mockito.ArgumentMatchers.eq("BK01"), - org.mockito.ArgumentMatchers.eq("ORG_001"), org.mockito.ArgumentMatchers.anyList(), + verify(gateway, never()).activateGeneratedCurrencies(org.mockito.ArgumentMatchers.eq(subject), + org.mockito.ArgumentMatchers.eq(requestId), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(actor)); assertThat(confirmationCaptor.getValue().getSigningKeyId()).isEqualTo(InstitutionKeyService.CENTRAL_FIRST_KEY); } + @Test + void activatesCurrenciesAndSignsReceiptOnlyAfterCommercialBankVerification() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + ReserveDeductionNotificationRepository notificationRepository = mock(ReserveDeductionNotificationRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DraftDigitalCurrency coin = new DraftDigitalCurrency("DC_001", "MINT_001", 1, new BigDecimal("100.00"), + "ORG_001", "DC", "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", 901L, 801L, + "SOURCE_COMPLETE_CURRENCY", "CONTROL_BIT", "CONTROL_SIGNATURE", "SOURCE_COMPLETE_CURRENCY", + "COIN_SIGNATURE", "SOURCE_COMPLETE_CURRENCY|COIN_SIGNATURE", Instant.now(), "user-1", "张三"); + DigitalCurrencyProductionBatch batch = new DigitalCurrencyProductionBatch("MINT_001", requestId, "RESERVE_001", + "ORG_001", "DC", new BigDecimal("100.00"), "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", + 81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE", Collections.singletonList(coin)); + String confirmationText = confirmationText(requestId, "100.00", "DC_001"); + DigitalCurrencyOwnershipConfirmation central = new DigitalCurrencyOwnershipConfirmation(requestId, "MINT_001", + "CENTRAL_CONFIRMED", Instant.now(), "user-1", "张三", InstitutionKeyService.CENTRAL_FIRST_KEY, + confirmationText, "BATCH_DIGEST", "CENTRAL_SIGNATURE", + null, null, null, null, null, null, null, null); + DigitalCurrencyOwnershipConfirmation verified = central.verified(Instant.now(), "user-1", "张三"); + DigitalCurrencyOwnershipConfirmation activated = verified.currenciesActivated(); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(batch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)) + .thenReturn(Optional.of(central), Optional.of(verified), Optional.of(activated)); + when(notificationRepository.findByRequestId(requestId)).thenReturn(Optional.of(notification(requestId))); + when(gateway.digest(confirmationText)).thenReturn("BATCH_DIGEST"); + when(gateway.verifyOwnership(subject, "BATCH_DIGEST", "CENTRAL_SIGNATURE")).thenReturn(true); + when(gateway.verifyOwnership(subject, "SOURCE_COMPLETE_CURRENCY", "COIN_SIGNATURE")).thenReturn(true); + when(gateway.digest(org.mockito.ArgumentMatchers.startsWith("OWNERSHIP_RECEIPT|"))) + .thenReturn("RECEIPT_DIGEST"); + when(gateway.signReceipt(subject, "RECEIPT_DIGEST")).thenReturn("RECEIPT_SIGNATURE"); + when(gateway.commercialBankSigningKeyId()).thenReturn(InstitutionKeyService.BANK_SECOND_KEY); + DigitalCurrencyOwnershipConfirmationService service = + new DigitalCurrencyOwnershipConfirmationService(repository, notificationRepository, gateway); + + service.verifyCentralBankConfirmation(requestId, actor, subject); + service.activateCurrencies(requestId, actor, subject); + DigitalCurrencyProductionBatchView result = service.signReceipt(requestId, actor, subject); + + assertThat(result.getStatus()).isEqualTo("OWNERSHIP_CONFIRMED"); + assertThat(result.getOwnershipConfirmation().getStatus()).isEqualTo("RECEIPT_SENT"); + assertThat(result.getOwnershipConfirmation().getReceiptDigest()).isEqualTo("RECEIPT_DIGEST"); + assertThat(result.getOwnershipConfirmation().getReceiptSignature()).isEqualTo("RECEIPT_SIGNATURE"); + assertThat(result.getOwnershipConfirmation().getReceiptSigningKeyId()).isEqualTo(InstitutionKeyService.BANK_SECOND_KEY); + verify(gateway).activateGeneratedCurrencies(subject, requestId, "BK01", "ORG_001", + Collections.singletonList(coin), actor); + ArgumentCaptor stateCaptor = + ArgumentCaptor.forClass(DigitalCurrencyOwnershipConfirmation.class); + verify(repository, times(3)).confirmOwnership(org.mockito.ArgumentMatchers.any(DigitalCurrencyProductionBatch.class), + stateCaptor.capture()); + assertThat(stateCaptor.getAllValues()).extracting(DigitalCurrencyOwnershipConfirmation::getStatus) + .containsExactly("BANK_VERIFIED", "CURRENCIES_ACTIVATED", "RECEIPT_SENT"); + } + + @Test + void rejectsActivationWhenTheCommercialBankCannotVerifyTheCentralBankConfirmation() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + ReserveDeductionNotificationRepository notificationRepository = mock(ReserveDeductionNotificationRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DraftDigitalCurrency coin = new DraftDigitalCurrency("DC_001", "MINT_001", 1, new BigDecimal("100.00"), + "ORG_001", "DC", "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", 901L, 801L, + "SOURCE_COMPLETE_CURRENCY", "CONTROL_BIT", "CONTROL_SIGNATURE", "SOURCE_COMPLETE_CURRENCY", + "COIN_SIGNATURE", "SOURCE_COMPLETE_CURRENCY|COIN_SIGNATURE", Instant.now(), "user-1", "张三"); + DigitalCurrencyProductionBatch batch = new DigitalCurrencyProductionBatch("MINT_001", requestId, "RESERVE_001", + "ORG_001", "DC", new BigDecimal("100.00"), "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", + 81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE", Collections.singletonList(coin)); + String confirmationText = confirmationText(requestId, "100.00", "DC_001"); + DigitalCurrencyOwnershipConfirmation central = new DigitalCurrencyOwnershipConfirmation(requestId, "MINT_001", + "CENTRAL_CONFIRMED", Instant.now(), "user-1", "张三", InstitutionKeyService.CENTRAL_FIRST_KEY, + confirmationText, "BATCH_DIGEST", "TAMPERED_SIGNATURE", + null, null, null, null, null, null, null, null); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(batch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)).thenReturn(Optional.of(central)); + when(gateway.digest(confirmationText)).thenReturn("BATCH_DIGEST"); + when(gateway.verifyOwnership(subject, "BATCH_DIGEST", "TAMPERED_SIGNATURE")).thenReturn(false); + DigitalCurrencyOwnershipConfirmationService service = + new DigitalCurrencyOwnershipConfirmationService(repository, notificationRepository, gateway); + + assertThatThrownBy(() -> service.verifyCentralBankConfirmation(requestId, actor, subject)) + .isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> service.activateCurrencies(requestId, actor, subject)) + .isInstanceOf(RuntimeException.class); + verify(gateway, never()).activateGeneratedCurrencies(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsCentralConfirmationWhenTheSignedBatchOriginalTextWasTampered() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DigitalCurrencyProductionBatch batch = signedBatch(requestId, "100.00", + Collections.singletonList(signedCoin("DC_001"))); + DigitalCurrencyOwnershipConfirmation central = centralConfirmation(requestId, + confirmationText(requestId, "100.00", "DC_001") + "|TAMPERED"); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(batch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)).thenReturn(Optional.of(central)); + when(gateway.verifyOwnership(subject, "BATCH_DIGEST", "CENTRAL_SIGNATURE")).thenReturn(true); + when(gateway.verifyOwnership(subject, "SOURCE_COMPLETE_CURRENCY", "COIN_SIGNATURE")).thenReturn(true); + DigitalCurrencyOwnershipConfirmationService service = new DigitalCurrencyOwnershipConfirmationService( + repository, mock(ReserveDeductionNotificationRepository.class), gateway); + + assertThatThrownBy(() -> service.verifyCentralBankConfirmation(requestId, actor, subject)) + .isInstanceOf(RuntimeException.class); + verify(repository, never()).confirmOwnership(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsCentralConfirmationWhenBatchMetadataChangedAfterSigning() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DigitalCurrencyProductionBatch changedBatch = signedBatch(requestId, "200.00", + Collections.singletonList(signedCoin("DC_001"))); + DigitalCurrencyOwnershipConfirmation central = centralConfirmation(requestId, + confirmationText(requestId, "100.00", "DC_001")); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(changedBatch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)).thenReturn(Optional.of(central)); + when(gateway.verifyOwnership(subject, "BATCH_DIGEST", "CENTRAL_SIGNATURE")).thenReturn(true); + when(gateway.verifyOwnership(subject, "SOURCE_COMPLETE_CURRENCY", "COIN_SIGNATURE")).thenReturn(true); + DigitalCurrencyOwnershipConfirmationService service = new DigitalCurrencyOwnershipConfirmationService( + repository, mock(ReserveDeductionNotificationRepository.class), + gateway); + + assertThatThrownBy(() -> service.verifyCentralBankConfirmation(requestId, actor, subject)) + .isInstanceOf(RuntimeException.class); + verify(repository, never()).confirmOwnership(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsCentralConfirmationWhenCoinMembershipChangedAfterSigning() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DigitalCurrencyProductionBatch changedBatch = signedBatch(requestId, "100.00", + Collections.singletonList(signedCoin("DC_002"))); + DigitalCurrencyOwnershipConfirmation central = centralConfirmation(requestId, + confirmationText(requestId, "100.00", "DC_001")); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(changedBatch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)).thenReturn(Optional.of(central)); + when(gateway.verifyOwnership(subject, "BATCH_DIGEST", "CENTRAL_SIGNATURE")).thenReturn(true); + when(gateway.verifyOwnership(subject, "SOURCE_COMPLETE_CURRENCY", "COIN_SIGNATURE")).thenReturn(true); + DigitalCurrencyOwnershipConfirmationService service = new DigitalCurrencyOwnershipConfirmationService( + repository, mock(ReserveDeductionNotificationRepository.class), + gateway); + + assertThatThrownBy(() -> service.verifyCentralBankConfirmation(requestId, actor, subject)) + .isInstanceOf(RuntimeException.class); + verify(repository, never()).confirmOwnership(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void treatsLegacyConfirmedOwnershipAsAnAlreadyCompletedTerminalResult() { + UUID requestId = UUID.randomUUID(); + DigitalCurrencyProductionRepository repository = mock(DigitalCurrencyProductionRepository.class); + DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); + DigitalCurrencyProductionBatch batch = new DigitalCurrencyProductionBatch("MINT_001", requestId, "RESERVE_001", + "ORG_001", "DC", new BigDecimal("100.00"), "OWNERSHIP_CONFIRMED", Instant.now(), "user-1", "张三", + 81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE", Collections.singletonList(signedCoin("DC_001"))); + DigitalCurrencyOwnershipConfirmation legacy = new DigitalCurrencyOwnershipConfirmation(requestId, "MINT_001", + "CONFIRMED", Instant.now(), "user-1", "张三", InstitutionKeyService.CENTRAL_FIRST_KEY); + when(repository.findByRequestId(requestId)).thenReturn(Optional.of(batch)); + when(repository.findOwnershipConfirmationByRequestId(requestId)).thenReturn(Optional.of(legacy)); + DigitalCurrencyOwnershipConfirmationService service = new DigitalCurrencyOwnershipConfirmationService( + repository, mock(ReserveDeductionNotificationRepository.class), gateway); + + assertThat(service.confirm(requestId, actor, subject).getOwnershipConfirmation().getStatus()).isEqualTo("CONFIRMED"); + assertThat(service.verifyCentralBankConfirmation(requestId, actor, subject) + .getOwnershipConfirmation().getStatus()).isEqualTo("CONFIRMED"); + assertThat(service.activateCurrencies(requestId, actor, subject) + .getOwnershipConfirmation().getStatus()).isEqualTo("CONFIRMED"); + assertThat(service.signReceipt(requestId, actor, subject) + .getOwnershipConfirmation().getStatus()).isEqualTo("CONFIRMED"); + verify(repository, never()).confirmOwnership(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + verify(gateway, never()).activateGeneratedCurrencies(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.any()); + } + + private DraftDigitalCurrency signedCoin(String coinId) { + return new DraftDigitalCurrency(coinId, "MINT_001", 1, new BigDecimal("100.00"), + "ORG_001", "DC", "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", 901L, 801L, + "SOURCE_COMPLETE_CURRENCY", "CONTROL_BIT", "CONTROL_SIGNATURE", "SOURCE_COMPLETE_CURRENCY", + "COIN_SIGNATURE", "SOURCE_COMPLETE_CURRENCY|COIN_SIGNATURE", Instant.now(), "user-1", "张三"); + } + + private DigitalCurrencyProductionBatch signedBatch(UUID requestId, String totalAmount, + java.util.List coins) { + return new DigitalCurrencyProductionBatch("MINT_001", requestId, "RESERVE_001", "ORG_001", "DC", + new BigDecimal(totalAmount), "OWNERSHIP_SIGNED", Instant.now(), "user-1", "张三", + 81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE", coins); + } + + private DigitalCurrencyOwnershipConfirmation centralConfirmation(UUID requestId, String originalText) { + return new DigitalCurrencyOwnershipConfirmation(requestId, "MINT_001", "CENTRAL_CONFIRMED", Instant.now(), + "user-1", "张三", InstitutionKeyService.CENTRAL_FIRST_KEY, originalText, "BATCH_DIGEST", + "CENTRAL_SIGNATURE", null, null, null, null, null, null, null, null); + } + + private String confirmationText(UUID requestId, String totalAmount, String coinId) { + return "OWNERSHIP_CONFIRM|" + requestId + "|MINT_001|ORG_001|DC|" + totalAmount + + "|1:" + coinId + ":COIN_SIGNATURE"; + } + private CentralBankIssuanceReceipt receipt(UUID requestId) { return new CentralBankIssuanceReceipt(requestId, "ISSUE_REQ_1", "{\"orgId\":\"ORG_001\",\"currency\":\"DC\",\"totalAmount\":\"100.00\"," diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java index 028c2d0..c500a70 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionServiceTest.java @@ -67,7 +67,9 @@ class IssuanceTrainingActionServiceTest { "04:generate-reserve-deduction-request", "05:confirm-receipt", "05:query-account", "05:verify-deduction", "05:execute-deduction", "05:generate-balance-notice", "05:send-balance-notice", "06:confirm-production-receipt", - "06:generate-production-digest", "07:load-ownership-result" + "06:generate-production-digest", "07:central-confirm-ownership", + "07:verify-central-bank-signature", "07:activate-bank-currencies", + "07:sign-bank-receipt", "07:load-ownership-result" }; for (String key : keys) { String[] parts = key.split(":"); diff --git a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java index 4e89c9f..98b4840 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java @@ -52,6 +52,9 @@ class IssuanceControllerTest { when(generationGateway.requireControlMaterial(any(), any(BigDecimal.class), anyString())) .thenReturn(new IssuanceControlMaterial(81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE")); when(generationGateway.signOwnership(any(), anyString())).thenReturn("OWNERSHIP_SIGNATURE"); + when(generationGateway.digest(anyString())).thenReturn("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"); + when(generationGateway.verifyOwnership(any(), anyString(), anyString())).thenReturn(true); + when(generationGateway.signReceipt(any(), anyString())).thenReturn("BANK_RECEIPT_SIGNATURE"); when(generationGateway.reserveGeneratedCurrencies(any(), any(), any(), any(), any())) .thenAnswer(invocation -> generatedStock(invocation.getArgument(3))); } @@ -122,7 +125,7 @@ class IssuanceControllerTest { .with(jwt().jwt(jwt -> jwt.subject(otherId).claim("userId", otherId) .claim("preferred_username", "tzs002")))) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("FORBIDDEN")) + .andExpect(jsonPath("$.code").value(403)) .andExpect(jsonPath("$.message").value("无权访问其他用户的数字货币发行请求")); } finally { jdbcTemplate.update("DELETE FROM sys_user WHERE user_id = ?", otherId); @@ -259,7 +262,9 @@ class IssuanceControllerTest { .claim("preferred_username", "tzs001")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.status").value("OWNERSHIP_CONFIRMED")) - .andExpect(jsonPath("$.data.ownershipConfirmation.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.ownershipConfirmation.status").value("RECEIPT_SENT")) + .andExpect(jsonPath("$.data.ownershipConfirmation.centralBankSignature").value("OWNERSHIP_SIGNATURE")) + .andExpect(jsonPath("$.data.ownershipConfirmation.receiptSignature").value("BANK_RECEIPT_SIGNATURE")) .andExpect(jsonPath("$.data.ownershipConfirmation.confirmedByUserId").value("00000000-0000-0000-0000-000000000487")) .andExpect(jsonPath("$.data.ownershipConfirmation.confirmedBy").value("tzs001")) .andExpect(jsonPath("$.data.ownershipConfirmation.signingKeyId").value(InstitutionKeyService.CENTRAL_FIRST_KEY)) @@ -270,7 +275,7 @@ class IssuanceControllerTest { mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}/digital-currency-ownership", requestId) .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487")))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("CONFIRMED")) + .andExpect(jsonPath("$.data.status").value("RECEIPT_SENT")) .andExpect(jsonPath("$.data.batchId").value(org.hamcrest.Matchers.startsWith("MINT_"))) .andExpect(jsonPath("$.data.confirmedByUserId").value("00000000-0000-0000-0000-000000000487")); } @@ -331,7 +336,7 @@ class IssuanceControllerTest { mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests") .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487").claim("userId", "00000000-0000-0000-0000-000000000487").claim("preferred_username", "tzs001"))) .contentType(MediaType.APPLICATION_JSON).content(requestBody)) - .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value("面额必须包含全部 11 种标准面额")); } @@ -341,7 +346,7 @@ class IssuanceControllerTest { mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests") .with(jwt().jwt(jwt -> jwt.subject("487").claim("preferred_username", "tzs001"))) .contentType(MediaType.APPLICATION_JSON).content(requestBody)) - .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); + .andExpect(jsonPath("$.code").value(401)); } @Test diff --git a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java index e2f33c1..b232e3f 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceTrainingAttemptControllerTest.java @@ -67,6 +67,8 @@ class IssuanceTrainingAttemptControllerTest { {"05", "query-account"}, {"05", "verify-deduction"}, {"05", "execute-deduction"}, {"05", "generate-balance-notice"}, {"05", "send-balance-notice"}, {"06", "confirm-production-receipt"}, {"06", "generate-production-digest"}, + {"07", "central-confirm-ownership"}, {"07", "verify-central-bank-signature"}, + {"07", "activate-bank-currencies"}, {"07", "sign-bank-receipt"}, {"07", "load-ownership-result"} }; for (String[] action : actions) { 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 e62695f..e4a5a02 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 @@ -220,6 +220,40 @@ class PaymentControllerTest { BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("0.00"); } + @Test + void rejectsEveryAttemptToBypassTheThreePaymentSignatureGates() throws Exception { + String created = mockMvc.perform(post("/api/v1/payments").with(payer()).contentType(MediaType.APPLICATION_JSON) + .content("{\"payerWalletId\":\"" + PAYER_WALLET + "\",\"payeeWalletId\":\"" + PAYEE_WALLET + + "\",\"amount\":200.00,\"note\":\"signature gates\"}")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String id = JsonPath.read(created, "$.data.id"); + + mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer())) + .andExpect(status().isBadRequest()); + assertThat(jdbc.queryForObject("SELECT frozen_amount FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYER_WALLET)).isEqualByComparingTo("0.00"); + + mockMvc.perform(post("/api/v1/payments/{id}/sign", id).with(payer())).andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/payments/{id}/payer-bank-process", id).with(payer())).andExpect(status().isOk()); + String payerBankSignature = jdbc.queryForObject("SELECT payer_bank_signature FROM payment_order WHERE id=?", + String.class, id); + jdbc.update("UPDATE payment_order SET payer_bank_signature='TAMPERED' WHERE id=?", id); + mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer())) + .andExpect(status().isBadRequest()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_clearing_record WHERE payment_id=?", + Integer.class, id)).isZero(); + + jdbc.update("UPDATE payment_order SET payer_bank_signature=? WHERE id=?", payerBankSignature, id); + mockMvc.perform(post("/api/v1/payments/{id}/central-bank-settle", id).with(payer())).andExpect(status().isOk()); + jdbc.update("UPDATE payment_order SET central_bank_signature='TAMPERED' WHERE id=?", id); + mockMvc.perform(post("/api/v1/payments/{id}/payee-bank-credit", id).with(payer())) + .andExpect(status().isBadRequest()); + assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, PAYEE_WALLET)).isEqualByComparingTo("0.00"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_wallet_ledger WHERE payment_id=?", + Integer.class, id)).isZero(); + } + @Test void transferredCoinsCanBeReservedByThePayeeInALaterPayment() throws Exception { String firstId = createSignedPayment(PAYER_WALLET, PAYEE_WALLET, payer()); diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index d1cbefe..ca190f4 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -389,6 +389,10 @@ CREATE TABLE IF NOT EXISTS digital_currency_ownership_confirmation ( request_id CHAR(36) PRIMARY KEY, batch_id VARCHAR(64) NOT NULL UNIQUE, status VARCHAR(32) NOT NULL, confirmed_at TIMESTAMP NOT NULL, confirmed_by_user_id VARCHAR(36) NOT NULL, confirmed_by VARCHAR(64) NOT NULL, signing_key_id VARCHAR(64) NOT NULL, + confirmation_original_text TEXT, confirmation_digest CHAR(64), central_bank_signature VARCHAR(512), + verified_at TIMESTAMP, verified_by_user_id VARCHAR(36), verified_by VARCHAR(64), + receipt_original_text TEXT, receipt_digest CHAR(64), receipt_signature VARCHAR(512), + receipt_signing_key_id VARCHAR(64), receipt_at TIMESTAMP, CONSTRAINT fk_digital_currency_ownership_batch FOREIGN KEY (batch_id) REFERENCES digital_currency_production_batch(batch_id) ); CREATE TABLE IF NOT EXISTS commercial_bank_currency (