feat: settle exchange ownership atomically

agent/payment-training-progress
chenyuan 2 weeks ago
parent 06816b536c
commit 47ff07c956

@ -0,0 +1,25 @@
package com.yau.digitalrmb.exchange.application.query;
public final class ExchangeReservationResult {
private final boolean reserved;
private final String message;
private final ExchangeOrderView order;
private ExchangeReservationResult(boolean reserved, String message, ExchangeOrderView order) {
this.reserved = reserved;
this.message = message;
this.order = order;
}
public static ExchangeReservationResult reserved(ExchangeOrderView order) {
return new ExchangeReservationResult(true, null, order);
}
public static ExchangeReservationResult rolledBack(String message, ExchangeOrderView order) {
return new ExchangeReservationResult(false, message, order);
}
public boolean isReserved() { return reserved; }
public String getMessage() { return message; }
public ExchangeOrderView getOrder() { return order; }
}

@ -2,6 +2,7 @@ package com.yau.digitalrmb.exchange.application.service;
import com.yau.digitalrmb.exchange.application.command.CreateExchangeCommand;
import com.yau.digitalrmb.exchange.application.query.ExchangeOrderView;
import com.yau.digitalrmb.exchange.application.query.ExchangeReservationResult;
import com.yau.digitalrmb.exchange.domain.model.DebitHoldResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
@ -173,21 +174,90 @@ public class ExchangeApplicationService {
return ExchangeOrderView.from(order, coins);
}
@Transactional
public ExchangeReservationResult reserveCoinsWithCompensation(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() == ExchangeStatus.COINS_RESERVED) {
return ExchangeReservationResult.reserved(current(order));
}
if (order.getStatus() != ExchangeStatus.FUNDS_HELD) {
throw validation("请先完成商业银行验签和资金冻结");
}
try {
List<ReservedCoin> coins = resourceRepository.reserveCoins(order, actor);
order.reserveCoins(coins.size());
orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "05-1", "从商业银行库锁定币串",
String.valueOf(coins.size()), actor);
return ExchangeReservationResult.reserved(ExchangeOrderView.from(order, coins));
} catch (BusinessException exception) {
resourceRepository.releaseReservedCoins(order, actor);
resourceRepository.releaseDebitHold(order, actor);
order.releaseHeldResources();
orderRepository.save(order, actor.getUsername());
return ExchangeReservationResult.rolledBack(
"发行币串已被占用,冻结资金已释放", current(order));
}
}
@Transactional
public ExchangeOrderView prepareTransfer(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != ExchangeStatus.COINS_RESERVED) {
if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order);
throw validation("请先锁定等额商业银行币串");
}
if (order.getTransferRequestNo() == null) {
prepareTransfer(order, actor);
orderRepository.save(order, actor.getUsername());
}
return current(order);
}
public ExchangeOrderView verifyBankSignature(UUID id, ExchangeActor actor) {
ExchangeOrder order = owned(id, actor);
InstitutionKeySubject subject = keySubject(actor);
if (order.getTransferDigest() == null || order.getBankSignature() == null
|| !keyService.verifyCommercialBank(subject, order.getTransferDigest(), order.getBankSignature())) {
throw validation("商业银行权属变更请求签名验证失败");
}
return current(order);
}
public ExchangeOrderView verifyCoinOwnership(UUID id, ExchangeActor actor) {
ExchangeOrder order = owned(id, actor);
List<ReservedCoin> coins = resourceRepository.findReservedCoins(order);
resourceRepository.verifyReservedCoins(order, coins, actor);
return ExchangeOrderView.from(order, coins);
}
@Transactional
public void cancel(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() == ExchangeStatus.SUCCESS) {
throw validation("币串权属已转移,不能取消兑换实验");
}
if (order.getStatus() == ExchangeStatus.COINS_RESERVED) {
resourceRepository.releaseReservedCoins(order, actor);
}
if (order.getStatus() == ExchangeStatus.FUNDS_HELD
|| order.getStatus() == ExchangeStatus.COINS_RESERVED) {
resourceRepository.releaseDebitHold(order, actor);
order.releaseHeldResources();
orderRepository.save(order, actor.getUsername());
}
}
@Transactional
public ExchangeOrderView confirmOwnership(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order);
if (order.getStatus() != ExchangeStatus.COINS_RESERVED) throw validation("请先锁定等额商业银行币串");
List<ReservedCoin> coins = resourceRepository.findReservedCoins(order);
String transferRequestNo = "TRANSFER_REQ_" + order.getRequestTimestamp() + "_"
+ order.getId().toString().substring(0, 8).toUpperCase();
if (order.getTransferRequestNo() == null) prepareTransfer(order, actor);
String bankVaultOwner = "BANK_VAULT_" + order.getBankCode();
String transferText = transferRequestNo + "|" + order.getBankCode() + "|" + bankVaultOwner + "|"
+ order.getWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getRequestTimestamp();
InstitutionKeySubject subject = new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId());
String transferDigest = cryptography.sm3(transferText);
String bankSignature = keyService.signCommercialBank(subject, transferDigest);
if (!keyService.verifyCommercialBank(subject, transferDigest, bankSignature)) {
InstitutionKeySubject subject = keySubject(actor);
if (!keyService.verifyCommercialBank(subject, order.getTransferDigest(), order.getBankSignature())) {
throw validation("商业银行权属变更请求签名验证失败");
}
String transactionId = "TXN_" + order.getRequestNo();
@ -201,7 +271,8 @@ public class ExchangeApplicationService {
}
OwnershipConfirmation confirmation = resourceRepository.confirmOwnership(order, coins, confirmationDigest,
centralSignature, actor);
order.confirm(transferRequestNo, transferText, transferDigest, bankSignature, confirmationText,
order.confirm(order.getTransferRequestNo(), order.getTransferOriginalText(), order.getTransferDigest(),
order.getBankSignature(), confirmationText,
confirmationDigest, confirmation, clock.instant()); orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "05-2", "央行登记中心完成权属变更", confirmation.getTransactionId(), actor);
return ExchangeOrderView.from(order, resourceRepository.findReservedCoins(order));
@ -241,6 +312,21 @@ public class ExchangeApplicationService {
return value;
}
private void prepareTransfer(ExchangeOrder order, ExchangeActor actor) {
String requestNo = "TRANSFER_REQ_" + order.getRequestTimestamp() + "_"
+ order.getId().toString().substring(0, 8).toUpperCase();
String bankVaultOwner = "BANK_VAULT_" + order.getBankCode();
String originalText = requestNo + "|" + order.getBankCode() + "|" + bankVaultOwner + "|"
+ order.getWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getRequestTimestamp();
String digest = cryptography.sm3(originalText);
String signature = keyService.signCommercialBank(keySubject(actor), digest);
order.prepareTransfer(requestNo, originalText, digest, signature);
}
private InstitutionKeySubject keySubject(ExchangeActor actor) {
return new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId());
}
private BigDecimal amount(BigDecimal value) {
if (value == null) throw validation("兑换金额不能为空");
try {

@ -0,0 +1,38 @@
package com.yau.digitalrmb.exchange.application.service;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.attempt.application.AttemptCancellationHandler;
import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class ExchangeAttemptCancellationHandler implements AttemptCancellationHandler {
private final ExchangeApplicationService exchanges;
public ExchangeAttemptCancellationHandler(ExchangeApplicationService exchanges) {
this.exchanges = exchanges;
}
@Override
public ExperimentModule module() {
return ExperimentModule.EXCHANGE;
}
@Override
public void cancel(ExperimentAttempt attempt, ExperimentSubject subject) {
if (attempt.getBusinessId() == null) return;
try {
exchanges.cancel(UUID.fromString(attempt.getBusinessId()),
new ExchangeActor(subject.getUserId(), subject.getUserId(),
subject.getSchoolId(), subject.getClassId()));
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "兑换实验绑定的业务标识无效");
}
}
}

@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.exchange.application.command.CreateExchangeCommand;
import com.yau.digitalrmb.exchange.application.query.ExchangeOrderView;
import com.yau.digitalrmb.exchange.application.query.ExchangeReservationResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
@ -41,7 +42,14 @@ public class ExchangeTrainingActionService {
"03:generate-sign-timestamp", "03:concatenate-sign-source", "03:generate-digest",
"03:wallet-sign", "03:package-request", "03:send-request",
"04:verify-wallet-signature", "04:check-account-balance", "04:check-wallet-limits",
"04:execute-debit-hold", "04:output-debit-voucher");
"04:execute-debit-hold", "04:output-debit-voucher",
"05:receive-debit-voucher", "05:reserve-coins", "05:adjust-bank-ledger",
"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:close-notification", "05:user-confirm");
private final ExperimentAttemptService attempts;
private final ExchangeApplicationService exchanges;
@ -119,6 +127,16 @@ public class ExchangeTrainingActionService {
case "03:package-request":
case "03:send-request":
case "04:output-debit-voucher":
case "05:receive-debit-voucher":
case "05:adjust-bank-ledger":
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":
case "05:close-notification":
return completed(exchanges.get(exchangeId, actor));
case "04:verify-wallet-signature":
return completed(exchanges.verifyWalletSignature(exchangeId, actor));
@ -128,6 +146,43 @@ public class ExchangeTrainingActionService {
return completed(exchanges.checkWalletLimits(exchangeId, actor));
case "04:execute-debit-hold":
return completed(exchanges.bankProcess(exchangeId, actor));
case "05:reserve-coins": {
ExchangeReservationResult result = exchanges.reserveCoinsWithCompensation(exchangeId, actor);
if (!result.isReserved()) {
return ActionOutcome.rolledBack(result, "04", "execute-debit-hold");
}
return completed(result);
}
case "05:generate-transfer-request":
return completed(exchanges.prepareTransfer(exchangeId, actor));
case "05:verify-bank-signature":
return completed(exchanges.verifyBankSignature(exchangeId, actor));
case "05:recalculate-transfer-digest": {
ExchangeOrderView order = exchanges.get(exchangeId, actor);
return completed(value("digest", cryptography.sm3(order.getTransferOriginalText())));
}
case "05:compare-transfer-digest": {
ExchangeOrderView order = exchanges.get(exchangeId, actor);
String recalculated = text(prior(attempt, "05", "recalculate-transfer-digest"), "digest");
if (!recalculated.equals(order.getTransferDigest())) {
throw validation("权属变更请求摘要比对失败");
}
return completed(value("matched", true));
}
case "05:verify-coin-ownership":
return completed(exchanges.verifyCoinOwnership(exchangeId, actor));
case "05:execute-ownership-transfer":
return completed(exchanges.confirmOwnership(exchangeId, actor));
case "05:generate-confirmation-digest": {
ExchangeOrderView order = exchanges.get(exchangeId, actor);
return completed(value("digest", order.getConfirmationDigest()));
}
case "05:central-bank-sign": {
ExchangeOrderView order = exchanges.get(exchangeId, actor);
return completed(value("signature", order.getCentralBankSignature()));
}
case "05:user-confirm":
return ActionOutcome.completedAttempt(exchanges.get(exchangeId, actor));
default:
throw validation("不支持的兑换实验动作:" + key);
}

@ -124,6 +124,30 @@ public final class ExchangeOrder {
this.coinCount = count; this.status = ExchangeStatus.COINS_RESERVED;
}
public void prepareTransfer(String requestNo, String originalText, String transferDigest,
String commercialBankSignature) {
requireStatus(ExchangeStatus.COINS_RESERVED);
this.transferRequestNo = required(requestNo, "权属变更请求编号");
this.transferOriginalText = required(originalText, "权属变更请求原文");
this.transferDigest = required(transferDigest, "权属变更请求摘要");
this.bankSignature = required(commercialBankSignature, "商业银行签名");
}
public void releaseHeldResources() {
if (status != ExchangeStatus.FUNDS_HELD && status != ExchangeStatus.COINS_RESERVED) {
throw new IllegalStateException("当前兑换订单没有可释放的冻结资源");
}
debitVoucherNo = null;
bankBalanceBefore = null;
bankBalanceAfter = null;
coinCount = 0;
transferRequestNo = null;
transferOriginalText = null;
transferDigest = null;
bankSignature = null;
status = ExchangeStatus.SIGNED;
}
public void confirm(String transferRequestNo, String transferOriginalText, String transferDigest,
String bankSignature, String confirmationOriginalText, String confirmationDigest,
OwnershipConfirmation confirmation, Instant completedAt) {

@ -19,7 +19,9 @@ public interface ExchangeResourceRepository {
void releaseDebitHold(ExchangeOrder order, ExchangeActor actor);
BankProcessingResult settleDebitHold(ExchangeOrder order, ExchangeActor actor);
List<ReservedCoin> reserveCoins(ExchangeOrder order, ExchangeActor actor);
void releaseReservedCoins(ExchangeOrder order, ExchangeActor actor);
List<ReservedCoin> findReservedCoins(ExchangeOrder order);
void verifyReservedCoins(ExchangeOrder order, List<ReservedCoin> coins, ExchangeActor actor);
OwnershipConfirmation confirmOwnership(ExchangeOrder order, List<ReservedCoin> coins,
String confirmationDigest, String centralBankSignature,
ExchangeActor actor);

@ -170,15 +170,25 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
if (frozen != 1) throw validation("银行卡可用余额已变化,请刷新后重试");
BigDecimal after = account.balance.subtract(order.getAmount()).setScale(2);
String voucher = "DEBIT_" + order.getRequestNo();
jdbc.update("INSERT INTO exchange_debit_record (voucher_no,exchange_id,bank_account_id,before_balance,amount," +
"after_balance,status,created_at,created_by) VALUES (?,?,?,?,?,?,'FROZEN',CURRENT_TIMESTAMP,?)",
voucher, order.getId().toString(), order.getBankAccountId(), account.balance, order.getAmount(), after,
actor.getUsername());
int reused = jdbc.update("UPDATE exchange_debit_record SET bank_account_id=?,before_balance=?,amount=?," +
"after_balance=?,status='FROZEN',created_at=CURRENT_TIMESTAMP,created_by=? " +
"WHERE exchange_id=? AND status='RELEASED'",
order.getBankAccountId(), account.balance, order.getAmount(), after, actor.getUsername(),
order.getId().toString());
if (reused == 0) {
jdbc.update("INSERT INTO exchange_debit_record (voucher_no,exchange_id,bank_account_id,before_balance,amount," +
"after_balance,status,created_at,created_by) VALUES (?,?,?,?,?,?,'FROZEN',CURRENT_TIMESTAMP,?)",
voucher, order.getId().toString(), order.getBankAccountId(), account.balance, order.getAmount(), after,
actor.getUsername());
}
return new DebitHoldResult(account.balance, after, voucher);
}
@Override
public void releaseDebitHold(ExchangeOrder order, ExchangeActor actor) {
Integer frozenVoucher = jdbc.queryForObject("SELECT COUNT(*) FROM exchange_debit_record " +
"WHERE exchange_id=? AND status='FROZEN'", Integer.class, order.getId().toString());
if (frozenVoucher == null || frozenVoucher == 0) return;
int released = jdbc.update("UPDATE simulated_bank_account SET frozen_amount=frozen_amount-?," +
"updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND frozen_amount>=?",
order.getAmount(), order.getBankAccountId(), order.getAmount());
@ -247,6 +257,20 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
return findReservedCoins(order);
}
@Override
public void releaseReservedCoins(ExchangeOrder order, ExchangeActor actor) {
List<ReservedCoin> coins = findReservedCoins(order);
for (ReservedCoin coin : coins) {
if (!"RESERVED".equals(coin.getStatus())) continue;
if (!ownershipLocks.release(coin.getCurrencyId(), "BANK", order.getBankCode(),
"EXCHANGE", order.getId().toString())) {
throw validation("兑换币串锁定状态已变化:" + coin.getCurrencyId());
}
}
jdbc.update("DELETE FROM exchange_coin_reservation WHERE exchange_id=? AND status='RESERVED'",
order.getId().toString());
}
@Override
public List<ReservedCoin> findReservedCoins(ExchangeOrder order) {
return jdbc.query("SELECT currency_id,denomination,status FROM exchange_coin_reservation WHERE exchange_id=? " +
@ -259,8 +283,38 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
public OwnershipConfirmation confirmOwnership(ExchangeOrder order, List<ReservedCoin> coins,
String confirmationDigest, String centralBankSignature,
ExchangeActor actor) {
if (coins == null || coins.isEmpty()) throw validation("权属变更请求未包含币串");
verifyReservedCoins(order, coins, actor);
settleDebitHold(order, actor);
String transactionId = "TXN_" + order.getRequestNo();
jdbc.update("INSERT INTO currency_ownership_transfer (transaction_id,exchange_id,from_owner,to_wallet_id,amount," +
"coin_count,confirmation_digest,central_bank_signature,status,confirmed_at,confirmed_by) " +
"VALUES (?,?,?,?,?,?,?,?, 'CONFIRMED',CURRENT_TIMESTAMP,?)",
transactionId, order.getId().toString(), order.getBankCode(), order.getWalletId(),
order.getAmount(), coins.size(), confirmationDigest, centralBankSignature, actor.getUsername());
for (ReservedCoin coin : coins) {
jdbc.update("UPDATE exchange_coin_reservation SET status='TRANSFERRED',transferred_at=CURRENT_TIMESTAMP " +
"WHERE exchange_id=? AND currency_id=?", order.getId().toString(), coin.getCurrencyId());
int changed = ownershipLocks.transferLocked(coin.getCurrencyId(), "BANK", order.getBankCode(),
"EXCHANGE", order.getId().toString(), "WALLET", order.getWalletId(), transactionId) ? 1 : 0;
if (changed != 1) throw validation("央行登记中心币串权属已变化:" + coin.getCurrencyId());
}
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());
jdbc.update("INSERT INTO wallet_balance_ledger (ledger_no,wallet_id,exchange_id,direction,amount,balance_after," +
"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);
}
@Override
public void verifyReservedCoins(ExchangeOrder order, List<ReservedCoin> coins, ExchangeActor actor) {
if (coins == null || coins.isEmpty()) throw validation("权属变更请求未包含币串");
BigDecimal total = BigDecimal.ZERO.setScale(2);
for (ReservedCoin coin : coins) {
Integer valid = jdbc.queryForObject("SELECT COUNT(*) FROM commercial_bank_currency c " +
@ -287,32 +341,6 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
total = total.add(coin.getDenomination()).setScale(2);
}
if (total.compareTo(order.getAmount()) != 0) throw validation("权属变更币串总额与兑换金额不一致");
settleDebitHold(order, actor);
jdbc.update("INSERT INTO currency_ownership_transfer (transaction_id,exchange_id,from_owner,to_wallet_id,amount," +
"coin_count,confirmation_digest,central_bank_signature,status,confirmed_at,confirmed_by) " +
"VALUES (?,?,?,?,?,?,?,?, 'CONFIRMED',CURRENT_TIMESTAMP,?)",
transactionId, order.getId().toString(), order.getBankCode(), order.getWalletId(),
order.getAmount(), coins.size(), confirmationDigest, centralBankSignature, actor.getUsername());
for (ReservedCoin coin : coins) {
jdbc.update("UPDATE exchange_coin_reservation SET status='TRANSFERRED',transferred_at=CURRENT_TIMESTAMP " +
"WHERE exchange_id=? AND currency_id=?", order.getId().toString(), coin.getCurrencyId());
int changed = ownershipLocks.transferLocked(coin.getCurrencyId(), "BANK", order.getBankCode(),
"EXCHANGE", order.getId().toString(), "WALLET", order.getWalletId(), transactionId) ? 1 : 0;
if (changed != 1) throw validation("央行登记中心币串权属已变化:" + coin.getCurrencyId());
}
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());
jdbc.update("INSERT INTO wallet_balance_ledger (ledger_no,wallet_id,exchange_id,direction,amount,balance_after," +
"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);
}
@Override

@ -97,11 +97,32 @@ public class ExchangeController {
@PostMapping("/{id}/reserve-coins")
@Operation(summary = "货币兑换的步骤五之一:从商业银行库锁定等额币串")
public ApiResponse<ExchangeOrderView> reserveCoins(@PathVariable UUID id) { return ok(service.reserveCoins(id, actor())); }
public ApiResponse<ExchangeOrderView> reserveCoins(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.reserveCoins(id, actor(user)));
run(attempt, "05", "receive-debit-voucher", new ExchangeActionRequest(), user);
run(attempt, "05", "reserve-coins", new ExchangeActionRequest(), user);
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/confirm-ownership")
@Operation(summary = "货币兑换的步骤五之二:央行登记中心校验并变更币串权属")
public ApiResponse<ExchangeOrderView> confirmOwnership(@PathVariable UUID id) { return ok(service.confirmOwnership(id, actor())); }
public ApiResponse<ExchangeOrderView> confirmOwnership(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.confirmOwnership(id, actor(user)));
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",
"close-notification", "user-confirm"};
for (String actionCode : actionCodes) {
run(attempt, "05", actionCode, new ExchangeActionRequest(), user);
}
return ok(service.get(id, actor(user)));
}
@GetMapping("/{id}")
@Operation(summary = "查询兑换订单和币串明细")

@ -8,26 +8,32 @@ public final class ActionOutcome<T> {
private final String nextStepCode;
private final String nextActionCode;
private final String businessId;
private final boolean completesAttempt;
private ActionOutcome(T output, ActionStatus status, String nextStepCode, String nextActionCode,
String businessId) {
String businessId, boolean completesAttempt) {
this.output = output;
this.status = status;
this.nextStepCode = nextStepCode;
this.nextActionCode = nextActionCode;
this.businessId = businessId;
this.completesAttempt = completesAttempt;
}
public static <T> ActionOutcome<T> completed(T output) {
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, null);
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, null, false);
}
public static <T> ActionOutcome<T> completed(T output, String businessId) {
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, businessId);
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, businessId, false);
}
public static <T> ActionOutcome<T> completedAttempt(T output) {
return new ActionOutcome<T>(output, ActionStatus.COMPLETED, null, null, null, true);
}
public static <T> ActionOutcome<T> rolledBack(T output, String nextStepCode, String nextActionCode) {
return new ActionOutcome<T>(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode, null);
return new ActionOutcome<T>(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode, null, false);
}
public T getOutput() { return output; }
@ -35,4 +41,5 @@ public final class ActionOutcome<T> {
public String getNextStepCode() { return nextStepCode; }
public String getNextActionCode() { return nextActionCode; }
public String getBusinessId() { return businessId; }
public boolean isCompletesAttempt() { return completesAttempt; }
}

@ -83,6 +83,11 @@ public class ExperimentAttemptService {
if (!action.getRequestFingerprint().equals(requestFingerprint)) {
throw validation("该动作已使用不同参数完成,不能覆盖原结果");
}
if (stepCode.equals(attempt.getCurrentStepCode())
&& actionCode.equals(attempt.getCurrentActionCode())) {
attempt.moveTo(nextStepCode, nextActionCode, Instant.now());
repository.save(attempt);
}
return toActionView(attempt, action, readOutput(action.getOutputJson(), outputType));
}
@ -108,7 +113,15 @@ public class ExperimentAttemptService {
attempt.bindBusinessId(outcome.getBusinessId(), completedAt);
}
if (outcome.getStatus() == ActionStatus.ROLLED_BACK) {
ExperimentAction rollbackTarget = repository.findActionForUpdate(attemptId,
outcome.getNextStepCode(), outcome.getNextActionCode()).orElse(null);
if (rollbackTarget != null && rollbackTarget != action) {
rollbackTarget.rollBack(completedAt);
repository.saveAction(rollbackTarget);
}
attempt.moveTo(outcome.getNextStepCode(), outcome.getNextActionCode(), completedAt);
} else if (outcome.isCompletesAttempt()) {
attempt.complete(completedAt);
} else {
attempt.moveTo(nextStepCode, nextActionCode, completedAt);
}

@ -76,6 +76,15 @@ public final class ExperimentAction {
updatedAt = now;
}
public void rollBack(Instant now) {
if (status != ActionStatus.COMPLETED) {
return;
}
status = ActionStatus.ROLLED_BACK;
completedAt = now;
updatedAt = now;
}
public void markPersisted() { version++; }
public UUID getId() { return id; }

@ -17,8 +17,11 @@ import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptogr
import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair;
import com.yau.digitalrmb.testsupport.WalletOpeningTestData;
import com.yau.digitalrmb.shared.wallet.WalletPrerequisiteProjectionService;
import com.yau.digitalrmb.exchange.application.service.ExchangeApplicationService;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -35,6 +38,7 @@ class ExchangeControllerTest {
@Autowired private InstitutionIdentityCryptography cryptography;
@Autowired private InstitutionKeyService keyService;
@Autowired private WalletPrerequisiteProjectionService walletProjection;
@Autowired private ExchangeApplicationService exchangeService;
@BeforeEach
void seedPrototypePrerequisites() {
@ -187,6 +191,12 @@ class ExchangeControllerTest {
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?",
String.class, id)).isEqualTo("SETTLED");
assertThat(jdbc.queryForObject("SELECT status FROM training_experiment_attempt WHERE business_id=?",
String.class, id)).isEqualTo("COMPLETED");
String attemptId = jdbc.queryForObject("SELECT id FROM training_experiment_attempt WHERE business_id=?",
String.class, id);
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/cancel", attemptId).with(user()))
.andExpect(status().isBadRequest());
}
@Test
@ -205,6 +215,153 @@ class ExchangeControllerTest {
.isZero();
}
@Test
void releasesDebitHoldAndReturnsToStepFourWhenCoinReservationLosesTheRace() throws Exception {
String[] values = createHeldExchange();
String attemptId = values[0];
String exchangeId = values[1];
String accountId = values[2];
jdbc.update("UPDATE central_bank_currency_ownership SET status='PAYMENT_LOCKED'," +
"lock_business_type='PAYMENT',lock_business_id='COMPETING_PAYMENT' " +
"WHERE owner_type='BANK' AND owner_id='BKCHCNBJ00001'");
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/05/actions/receive-debit-voucher", attemptId)
.with(user()).contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.actionStatus").value("COMPLETED"));
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/05/actions/reserve-coins", attemptId)
.with(user()).contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.actionStatus").value("ROLLED_BACK"))
.andExpect(jsonPath("$.data.currentStepCode").value("04"))
.andExpect(jsonPath("$.data.currentActionCode").value("execute-debit-hold"));
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?",
String.class, exchangeId)).isEqualTo("RELEASED");
assertThat(jdbc.queryForObject("SELECT status FROM training_experiment_action WHERE attempt_id=? " +
"AND step_code='04' AND action_code='execute-debit-hold'", String.class, attemptId))
.isEqualTo("ROLLED_BACK");
jdbc.update("UPDATE central_bank_currency_ownership SET status='AVAILABLE',lock_business_type=NULL," +
"lock_business_id=NULL WHERE owner_type='BANK' AND owner_id='BKCHCNBJ00001'");
executeAction(attemptId, "04", "execute-debit-hold", "COMPLETED");
executeAction(attemptId, "04", "output-debit-voucher", "COMPLETED");
executeAction(attemptId, "05", "receive-debit-voucher", "COMPLETED");
executeAction(attemptId, "05", "reserve-coins", "COMPLETED");
mockMvc.perform(post("/api/v1/exchanges/{id}/confirm-ownership", exchangeId).with(user()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("SUCCESS"));
}
@Test
void cancellingAnExchangeAttemptReleasesTheDebitHold() throws Exception {
String[] values = createHeldExchange();
String attemptId = values[0];
String exchangeId = values[1];
String accountId = values[2];
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/cancel", attemptId).with(user()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.attemptStatus").value("CANCELLED"));
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?",
String.class, exchangeId)).isEqualTo("RELEASED");
}
@Test
void cancellingAfterCoinReservationReleasesBothCoinLocksAndDebitHold() throws Exception {
String[] values = createHeldExchange();
String attemptId = values[0];
String exchangeId = values[1];
String accountId = values[2];
executeAction(attemptId, "05", "receive-debit-voucher", "COMPLETED");
executeAction(attemptId, "05", "reserve-coins", "COMPLETED");
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/cancel", attemptId).with(user()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.attemptStatus").value("CANCELLED"));
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?",
String.class, exchangeId)).isEqualTo("RELEASED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM exchange_coin_reservation WHERE exchange_id=?",
Integer.class, exchangeId)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM central_bank_currency_ownership " +
"WHERE owner_type='BANK' AND owner_id='BKCHCNBJ00001' AND status='AVAILABLE' " +
"AND lock_business_type IS NULL AND lock_business_id IS NULL", Integer.class))
.isEqualTo(2);
}
@Test
void rollsBackBankSettlementWhenOwnershipTransferCannotBeRecorded() throws Exception {
String[] values = createHeldExchange();
String attemptId = values[0];
String exchangeId = values[1];
String accountId = values[2];
executeAction(attemptId, "05", "receive-debit-voucher", "COMPLETED");
executeAction(attemptId, "05", "reserve-coins", "COMPLETED");
jdbc.update("INSERT INTO currency_ownership_transfer (transaction_id,exchange_id,from_owner,to_wallet_id," +
"amount,coin_count,confirmation_digest,central_bank_signature,status,confirmed_at,confirmed_by) " +
"VALUES ('BLOCKING_TRANSFER',?,'BKCHCNBJ00001','WALLET_EXCHANGE_TEST',200,2,?,?,'CONFIRMED'," +
"CURRENT_TIMESTAMP,'test')", exchangeId, repeat('A', 64), "BLOCKING_SIGNATURE");
assertThatThrownBy(() -> exchangeService.confirmOwnership(java.util.UUID.fromString(exchangeId),
new ExchangeActor(USER_ID, "test", 1999L, 2999L))).isInstanceOf(RuntimeException.class);
assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("50000.00");
assertThat(jdbc.queryForObject("SELECT frozen_amount FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("200.00");
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 status FROM exchange_debit_record WHERE exchange_id=?",
String.class, exchangeId)).isEqualTo("FROZEN");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM central_bank_currency_ownership " +
"WHERE status='EXCHANGE_LOCKED' AND lock_business_id=?", Integer.class, exchangeId))
.isEqualTo(2);
}
private String[] createHeldExchange() throws Exception {
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
String walletId = JsonPath.read(context, "$.data.walletId");
String accountId = JsonPath.read(context, "$.data.bankAccountId");
String body = "{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId
+ "\",\"amount\":200.00}";
String created = mockMvc.perform(post("/api/v1/exchanges").with(user())
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
String exchangeId = JsonPath.read(created, "$.data.id");
mockMvc.perform(post("/api/v1/exchanges/{id}/sign", exchangeId).with(user()))
.andExpect(status().isOk());
mockMvc.perform(post("/api/v1/exchanges/{id}/bank-process", exchangeId).with(user()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("FUNDS_HELD"));
String attempt = mockMvc.perform(get("/api/v1/exchange/attempts/current").with(user()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
String attemptId = JsonPath.read(attempt, "$.data.attemptId");
return new String[]{attemptId, exchangeId, accountId};
}
private void executeAction(String attemptId, String step, String action, String expectedStatus) throws Exception {
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}",
attemptId, step, action).with(user())
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.actionStatus").value(expectedStatus));
}
private String repeat(char value, int count) {
StringBuilder text = new StringBuilder(count);
for (int i = 0; i < count; i++) text.append(value);
return text.toString();
}
private org.springframework.test.web.servlet.request.RequestPostProcessor user() {
return jwt().jwt(jwt -> jwt.subject(USER_ID).claim("userId", USER_ID).claim("preferred_username", "tzs001"));
}

@ -41,7 +41,7 @@ class ExchangeTrainingAttemptControllerTest {
@MockBean private CurrentUserService currentUserService;
@Test
void exposesLifecycleAndEveryExchangeActionThroughStepFour() throws Exception {
void exposesLifecycleAndEveryExchangeAction() throws Exception {
when(currentUserService.getCurrentUser()).thenReturn(user());
String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(jwtForUser()))
.andExpect(status().isOk())
@ -63,7 +63,17 @@ class ExchangeTrainingAttemptControllerTest {
{"03", "wallet-sign"}, {"03", "package-request"}, {"03", "send-request"},
{"04", "verify-wallet-signature"}, {"04", "check-account-balance"},
{"04", "check-wallet-limits"}, {"04", "execute-debit-hold"},
{"04", "output-debit-voucher"}
{"04", "output-debit-voucher"},
{"05", "receive-debit-voucher"}, {"05", "reserve-coins"},
{"05", "adjust-bank-ledger"}, {"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", "close-notification"}, {"05", "user-confirm"}
};
for (String[] action : actions) {
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}",

@ -66,6 +66,10 @@ class ExperimentAttemptServiceTest {
@Test
void retriesAnActionAfterCommittedRollback() {
ExperimentAttemptView attempt = service.create(ExperimentModule.EXCHANGE, subject);
service.execute(attempt.getAttemptId(), subject,
"04", "execute-debit-hold", "hold-a", TestOutput.class,
() -> ActionOutcome.completed(new TestOutput("held")),
"04", "output-debit-voucher");
ExperimentActionView<TestOutput> rolledBack = service.execute(attempt.getAttemptId(), subject,
"05", "reserve-coins", "fingerprint-a", TestOutput.class,
@ -80,6 +84,24 @@ class ExperimentAttemptServiceTest {
assertEquals("04", rolledBack.getCurrentStepCode());
assertEquals(ActionStatus.COMPLETED, completed.getActionStatus());
assertEquals("reserved", completed.getOutput().getValue());
assertEquals(ActionStatus.ROLLED_BACK, service.detail(attempt.getAttemptId(), subject).getActions().stream()
.filter(action -> "04".equals(action.getStepCode())
&& "execute-debit-hold".equals(action.getActionCode()))
.findFirst().orElseThrow(AssertionError::new).getActionStatus());
}
@Test
void completesTheAttemptWithTheTerminalActionOutcome() {
ExperimentAttemptView attempt = service.create(ExperimentModule.EXCHANGE, subject);
ExperimentActionView<TestOutput> result = service.execute(attempt.getAttemptId(), subject,
"05", "user-confirm", "confirm-a", TestOutput.class,
() -> ActionOutcome.completedAttempt(new TestOutput("confirmed")),
"05", "user-confirm");
assertEquals(AttemptStatus.COMPLETED, result.getAttemptStatus());
assertEquals(AttemptStatus.COMPLETED,
service.detail(attempt.getAttemptId(), subject).getAttemptStatus());
}
@Test

Loading…
Cancel
Save