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.command.CreateExchangeCommand;
import com.yau.digitalrmb.exchange.application.query.ExchangeOrderView; 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.DebitHoldResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor; import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext; import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
@ -173,21 +174,90 @@ public class ExchangeApplicationService {
return ExchangeOrderView.from(order, coins); 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 @Transactional
public ExchangeOrderView confirmOwnership(UUID id, ExchangeActor actor) { public ExchangeOrderView confirmOwnership(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor); ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order); if (order.getStatus() == ExchangeStatus.SUCCESS) return current(order);
if (order.getStatus() != ExchangeStatus.COINS_RESERVED) throw validation("请先锁定等额商业银行币串"); if (order.getStatus() != ExchangeStatus.COINS_RESERVED) throw validation("请先锁定等额商业银行币串");
List<ReservedCoin> coins = resourceRepository.findReservedCoins(order); List<ReservedCoin> coins = resourceRepository.findReservedCoins(order);
String transferRequestNo = "TRANSFER_REQ_" + order.getRequestTimestamp() + "_" if (order.getTransferRequestNo() == null) prepareTransfer(order, actor);
+ order.getId().toString().substring(0, 8).toUpperCase();
String bankVaultOwner = "BANK_VAULT_" + order.getBankCode(); String bankVaultOwner = "BANK_VAULT_" + order.getBankCode();
String transferText = transferRequestNo + "|" + order.getBankCode() + "|" + bankVaultOwner + "|" InstitutionKeySubject subject = keySubject(actor);
+ order.getWalletId() + "|" + order.getAmount().toPlainString() + "|" + order.getRequestTimestamp(); if (!keyService.verifyCommercialBank(subject, order.getTransferDigest(), order.getBankSignature())) {
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)) {
throw validation("商业银行权属变更请求签名验证失败"); throw validation("商业银行权属变更请求签名验证失败");
} }
String transactionId = "TXN_" + order.getRequestNo(); String transactionId = "TXN_" + order.getRequestNo();
@ -201,7 +271,8 @@ public class ExchangeApplicationService {
} }
OwnershipConfirmation confirmation = resourceRepository.confirmOwnership(order, coins, confirmationDigest, OwnershipConfirmation confirmation = resourceRepository.confirmOwnership(order, coins, confirmationDigest,
centralSignature, actor); 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()); confirmationDigest, confirmation, clock.instant()); orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "05-2", "央行登记中心完成权属变更", confirmation.getTransactionId(), actor); resourceRepository.appendStepLog(order, "05-2", "央行登记中心完成权属变更", confirmation.getTransactionId(), actor);
return ExchangeOrderView.from(order, resourceRepository.findReservedCoins(order)); return ExchangeOrderView.from(order, resourceRepository.findReservedCoins(order));
@ -241,6 +312,21 @@ public class ExchangeApplicationService {
return value; 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) { private BigDecimal amount(BigDecimal value) {
if (value == null) throw validation("兑换金额不能为空"); if (value == null) throw validation("兑换金额不能为空");
try { 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.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.exchange.application.command.CreateExchangeCommand; import com.yau.digitalrmb.exchange.application.command.CreateExchangeCommand;
import com.yau.digitalrmb.exchange.application.query.ExchangeOrderView; 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.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext; import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest; 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:generate-sign-timestamp", "03:concatenate-sign-source", "03:generate-digest",
"03:wallet-sign", "03:package-request", "03:send-request", "03:wallet-sign", "03:package-request", "03:send-request",
"04:verify-wallet-signature", "04:check-account-balance", "04:check-wallet-limits", "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 ExperimentAttemptService attempts;
private final ExchangeApplicationService exchanges; private final ExchangeApplicationService exchanges;
@ -119,6 +127,16 @@ public class ExchangeTrainingActionService {
case "03:package-request": case "03:package-request":
case "03:send-request": case "03:send-request":
case "04:output-debit-voucher": 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)); return completed(exchanges.get(exchangeId, actor));
case "04:verify-wallet-signature": case "04:verify-wallet-signature":
return completed(exchanges.verifyWalletSignature(exchangeId, actor)); return completed(exchanges.verifyWalletSignature(exchangeId, actor));
@ -128,6 +146,43 @@ public class ExchangeTrainingActionService {
return completed(exchanges.checkWalletLimits(exchangeId, actor)); return completed(exchanges.checkWalletLimits(exchangeId, actor));
case "04:execute-debit-hold": case "04:execute-debit-hold":
return completed(exchanges.bankProcess(exchangeId, actor)); 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: default:
throw validation("不支持的兑换实验动作:" + key); throw validation("不支持的兑换实验动作:" + key);
} }

@ -124,6 +124,30 @@ public final class ExchangeOrder {
this.coinCount = count; this.status = ExchangeStatus.COINS_RESERVED; 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, public void confirm(String transferRequestNo, String transferOriginalText, String transferDigest,
String bankSignature, String confirmationOriginalText, String confirmationDigest, String bankSignature, String confirmationOriginalText, String confirmationDigest,
OwnershipConfirmation confirmation, Instant completedAt) { OwnershipConfirmation confirmation, Instant completedAt) {

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

@ -170,15 +170,25 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
if (frozen != 1) throw validation("银行卡可用余额已变化,请刷新后重试"); if (frozen != 1) throw validation("银行卡可用余额已变化,请刷新后重试");
BigDecimal after = account.balance.subtract(order.getAmount()).setScale(2); BigDecimal after = account.balance.subtract(order.getAmount()).setScale(2);
String voucher = "DEBIT_" + order.getRequestNo(); String voucher = "DEBIT_" + order.getRequestNo();
jdbc.update("INSERT INTO exchange_debit_record (voucher_no,exchange_id,bank_account_id,before_balance,amount," + int reused = jdbc.update("UPDATE exchange_debit_record SET bank_account_id=?,before_balance=?,amount=?," +
"after_balance,status,created_at,created_by) VALUES (?,?,?,?,?,?,'FROZEN',CURRENT_TIMESTAMP,?)", "after_balance=?,status='FROZEN',created_at=CURRENT_TIMESTAMP,created_by=? " +
voucher, order.getId().toString(), order.getBankAccountId(), account.balance, order.getAmount(), after, "WHERE exchange_id=? AND status='RELEASED'",
actor.getUsername()); 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); return new DebitHoldResult(account.balance, after, voucher);
} }
@Override @Override
public void releaseDebitHold(ExchangeOrder order, ExchangeActor actor) { 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-?," + int released = jdbc.update("UPDATE simulated_bank_account SET frozen_amount=frozen_amount-?," +
"updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND frozen_amount>=?", "updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND frozen_amount>=?",
order.getAmount(), order.getBankAccountId(), order.getAmount()); order.getAmount(), order.getBankAccountId(), order.getAmount());
@ -247,6 +257,20 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
return findReservedCoins(order); 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 @Override
public List<ReservedCoin> findReservedCoins(ExchangeOrder order) { public List<ReservedCoin> findReservedCoins(ExchangeOrder order) {
return jdbc.query("SELECT currency_id,denomination,status FROM exchange_coin_reservation WHERE exchange_id=? " + 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, public OwnershipConfirmation confirmOwnership(ExchangeOrder order, List<ReservedCoin> coins,
String confirmationDigest, String centralBankSignature, String confirmationDigest, String centralBankSignature,
ExchangeActor actor) { ExchangeActor actor) {
if (coins == null || coins.isEmpty()) throw validation("权属变更请求未包含币串"); verifyReservedCoins(order, coins, actor);
settleDebitHold(order, actor);
String transactionId = "TXN_" + order.getRequestNo(); 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); BigDecimal total = BigDecimal.ZERO.setScale(2);
for (ReservedCoin coin : coins) { for (ReservedCoin coin : coins) {
Integer valid = jdbc.queryForObject("SELECT COUNT(*) FROM commercial_bank_currency c " + 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); total = total.add(coin.getDenomination()).setScale(2);
} }
if (total.compareTo(order.getAmount()) != 0) throw validation("权属变更币串总额与兑换金额不一致"); 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 @Override

@ -97,11 +97,32 @@ public class ExchangeController {
@PostMapping("/{id}/reserve-coins") @PostMapping("/{id}/reserve-coins")
@Operation(summary = "货币兑换的步骤五之一:从商业银行库锁定等额币串") @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") @PostMapping("/{id}/confirm-ownership")
@Operation(summary = "货币兑换的步骤五之二:央行登记中心校验并变更币串权属") @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}") @GetMapping("/{id}")
@Operation(summary = "查询兑换订单和币串明细") @Operation(summary = "查询兑换订单和币串明细")

@ -8,26 +8,32 @@ public final class ActionOutcome<T> {
private final String nextStepCode; private final String nextStepCode;
private final String nextActionCode; private final String nextActionCode;
private final String businessId; private final String businessId;
private final boolean completesAttempt;
private ActionOutcome(T output, ActionStatus status, String nextStepCode, String nextActionCode, private ActionOutcome(T output, ActionStatus status, String nextStepCode, String nextActionCode,
String businessId) { String businessId, boolean completesAttempt) {
this.output = output; this.output = output;
this.status = status; this.status = status;
this.nextStepCode = nextStepCode; this.nextStepCode = nextStepCode;
this.nextActionCode = nextActionCode; this.nextActionCode = nextActionCode;
this.businessId = businessId; this.businessId = businessId;
this.completesAttempt = completesAttempt;
} }
public static <T> ActionOutcome<T> completed(T output) { 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) { 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) { 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; } public T getOutput() { return output; }
@ -35,4 +41,5 @@ public final class ActionOutcome<T> {
public String getNextStepCode() { return nextStepCode; } public String getNextStepCode() { return nextStepCode; }
public String getNextActionCode() { return nextActionCode; } public String getNextActionCode() { return nextActionCode; }
public String getBusinessId() { return businessId; } public String getBusinessId() { return businessId; }
public boolean isCompletesAttempt() { return completesAttempt; }
} }

@ -83,6 +83,11 @@ public class ExperimentAttemptService {
if (!action.getRequestFingerprint().equals(requestFingerprint)) { if (!action.getRequestFingerprint().equals(requestFingerprint)) {
throw validation("该动作已使用不同参数完成,不能覆盖原结果"); 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)); return toActionView(attempt, action, readOutput(action.getOutputJson(), outputType));
} }
@ -108,7 +113,15 @@ public class ExperimentAttemptService {
attempt.bindBusinessId(outcome.getBusinessId(), completedAt); attempt.bindBusinessId(outcome.getBusinessId(), completedAt);
} }
if (outcome.getStatus() == ActionStatus.ROLLED_BACK) { 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); attempt.moveTo(outcome.getNextStepCode(), outcome.getNextActionCode(), completedAt);
} else if (outcome.isCompletesAttempt()) {
attempt.complete(completedAt);
} else { } else {
attempt.moveTo(nextStepCode, nextActionCode, completedAt); attempt.moveTo(nextStepCode, nextActionCode, completedAt);
} }

@ -76,6 +76,15 @@ public final class ExperimentAction {
updatedAt = now; 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 void markPersisted() { version++; }
public UUID getId() { return id; } 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.institutionidentity.domain.InstitutionSm2KeyPair;
import com.yau.digitalrmb.testsupport.WalletOpeningTestData; import com.yau.digitalrmb.testsupport.WalletOpeningTestData;
import com.yau.digitalrmb.shared.wallet.WalletPrerequisiteProjectionService; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -35,6 +38,7 @@ class ExchangeControllerTest {
@Autowired private InstitutionIdentityCryptography cryptography; @Autowired private InstitutionIdentityCryptography cryptography;
@Autowired private InstitutionKeyService keyService; @Autowired private InstitutionKeyService keyService;
@Autowired private WalletPrerequisiteProjectionService walletProjection; @Autowired private WalletPrerequisiteProjectionService walletProjection;
@Autowired private ExchangeApplicationService exchangeService;
@BeforeEach @BeforeEach
void seedPrototypePrerequisites() { void seedPrototypePrerequisites() {
@ -187,6 +191,12 @@ class ExchangeControllerTest {
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00"); java.math.BigDecimal.class, accountId)).isEqualByComparingTo("0.00");
assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?", assertThat(jdbc.queryForObject("SELECT status FROM exchange_debit_record WHERE exchange_id=?",
String.class, id)).isEqualTo("SETTLED"); 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 @Test
@ -205,6 +215,153 @@ class ExchangeControllerTest {
.isZero(); .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() { 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")); 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; @MockBean private CurrentUserService currentUserService;
@Test @Test
void exposesLifecycleAndEveryExchangeActionThroughStepFour() throws Exception { void exposesLifecycleAndEveryExchangeAction() throws Exception {
when(currentUserService.getCurrentUser()).thenReturn(user()); when(currentUserService.getCurrentUser()).thenReturn(user());
String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(jwtForUser())) String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(jwtForUser()))
.andExpect(status().isOk()) .andExpect(status().isOk())
@ -63,7 +63,17 @@ class ExchangeTrainingAttemptControllerTest {
{"03", "wallet-sign"}, {"03", "package-request"}, {"03", "send-request"}, {"03", "wallet-sign"}, {"03", "package-request"}, {"03", "send-request"},
{"04", "verify-wallet-signature"}, {"04", "check-account-balance"}, {"04", "verify-wallet-signature"}, {"04", "check-account-balance"},
{"04", "check-wallet-limits"}, {"04", "execute-debit-hold"}, {"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) { for (String[] action : actions) {
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}", mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}",

@ -66,6 +66,10 @@ class ExperimentAttemptServiceTest {
@Test @Test
void retriesAnActionAfterCommittedRollback() { void retriesAnActionAfterCommittedRollback() {
ExperimentAttemptView attempt = service.create(ExperimentModule.EXCHANGE, subject); 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, ExperimentActionView<TestOutput> rolledBack = service.execute(attempt.getAttemptId(), subject,
"05", "reserve-coins", "fingerprint-a", TestOutput.class, "05", "reserve-coins", "fingerprint-a", TestOutput.class,
@ -80,6 +84,24 @@ class ExperimentAttemptServiceTest {
assertEquals("04", rolledBack.getCurrentStepCode()); assertEquals("04", rolledBack.getCurrentStepCode());
assertEquals(ActionStatus.COMPLETED, completed.getActionStatus()); assertEquals(ActionStatus.COMPLETED, completed.getActionStatus());
assertEquals("reserved", completed.getOutput().getValue()); 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 @Test

Loading…
Cancel
Save