diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/query/ExchangeReservationResult.java b/src/main/java/com/yau/digitalrmb/exchange/application/query/ExchangeReservationResult.java new file mode 100644 index 0000000..91240c3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/application/query/ExchangeReservationResult.java @@ -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; } +} diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java index 78d2ab4..c17a89c 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeApplicationService.java @@ -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 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 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 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 { diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeAttemptCancellationHandler.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeAttemptCancellationHandler.java new file mode 100644 index 0000000..fdc26c0 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeAttemptCancellationHandler.java @@ -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, "兑换实验绑定的业务标识无效"); + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java index 512f229..8166b0d 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java @@ -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); } diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java index 6e41b7e..bdeeb61 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrder.java @@ -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) { diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java b/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java index a2fa02e..bc373f0 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/repository/ExchangeResourceRepository.java @@ -19,7 +19,9 @@ public interface ExchangeResourceRepository { void releaseDebitHold(ExchangeOrder order, ExchangeActor actor); BankProcessingResult settleDebitHold(ExchangeOrder order, ExchangeActor actor); List reserveCoins(ExchangeOrder order, ExchangeActor actor); + void releaseReservedCoins(ExchangeOrder order, ExchangeActor actor); List findReservedCoins(ExchangeOrder order); + void verifyReservedCoins(ExchangeOrder order, List coins, ExchangeActor actor); OwnershipConfirmation confirmOwnership(ExchangeOrder order, List coins, String confirmationDigest, String centralBankSignature, ExchangeActor actor); diff --git a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java index c9afc68..2161420 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java @@ -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 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 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 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 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 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 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 diff --git a/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java index 728475a..2884fb8 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java +++ b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeController.java @@ -97,11 +97,32 @@ public class ExchangeController { @PostMapping("/{id}/reserve-coins") @Operation(summary = "货币兑换的步骤五之一:从商业银行库锁定等额币串") - public ApiResponse reserveCoins(@PathVariable UUID id) { return ok(service.reserveCoins(id, actor())); } + public ApiResponse 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 confirmOwnership(@PathVariable UUID id) { return ok(service.confirmOwnership(id, actor())); } + public ApiResponse 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 = "查询兑换订单和币串明细") diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java b/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java index 3df2f8e..b7bee8a 100644 --- a/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java +++ b/src/main/java/com/yau/digitalrmb/training/attempt/application/ActionOutcome.java @@ -8,26 +8,32 @@ public final class ActionOutcome { 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 ActionOutcome completed(T output) { - return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, null); + return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, null, false); } public static ActionOutcome completed(T output, String businessId) { - return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, businessId); + return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, businessId, false); + } + + public static ActionOutcome completedAttempt(T output) { + return new ActionOutcome(output, ActionStatus.COMPLETED, null, null, null, true); } public static ActionOutcome rolledBack(T output, String nextStepCode, String nextActionCode) { - return new ActionOutcome(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode, null); + return new ActionOutcome(output, ActionStatus.ROLLED_BACK, nextStepCode, nextActionCode, null, false); } public T getOutput() { return output; } @@ -35,4 +41,5 @@ public final class ActionOutcome { public String getNextStepCode() { return nextStepCode; } public String getNextActionCode() { return nextActionCode; } public String getBusinessId() { return businessId; } + public boolean isCompletesAttempt() { return completesAttempt; } } diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java b/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java index 75ef037..37b800e 100644 --- a/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java +++ b/src/main/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptService.java @@ -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); } diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java index 756e07e..9e7906d 100644 --- a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java @@ -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; } diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java index a885e7b..0317ac0 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java @@ -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")); } diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java index 96b6f68..2b2936e 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java @@ -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}", diff --git a/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java b/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java index c6088d7..a278654 100644 --- a/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/training/attempt/application/ExperimentAttemptServiceTest.java @@ -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 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 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