feat: enforce staged SM2 verification across modules

chore/issuance-comments-zh
chenyuan 2 months ago
parent 2ddaa9d0ee
commit dbb8a4ca38

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

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

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

@ -6,6 +6,8 @@ public enum ExchangeStatus {
BANK_PROCESSED,
FUNDS_HELD,
COINS_RESERVED,
CENTRAL_CONFIRMED,
CONFIRMATION_VERIFIED,
SUCCESS,
FAILED
}

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

@ -302,6 +302,26 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
List<BigDecimal> 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<BigDecimal> 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<BigDecimal> 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

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

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

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

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

@ -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<DigitalCurrencyOwnershipConfirmationView> findOwnership(UUID requestId) {
return productionRepository.findOwnershipConfirmationByRequestId(requestId)
@ -105,6 +198,52 @@ public class DigitalCurrencyOwnershipConfirmationService {
return required(coin.getSourceCompleteCurrency(), "生成模块来源完整币串");
}
private String confirmationOriginalText(DigitalCurrencyProductionBatch batch,
List<DraftDigitalCurrency> 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;

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

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

@ -81,4 +81,12 @@ public final class DigitalCurrencyProductionBatch {
totalAmount, "OWNERSHIP_CONFIRMED", createdAt, createdByUserId, createdBy, quotaControlBitId,
controlSystemSignatureId, completeControlBit, controlSystemSignature, confirmedCoins);
}
public DigitalCurrencyProductionBatch ownershipSigned(List<DraftDigitalCurrency> signedCoins) {
java.util.ArrayList<DraftDigitalCurrency> pendingCoins = new java.util.ArrayList<DraftDigitalCurrency>();
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);
}
}

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

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

@ -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, "数字货币确权结果不存在")));
}

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

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

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

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

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

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

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

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

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

@ -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.<DigitalCurrencyOwnershipConfirmation>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<DigitalCurrencyOwnershipConfirmation> 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<DigitalCurrencyOwnershipConfirmation> 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<DraftDigitalCurrency> 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\","

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

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

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

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

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

Loading…
Cancel
Save