feat: align exchange actions and freeze debit funds

agent/payment-training-progress
chenyuan 2 weeks ago
parent 3a6cdae136
commit 06816b536c

@ -6,12 +6,20 @@ public final class CreateExchangeCommand {
private final String walletId;
private final String bankAccountId;
private final BigDecimal amount;
private final String requestTimestamp;
public CreateExchangeCommand(String walletId, String bankAccountId, BigDecimal amount) {
this(walletId, bankAccountId, amount, null);
}
public CreateExchangeCommand(String walletId, String bankAccountId, BigDecimal amount,
String requestTimestamp) {
this.walletId = walletId; this.bankAccountId = bankAccountId; this.amount = amount;
this.requestTimestamp = requestTimestamp;
}
public String getWalletId() { return walletId; }
public String getBankAccountId() { return bankAccountId; }
public BigDecimal getAmount() { return amount; }
public String getRequestTimestamp() { return requestTimestamp; }
}

@ -2,7 +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.domain.model.BankProcessingResult;
import com.yau.digitalrmb.exchange.domain.model.DebitHoldResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.domain.model.ExchangeOrder;
@ -55,17 +55,24 @@ public class ExchangeApplicationService {
public ExchangeContext context(ExchangeActor actor) { return resourceRepository.loadContext(actor); }
public ExchangeContext initializeContext(ExchangeActor actor) {
resourceRepository.initializeContext(actor);
return resourceRepository.loadContext(actor);
}
@Transactional
public ExchangeOrderView create(CreateExchangeCommand command, ExchangeActor actor) {
if (command == null) throw validation("兑换请求不能为空");
BigDecimal amount = amount(command.getAmount());
resourceRepository.initializeContext(actor);
ExchangeContext context = resourceRepository.loadContext(actor);
if (!context.getWalletId().equals(command.getWalletId()) || !context.getBankAccountId().equals(command.getBankAccountId())) {
throw validation("钱包或银行卡不属于当前用户,或尚未完成绑定");
}
Instant now = clock.instant();
UUID id = UUID.randomUUID();
String timestamp = TIMESTAMP.format(now);
String timestamp = command.getRequestTimestamp() == null
? TIMESTAMP.format(now) : timestamp(command.getRequestTimestamp());
String requestNo = "WITHDRAW_REQ_" + timestamp + "_" + id.toString().substring(0, 8).toUpperCase();
String message = "WITHDRAW|" + context.getWalletId() + "|" + amount.toPlainString()
+ "|" + context.getBankCardNumber().replace(" ", "") + "|" + timestamp;
@ -92,6 +99,51 @@ public class ExchangeApplicationService {
return ExchangeOrderView.from(order, null);
}
@Transactional
public ExchangeOrderView sign(UUID id, String signingOriginalText, String digest, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != ExchangeStatus.MESSAGE_PREPARED) {
if (order.getStatus().ordinal() > ExchangeStatus.MESSAGE_PREPARED.ordinal()) return current(order);
throw validation("当前兑换订单尚不能签名");
}
String expectedDigest = cryptography.sm3(signingOriginalText);
if (!expectedDigest.equals(digest)) throw validation("签名原文摘要不一致");
String signature = resourceRepository.signWithWallet(order.getWalletId(), digest);
order.sign(signingOriginalText, digest, signature);
orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "03", "使用钱包私钥签名", signature, actor);
return ExchangeOrderView.from(order, null);
}
public ExchangeOrderView verifyWalletSignature(UUID id, ExchangeActor actor) {
ExchangeOrder order = owned(id, actor);
if (order.getWalletSignature() == null || !resourceRepository.verifyWalletSignature(
order.getWalletId(), order.getDigest(), order.getWalletSignature())) {
throw validation("钱包SM2签名验证失败");
}
return current(order);
}
public ExchangeOrderView checkAccountBalance(UUID id, ExchangeActor actor) {
ExchangeOrder order = owned(id, actor);
ExchangeContext context = resourceRepository.loadContext(actor);
if (context.getBankAccountBalance().compareTo(order.getAmount()) < 0) {
throw validation("银行卡可用余额不足");
}
return current(order);
}
public ExchangeOrderView checkWalletLimits(UUID id, ExchangeActor actor) {
ExchangeOrder order = owned(id, actor);
ExchangeContext context = resourceRepository.loadContext(actor);
if (order.getAmount().compareTo(context.getSingleLimit()) > 0
|| context.getUsedToday().add(order.getAmount()).compareTo(context.getDailyLimit()) > 0
|| context.getWalletBalance().add(order.getAmount()).compareTo(context.getBalanceLimit()) > 0) {
throw validation("兑换金额不符合钱包合约限额");
}
return current(order);
}
@Transactional
public ExchangeOrderView bankProcess(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
@ -102,8 +154,8 @@ public class ExchangeApplicationService {
if (!resourceRepository.verifyWalletSignature(order.getWalletId(), order.getDigest(), order.getWalletSignature())) {
throw validation("钱包SM2签名验证失败");
}
BankProcessingResult result = resourceRepository.debit(order, actor);
order.recordBankProcessing(result); orderRepository.save(order, actor.getUsername());
DebitHoldResult result = resourceRepository.holdDebit(order, actor);
order.recordDebitHold(result); orderRepository.save(order, actor.getUsername());
resourceRepository.appendStepLog(order, "04", "验签、限额校验并扣减银行卡余额", result.getDebitVoucherNo(), actor);
return ExchangeOrderView.from(order, null);
}
@ -111,8 +163,8 @@ public class ExchangeApplicationService {
@Transactional
public ExchangeOrderView reserveCoins(UUID id, ExchangeActor actor) {
ExchangeOrder order = ownedForUpdate(id, actor);
if (order.getStatus() != ExchangeStatus.BANK_PROCESSED) {
if (order.getStatus().ordinal() > ExchangeStatus.BANK_PROCESSED.ordinal()) return current(order);
if (order.getStatus() != ExchangeStatus.FUNDS_HELD) {
if (order.getStatus().ordinal() > ExchangeStatus.FUNDS_HELD.ordinal()) return current(order);
throw validation("请先完成商业银行验签和扣款");
}
List<ReservedCoin> coins = resourceRepository.reserveCoins(order, actor);
@ -182,6 +234,13 @@ public class ExchangeApplicationService {
private BusinessException validation(String message) { return new BusinessException(ErrorCode.VALIDATION_ERROR, message); }
private String timestamp(String value) {
if (value == null || !value.matches("\\d{14}")) {
throw validation("请求时间戳格式必须为yyyyMMddHHmmss");
}
return value;
}
private BigDecimal amount(BigDecimal value) {
if (value == null) throw validation("兑换金额不能为空");
try {

@ -0,0 +1,234 @@
package com.yau.digitalrmb.exchange.application.service;
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.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.training.attempt.application.ActionOutcome;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class ExchangeTrainingActionService {
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
.withZone(ZoneOffset.UTC);
private static final List<String> ACTIONS = Arrays.asList(
"01:refresh-wallet", "01:confirm-binding", "01:refresh-institution",
"02:generate-timestamp", "02:concatenate-withdrawal", "02:output-withdrawal-request",
"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");
private final ExperimentAttemptService attempts;
private final ExchangeApplicationService exchanges;
private final InstitutionIdentityCryptography cryptography;
private final ObjectMapper objectMapper;
public ExchangeTrainingActionService(ExperimentAttemptService attempts,
ExchangeApplicationService exchanges,
InstitutionIdentityCryptography cryptography,
ObjectMapper objectMapper) {
this.attempts = attempts;
this.exchanges = exchanges;
this.cryptography = cryptography;
this.objectMapper = objectMapper;
}
public ExperimentActionView<?> execute(UUID attemptId, String stepCode, String actionCode,
ExchangeActionRequest request, ExperimentSubject subject,
ExchangeActor actor) {
String key = stepCode + ":" + actionCode;
int index = ACTIONS.indexOf(key);
if (index < 0) throw validation("不支持的兑换实验动作:" + key);
ExchangeActionRequest input = request == null ? new ExchangeActionRequest() : request;
ExperimentAttemptView attempt = attempts.detail(attemptId, subject);
requirePrevious(attempt, index);
UUID exchangeId = businessId(attempt.getBusinessId());
if (index > ACTIONS.indexOf("02:output-withdrawal-request") && exchangeId == null) {
throw validation("请先输出取币请求报文");
}
String[] next = ACTIONS.get(Math.min(index + 1, ACTIONS.size() - 1)).split(":", 2);
final UUID boundExchangeId = exchangeId;
return attempts.execute(attemptId, subject, stepCode, actionCode,
fingerprint(key, input, subject), Object.class,
() -> run(key, input, attempt, boundExchangeId, actor), next[0], next[1]);
}
private ActionOutcome<Object> run(String key, ExchangeActionRequest request,
ExperimentAttemptView attempt, UUID exchangeId,
ExchangeActor actor) {
switch (key) {
case "01:refresh-wallet":
return completed(exchanges.initializeContext(actor));
case "01:confirm-binding":
case "01:refresh-institution":
return completed(exchanges.context(actor));
case "02:generate-timestamp":
case "03:generate-sign-timestamp":
return completed(value("timestamp", TIMESTAMP.format(Instant.now())));
case "02:concatenate-withdrawal":
return completed(withdrawalOutput(request, attempt, actor));
case "02:output-withdrawal-request": {
Map<String, Object> source = prior(attempt, "02", "concatenate-withdrawal");
ExchangeOrderView created = exchanges.create(new CreateExchangeCommand(
text(source, "walletId"), text(source, "bankAccountId"),
decimal(source, "amount"), text(source, "timestamp")), actor);
return ActionOutcome.completed(created, created.getId());
}
case "03:concatenate-sign-source": {
ExchangeOrderView order = exchanges.get(exchangeId, actor);
ExchangeContext context = exchanges.context(actor);
String timestamp = text(prior(attempt, "03", "generate-sign-timestamp"), "timestamp");
String source = "SIGN|" + order.getRequestNo() + "|" + order.getAmount().toPlainString()
+ "|" + context.getBankCardLast4() + "|" + timestamp;
return completed(value("sourceText", source));
}
case "03:generate-digest": {
String source = text(prior(attempt, "03", "concatenate-sign-source"), "sourceText");
return completed(value("digest", cryptography.sm3(source)));
}
case "03:wallet-sign": {
String source = text(prior(attempt, "03", "concatenate-sign-source"), "sourceText");
String digest = text(prior(attempt, "03", "generate-digest"), "digest");
return completed(exchanges.sign(exchangeId, source, digest, actor));
}
case "03:package-request":
case "03:send-request":
case "04:output-debit-voucher":
return completed(exchanges.get(exchangeId, actor));
case "04:verify-wallet-signature":
return completed(exchanges.verifyWalletSignature(exchangeId, actor));
case "04:check-account-balance":
return completed(exchanges.checkAccountBalance(exchangeId, actor));
case "04:check-wallet-limits":
return completed(exchanges.checkWalletLimits(exchangeId, actor));
case "04:execute-debit-hold":
return completed(exchanges.bankProcess(exchangeId, actor));
default:
throw validation("不支持的兑换实验动作:" + key);
}
}
private Map<String, Object> withdrawalOutput(ExchangeActionRequest request,
ExperimentAttemptView attempt,
ExchangeActor actor) {
ExchangeContext context = exchanges.context(actor);
if (request.getWalletId() != null && !context.getWalletId().equals(request.getWalletId())) {
throw validation("钱包不属于当前用户");
}
if (request.getBankAccountId() != null && !context.getBankAccountId().equals(request.getBankAccountId())) {
throw validation("银行账户不属于当前用户");
}
BigDecimal amount = money(request.getAmount());
String timestamp = text(prior(attempt, "02", "generate-timestamp"), "timestamp");
Map<String, Object> output = new LinkedHashMap<String, Object>();
output.put("walletId", context.getWalletId());
output.put("bankAccountId", context.getBankAccountId());
output.put("bankCardNumber", context.getBankCardNumber());
output.put("amount", amount.toPlainString());
output.put("timestamp", timestamp);
output.put("sourceText", "WITHDRAW|" + context.getWalletId() + "|" + amount.toPlainString()
+ "|" + context.getBankCardNumber() + "|" + timestamp);
return output;
}
private void requirePrevious(ExperimentAttemptView attempt, int index) {
if (index == 0) return;
String[] previous = ACTIONS.get(index - 1).split(":", 2);
for (ExperimentActionView<?> action : attempt.getActions()) {
if (previous[0].equals(action.getStepCode()) && previous[1].equals(action.getActionCode())
&& action.getActionStatus() == ActionStatus.COMPLETED) return;
}
throw validation("请先完成动作:" + ACTIONS.get(index - 1));
}
@SuppressWarnings("unchecked")
private Map<String, Object> prior(ExperimentAttemptView attempt, String step, String actionCode) {
for (ExperimentActionView<?> action : attempt.getActions()) {
if (step.equals(action.getStepCode()) && actionCode.equals(action.getActionCode())
&& action.getActionStatus() == ActionStatus.COMPLETED
&& action.getOutput() instanceof Map) {
return (Map<String, Object>) action.getOutput();
}
}
throw validation("缺少前置动作结果:" + step + ":" + actionCode);
}
private Map<String, Object> value(String name, Object value) {
Map<String, Object> output = new LinkedHashMap<String, Object>();
output.put(name, value);
return output;
}
private ActionOutcome<Object> completed(Object output) { return ActionOutcome.completed(output); }
private UUID businessId(String value) {
if (value == null || value.trim().isEmpty()) return null;
try { return UUID.fromString(value); }
catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "兑换实验绑定的业务标识无效");
}
}
private String text(Map<String, Object> values, String name) {
Object value = values.get(name);
if (value == null || value.toString().trim().isEmpty()) throw validation("缺少派生参数:" + name);
return value.toString();
}
private BigDecimal decimal(Map<String, Object> values, String name) {
try { return new BigDecimal(text(values, name)); }
catch (NumberFormatException exception) { throw validation("派生金额无效:" + name); }
}
private BigDecimal money(BigDecimal value) {
if (value == null || value.signum() <= 0 || value.scale() > 2) throw validation("兑换金额必须大于零且最多两位小数");
return value.setScale(2);
}
private String fingerprint(String key, ExchangeActionRequest request, ExperimentSubject subject) {
Map<String, Object> values = new LinkedHashMap<String, Object>();
values.put("action", key);
values.put("walletId", request.getWalletId());
values.put("bankAccountId", request.getBankAccountId());
values.put("amount", request.getAmount());
values.put("userId", subject.getUserId());
try {
byte[] json = objectMapper.writeValueAsString(values).getBytes(StandardCharsets.UTF_8);
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json);
StringBuilder result = new StringBuilder();
for (byte value : digest) result.append(String.format("%02x", value & 0xff));
return result.toString();
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "无法生成动作幂等指纹");
}
}
private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
}
}

@ -0,0 +1,20 @@
package com.yau.digitalrmb.exchange.domain.model;
import java.math.BigDecimal;
public final class DebitHoldResult {
private final BigDecimal beforeBalance;
private final BigDecimal availableBalanceAfterHold;
private final String debitVoucherNo;
public DebitHoldResult(BigDecimal beforeBalance, BigDecimal availableBalanceAfterHold,
String debitVoucherNo) {
this.beforeBalance = beforeBalance;
this.availableBalanceAfterHold = availableBalanceAfterHold;
this.debitVoucherNo = debitVoucherNo;
}
public BigDecimal getBeforeBalance() { return beforeBalance; }
public BigDecimal getAvailableBalanceAfterHold() { return availableBalanceAfterHold; }
public String getDebitVoucherNo() { return debitVoucherNo; }
}

@ -15,8 +15,8 @@ public final class ExchangeOrder {
private final BigDecimal amount;
private final String requestTimestamp;
private final String messageText;
private final String signingOriginalText;
private final String digest;
private String signingOriginalText;
private String digest;
private String walletSignature;
private String transferRequestNo;
private String transferOriginalText;
@ -96,6 +96,22 @@ public final class ExchangeOrder {
this.status = ExchangeStatus.SIGNED;
}
public void sign(String signingOriginalText, String digest, String signature) {
requireStatus(ExchangeStatus.MESSAGE_PREPARED);
this.signingOriginalText = required(signingOriginalText, "签名原文");
this.digest = required(digest, "摘要");
this.walletSignature = required(signature, "钱包签名");
this.status = ExchangeStatus.SIGNED;
}
public void recordDebitHold(DebitHoldResult result) {
requireStatus(ExchangeStatus.SIGNED);
this.bankBalanceBefore = result.getBeforeBalance();
this.bankBalanceAfter = result.getAvailableBalanceAfterHold();
this.debitVoucherNo = result.getDebitVoucherNo();
this.status = ExchangeStatus.FUNDS_HELD;
}
public void recordBankProcessing(BankProcessingResult result) {
requireStatus(ExchangeStatus.SIGNED);
this.bankBalanceBefore = result.getBeforeBalance(); this.bankBalanceAfter = result.getAfterBalance();
@ -103,7 +119,7 @@ public final class ExchangeOrder {
}
public void reserveCoins(int count) {
requireStatus(ExchangeStatus.BANK_PROCESSED);
requireStatus(ExchangeStatus.FUNDS_HELD);
if (count <= 0) throw new IllegalArgumentException("锁定币串数量必须大于0");
this.coinCount = count; this.status = ExchangeStatus.COINS_RESERVED;
}

@ -4,6 +4,7 @@ public enum ExchangeStatus {
MESSAGE_PREPARED,
SIGNED,
BANK_PROCESSED,
FUNDS_HELD,
COINS_RESERVED,
SUCCESS,
FAILED

@ -1,6 +1,7 @@
package com.yau.digitalrmb.exchange.domain.repository;
import com.yau.digitalrmb.exchange.domain.model.BankProcessingResult;
import com.yau.digitalrmb.exchange.domain.model.DebitHoldResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.domain.model.ExchangeOrder;
@ -11,9 +12,12 @@ import java.util.List;
public interface ExchangeResourceRepository {
ExchangeContext loadContext(ExchangeActor actor);
void initializeContext(ExchangeActor actor);
String signWithWallet(String walletId, String digest);
boolean verifyWalletSignature(String walletId, String digest, String signature);
BankProcessingResult debit(ExchangeOrder order, ExchangeActor actor);
DebitHoldResult holdDebit(ExchangeOrder order, ExchangeActor actor);
void releaseDebitHold(ExchangeOrder order, ExchangeActor actor);
BankProcessingResult settleDebitHold(ExchangeOrder order, ExchangeActor actor);
List<ReservedCoin> reserveCoins(ExchangeOrder order, ExchangeActor actor);
List<ReservedCoin> findReservedCoins(ExchangeOrder order);
OwnershipConfirmation confirmOwnership(ExchangeOrder order, List<ReservedCoin> coins,

@ -20,13 +20,13 @@ public class JdbcExchangeOrderRepository implements ExchangeOrderRepository {
@Override
public void save(ExchangeOrder order, String operator) {
int updated = jdbc.update("UPDATE currency_exchange_order SET wallet_signature=?, transfer_request_no=?, "
int updated = jdbc.update("UPDATE currency_exchange_order SET signing_original_text=?,digest=?,wallet_signature=?, transfer_request_no=?, "
+ "transfer_original_text=?, transfer_digest=?, bank_signature=?, transfer_transaction_id=?, "
+ "central_bank_signature=?, confirmation_original_text=?, confirmation_digest=?, "
+ "debit_voucher_no=?, bank_balance_before=?, "
+ "bank_balance_after=?, wallet_balance_after=?, coin_count=?, status=?, completed_at=?, "
+ "updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE id=?",
order.getWalletSignature(), order.getTransferRequestNo(), order.getTransferOriginalText(),
order.getSigningOriginalText(), order.getDigest(), order.getWalletSignature(), order.getTransferRequestNo(), order.getTransferOriginalText(),
order.getTransferDigest(), order.getBankSignature(), order.getTransferTransactionId(),
order.getCentralBankSignature(), order.getConfirmationOriginalText(), order.getConfirmationDigest(),
order.getDebitVoucherNo(), order.getBankBalanceBefore(),

@ -1,6 +1,7 @@
package com.yau.digitalrmb.exchange.infrastructure.persistence;
import com.yau.digitalrmb.exchange.domain.model.BankProcessingResult;
import com.yau.digitalrmb.exchange.domain.model.DebitHoldResult;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.domain.model.ExchangeOrder;
@ -46,8 +47,6 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
@Override
public ExchangeContext loadContext(ExchangeActor actor) {
walletPrerequisites.ensureForSubject(new InstitutionKeySubject(
actor.getUserId(), actor.getSchoolId(), actor.getClassId()));
Institution institution = loadIssuedInstitution(actor);
List<ExchangeContext> values = jdbc.query(
"SELECT w.wallet_id,w.wallet_type,w.status wallet_status,w.balance wallet_balance," +
@ -86,11 +85,17 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
rs.getBigDecimal("inventory_balance"), institution.organizationId),
institution.organizationId, actor.getUserId(), actor.getUserId(), institution.bankCode);
if (values.isEmpty()) {
throw validation("请先在个人数字钱包开通模块完成银行卡绑定、证书备案、合约生成和央行最终确权");
throw validation("请先在个人数字钱包开通实验完成银行卡绑定、证书备案、合约生成和央行最终确权");
}
return values.get(0);
}
@Override
public void initializeContext(ExchangeActor actor) {
walletPrerequisites.ensureForSubject(new InstitutionKeySubject(
actor.getUserId(), actor.getSchoolId(), actor.getClassId()));
}
@Override
public String signWithWallet(String walletId, String digest) {
List<String> values = jdbc.query("SELECT cert.encrypted_private_key FROM wallet_certificate cert " +
@ -113,7 +118,7 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
}
@Override
public BankProcessingResult debit(ExchangeOrder order, ExchangeActor actor) {
public DebitHoldResult holdDebit(ExchangeOrder order, ExchangeActor actor) {
List<AccountSnapshot> accounts = jdbc.query("SELECT balance,frozen_amount,status FROM simulated_bank_account " +
"WHERE account_id=? AND user_id=? FOR UPDATE",
(rs, row) -> new AccountSnapshot(rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount"),
@ -164,21 +169,48 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
order.getAmount(), order.getBankAccountId(), order.getAmount());
if (frozen != 1) throw validation("银行卡可用余额已变化,请刷新后重试");
BigDecimal after = account.balance.subtract(order.getAmount()).setScale(2);
int deducted = jdbc.update("UPDATE simulated_bank_account SET balance=balance-?,frozen_amount=frozen_amount-?," +
"updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND frozen_amount>=?",
order.getAmount(), order.getAmount(), order.getBankAccountId(), order.getAmount());
if (deducted != 1) throw validation("银行卡冻结资金扣减失败");
jdbc.update("UPDATE wallet_contract SET daily_used_amount=?,daily_counter_date=CURRENT_DATE," +
"annual_used_amount=?,annual_counter_year=?," +
"updated_at=CURRENT_TIMESTAMP WHERE contract_id=?",
usedToday.add(order.getAmount()), usedThisYear.add(order.getAmount()), currentYear,
contractId(order.getWalletId()));
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 (?,?,?,?,?,?,'DEDUCTED',CURRENT_TIMESTAMP,?)",
"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 BankProcessingResult(account.balance, after, voucher);
return new DebitHoldResult(account.balance, after, voucher);
}
@Override
public void releaseDebitHold(ExchangeOrder order, ExchangeActor actor) {
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());
if (released != 1) throw validation("银行卡冻结资金释放失败");
jdbc.update("UPDATE exchange_debit_record SET status='RELEASED' WHERE exchange_id=? AND status='FROZEN'",
order.getId().toString());
}
@Override
public BankProcessingResult settleDebitHold(ExchangeOrder order, ExchangeActor actor) {
List<AccountSnapshot> accounts = jdbc.query("SELECT balance,frozen_amount,status FROM simulated_bank_account " +
"WHERE account_id=? AND user_id=? FOR UPDATE",
(rs, row) -> new AccountSnapshot(rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount"),
rs.getString("status")), order.getBankAccountId(), actor.getUserId());
if (accounts.isEmpty() || !"ACTIVE".equals(accounts.get(0).status)) {
throw validation("钱包开通模块绑定的银行卡不存在或状态异常");
}
AccountSnapshot account = accounts.get(0);
if (account.frozen.compareTo(order.getAmount()) < 0 || account.balance.compareTo(order.getAmount()) < 0) {
throw validation("银行卡冻结资金不足,不能完成兑换结算");
}
int settled = jdbc.update("UPDATE simulated_bank_account SET balance=balance-?,frozen_amount=frozen_amount-?," +
"updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND frozen_amount>=? AND balance>=?",
order.getAmount(), order.getAmount(), order.getBankAccountId(), order.getAmount(), order.getAmount());
if (settled != 1) throw validation("银行卡冻结资金结算失败");
int voucher = jdbc.update("UPDATE exchange_debit_record SET status='SETTLED' " +
"WHERE exchange_id=? AND status='FROZEN'",
order.getId().toString());
if (voucher != 1) throw validation("兑换扣款凭证状态异常");
updateContractUsage(order);
return new BankProcessingResult(account.balance,
account.balance.subtract(order.getAmount()).setScale(2), order.getDebitVoucherNo());
}
@Override
@ -256,6 +288,8 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
}
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,?)",
@ -315,6 +349,27 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
String.class, walletId);
}
private void updateContractUsage(ExchangeOrder order) {
List<WalletLimit> limits = jdbc.query("SELECT daily_used_amount,daily_counter_date,annual_used_amount," +
"annual_counter_year FROM wallet_contract WHERE wallet_id=? FOR UPDATE",
(rs, row) -> new WalletLimit(null, null, rs.getBigDecimal("daily_used_amount"),
rs.getDate("daily_counter_date") == null ? null : rs.getDate("daily_counter_date").toLocalDate(),
null, rs.getBigDecimal("annual_used_amount"),
rs.getObject("annual_counter_year") == null ? null : rs.getInt("annual_counter_year"), null),
order.getWalletId());
if (limits.isEmpty()) throw validation("钱包合约不存在或尚未生效");
WalletLimit limit = limits.get(0);
BigDecimal usedToday = LocalDate.now().equals(limit.counterDate)
? limit.usedToday : BigDecimal.ZERO.setScale(2);
int year = LocalDate.now().getYear();
BigDecimal usedYear = limit.annualCounterYear != null && limit.annualCounterYear == year
? limit.annualUsed : BigDecimal.ZERO.setScale(2);
jdbc.update("UPDATE wallet_contract SET daily_used_amount=?,daily_counter_date=CURRENT_DATE," +
"annual_used_amount=?,annual_counter_year=?,updated_at=CURRENT_TIMESTAMP WHERE contract_id=?",
usedToday.add(order.getAmount()), usedYear.add(order.getAmount()), year,
contractId(order.getWalletId()));
}
private BusinessException validation(String message) {
return new BusinessException(ErrorCode.VALIDATION_ERROR, message);
}

@ -0,0 +1,19 @@
package com.yau.digitalrmb.exchange.interfaces.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
@Schema(description = "兑换实验单步动作输入;时间戳、原文、摘要和签名均由服务端生成")
public class ExchangeActionRequest {
private String walletId;
private String bankAccountId;
private BigDecimal amount;
public String getWalletId() { return walletId; }
public void setWalletId(String walletId) { this.walletId = walletId; }
public String getBankAccountId() { return bankAccountId; }
public void setBankAccountId(String bankAccountId) { this.bankAccountId = bankAccountId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}

@ -1,11 +1,12 @@
package com.yau.digitalrmb.exchange.interfaces.rest;
import com.yau.digitalrmb.exchange.application.command.CreateExchangeCommand;
import com.yau.digitalrmb.exchange.application.query.ExchangeOrderView;
import com.yau.digitalrmb.exchange.application.service.ExchangeApplicationService;
import com.yau.digitalrmb.exchange.application.service.ExchangeTrainingActionService;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.domain.model.ExchangeContext;
import com.yau.digitalrmb.exchange.interfaces.dto.CreateExchangeRequest;
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
@ -24,6 +25,10 @@ import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.UUID;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
@RestController
@RequestMapping("/api/v1/exchanges")
@ -31,9 +36,13 @@ import java.util.UUID;
public class ExchangeController {
private final ExchangeApplicationService service;
private final CurrentUserService currentUserService;
private final ExperimentAttemptService attempts;
private final ExchangeTrainingActionService actions;
public ExchangeController(ExchangeApplicationService service, CurrentUserService currentUserService) {
public ExchangeController(ExchangeApplicationService service, CurrentUserService currentUserService,
ExperimentAttemptService attempts, ExchangeTrainingActionService actions) {
this.service = service; this.currentUserService = currentUserService;
this.attempts = attempts; this.actions = actions;
}
@GetMapping("/context")
@ -43,16 +52,48 @@ public class ExchangeController {
@PostMapping
@Operation(summary = "货币兑换的步骤二:输入金额并生成取币请求报文")
public ApiResponse<ExchangeOrderView> create(@Valid @RequestBody CreateExchangeRequest request) {
return ok(service.create(new CreateExchangeCommand(request.getWalletId(), request.getBankAccountId(), request.getAmount()), actor()));
CurrentUser user = currentUserService.getCurrentUser();
ExperimentSubject subject = subject(user);
ExperimentAttemptView attempt = attempts.create(ExperimentModule.EXCHANGE, subject);
ExchangeActionRequest input = actionRequest(request.getWalletId(), request.getBankAccountId(), request.getAmount());
run(attempt, "01", "refresh-wallet", new ExchangeActionRequest(), user);
run(attempt, "01", "confirm-binding", new ExchangeActionRequest(), user);
run(attempt, "01", "refresh-institution", new ExchangeActionRequest(), user);
run(attempt, "02", "generate-timestamp", new ExchangeActionRequest(), user);
run(attempt, "02", "concatenate-withdrawal", input, user);
run(attempt, "02", "output-withdrawal-request", new ExchangeActionRequest(), user);
ExperimentAttemptView result = attempts.detail(attempt.getAttemptId(), subject);
return ok(service.get(UUID.fromString(result.getBusinessId()), actor(user)));
}
@PostMapping("/{id}/sign")
@Operation(summary = "货币兑换的步骤三使用钱包私钥生成SM2签名")
public ApiResponse<ExchangeOrderView> sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); }
public ApiResponse<ExchangeOrderView> sign(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.sign(id, actor(user)));
run(attempt, "03", "generate-sign-timestamp", new ExchangeActionRequest(), user);
run(attempt, "03", "concatenate-sign-source", new ExchangeActionRequest(), user);
run(attempt, "03", "generate-digest", new ExchangeActionRequest(), user);
run(attempt, "03", "wallet-sign", new ExchangeActionRequest(), user);
run(attempt, "03", "package-request", new ExchangeActionRequest(), user);
run(attempt, "03", "send-request", new ExchangeActionRequest(), user);
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/bank-process")
@Operation(summary = "货币兑换的步骤四:商业银行验签、校验余额与限额并扣款")
public ApiResponse<ExchangeOrderView> bankProcess(@PathVariable UUID id) { return ok(service.bankProcess(id, actor())); }
@Operation(summary = "货币兑换的步骤四:商业银行验签、校验余额与限额并冻结待扣资金")
public ApiResponse<ExchangeOrderView> bankProcess(@PathVariable UUID id) {
CurrentUser user = currentUserService.getCurrentUser();
ExperimentAttemptView attempt = matchingAttempt(id, user);
if (attempt == null) return ok(service.bankProcess(id, actor(user)));
run(attempt, "04", "verify-wallet-signature", new ExchangeActionRequest(), user);
run(attempt, "04", "check-account-balance", new ExchangeActionRequest(), user);
run(attempt, "04", "check-wallet-limits", new ExchangeActionRequest(), user);
run(attempt, "04", "execute-debit-hold", new ExchangeActionRequest(), user);
run(attempt, "04", "output-debit-voucher", new ExchangeActionRequest(), user);
return ok(service.get(id, actor(user)));
}
@PostMapping("/{id}/reserve-coins")
@Operation(summary = "货币兑换的步骤五之一:从商业银行库锁定等额币串")
@ -66,8 +107,9 @@ public class ExchangeController {
@Operation(summary = "查询兑换订单和币串明细")
public ApiResponse<ExchangeOrderView> get(@PathVariable UUID id) { return ok(service.get(id, actor())); }
private ExchangeActor actor() {
CurrentUser user = currentUserService.getCurrentUser();
private ExchangeActor actor() { return actor(currentUserService.getCurrentUser()); }
private ExchangeActor actor(CurrentUser user) {
try {
return new ExchangeActor(user.getUserId(), user.getName(), Long.parseLong(user.getSchoolId()),
Long.parseLong(user.getClassId()));
@ -76,5 +118,36 @@ public class ExchangeController {
}
}
private ExperimentSubject subject(CurrentUser user) {
try {
return new ExperimentSubject(user.getUserId(), Long.parseLong(user.getSchoolId()),
Long.parseLong(user.getClassId()), "");
} catch (RuntimeException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户缺少兑换实验所需的用户、学校或班级信息");
}
}
private void run(ExperimentAttemptView attempt, String step, String action,
ExchangeActionRequest request, CurrentUser user) {
actions.execute(attempt.getAttemptId(), step, action, request, subject(user), actor(user));
}
private ExperimentAttemptView matchingAttempt(UUID exchangeId, CurrentUser user) {
try {
ExperimentAttemptView attempt = attempts.current(ExperimentModule.EXCHANGE, subject(user));
return exchangeId.toString().equals(attempt.getBusinessId()) ? attempt : null;
} catch (BusinessException exception) {
if (exception.getErrorCode() == ErrorCode.RESOURCE_NOT_FOUND) return null;
throw exception;
}
}
private ExchangeActionRequest actionRequest(String walletId, String bankAccountId,
java.math.BigDecimal amount) {
ExchangeActionRequest request = new ExchangeActionRequest();
request.setWalletId(walletId); request.setBankAccountId(bankAccountId); request.setAmount(amount);
return request;
}
private <T> ApiResponse<T> ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); }
}

@ -0,0 +1,110 @@
package com.yau.digitalrmb.exchange.interfaces.rest;
import com.yau.digitalrmb.exchange.application.service.ExchangeTrainingActionService;
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/exchange/attempts")
@Tag(name = "数字货币兑换实验动作", description = "模块四可恢复实验及前端独立按钮动作")
public class ExchangeTrainingAttemptController {
private final ExperimentAttemptService attempts;
private final ExchangeTrainingActionService actions;
private final CurrentUserService users;
public ExchangeTrainingAttemptController(ExperimentAttemptService attempts,
ExchangeTrainingActionService actions,
CurrentUserService users) {
this.attempts = attempts;
this.actions = actions;
this.users = users;
}
@PostMapping
@Operation(summary = "开始新的兑换实验")
public ApiResponse<ExperimentAttemptView> create() {
return ok(attempts.create(ExperimentModule.EXCHANGE, subject(users.getCurrentUser())));
}
@GetMapping("/current")
@Operation(summary = "恢复当前兑换实验")
public ApiResponse<ExperimentAttemptView> current() {
return ok(attempts.current(ExperimentModule.EXCHANGE, subject(users.getCurrentUser())));
}
@GetMapping("/{attemptId}")
@Operation(summary = "查询兑换实验详情和已保存动作")
public ApiResponse<ExperimentAttemptView> detail(@PathVariable UUID attemptId) {
return ok(attempts.detail(attemptId, subject(users.getCurrentUser())));
}
@PostMapping("/{attemptId}/cancel")
@Operation(summary = "取消兑换实验")
public ApiResponse<ExperimentAttemptView> cancel(@PathVariable UUID attemptId) {
return ok(attempts.cancel(attemptId, subject(users.getCurrentUser())));
}
@PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}")
@Operation(summary = "执行一个兑换实验按钮动作")
public ApiResponse<ExperimentActionView<?>> execute(@PathVariable UUID attemptId,
@PathVariable String stepCode,
@PathVariable String actionCode,
@RequestBody(required = false) ExchangeActionRequest request) {
CurrentUser user = users.getCurrentUser();
return ok(actions.execute(attemptId, stepCode, actionCode,
request == null ? new ExchangeActionRequest() : request, subject(user), actor(user)));
}
private ExperimentSubject subject(CurrentUser user) {
return new ExperimentSubject(required(user.getUserId(), "用户 ID"),
numeric(user.getSchoolId(), "学校 ID"), numeric(user.getClassId(), "班级 ID"), "");
}
private ExchangeActor actor(CurrentUser user) {
return new ExchangeActor(required(user.getUserId(), "用户 ID"), required(user.getName(), "用户姓名"),
numeric(user.getSchoolId(), "学校 ID"), numeric(user.getClassId(), "班级 ID"));
}
private long numeric(String value, String name) {
try {
long result = Long.parseLong(required(value, name));
if (result <= 0) throw new NumberFormatException(name);
return result;
} catch (NumberFormatException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据中的" + name + "无效");
}
}
private String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录凭据缺少" + name);
}
return value.trim();
}
private <T> ApiResponse<T> ok(T data) {
return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY));
}
}

@ -14,12 +14,13 @@ class ExchangeOrderTest {
void enforcesThePrototypeWorkflow() {
ExchangeOrder order = order();
assertThatThrownBy(() -> order.recordBankProcessing(
new BankProcessingResult(new BigDecimal("50000"), new BigDecimal("45000"), "VOUCHER")))
assertThatThrownBy(() -> order.recordDebitHold(
new DebitHoldResult(new BigDecimal("50000"), new BigDecimal("45000"), "VOUCHER")))
.isInstanceOf(IllegalStateException.class);
order.sign("WALLET_SIGNATURE");
order.recordBankProcessing(new BankProcessingResult(new BigDecimal("50000"), new BigDecimal("45000"), "VOUCHER"));
order.recordDebitHold(new DebitHoldResult(new BigDecimal("50000"), new BigDecimal("45000"), "VOUCHER"));
assertThat(order.getStatus()).isEqualTo(ExchangeStatus.FUNDS_HELD);
order.reserveCoins(50);
order.confirm("TRANSFER_REQ_1", "TRANSFER_REQ_1|BANK", "TRANSFER_DIGEST", "BANK_SIGNATURE",
"TRANSFER_CONFIRM|TXN_1", "CONFIRM_DIGEST",

@ -16,6 +16,7 @@ import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair;
import com.yau.digitalrmb.testsupport.WalletOpeningTestData;
import com.yau.digitalrmb.shared.wallet.WalletPrerequisiteProjectionService;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
@ -33,9 +34,13 @@ class ExchangeControllerTest {
@Autowired private JdbcTemplate jdbc;
@Autowired private InstitutionIdentityCryptography cryptography;
@Autowired private InstitutionKeyService keyService;
@Autowired private WalletPrerequisiteProjectionService walletProjection;
@BeforeEach
void seedPrototypePrerequisites() {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID);
jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID);
jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,"
+ "role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,4,0,?)",
USER_ID, "exchange-test", "unused", "exchange-test", "2999", "兑换测试班", "1999", "延安大学", "999");
@ -76,6 +81,7 @@ class ExchangeControllerTest {
jdbc.update("DELETE FROM wallet_certificate");
jdbc.update("DELETE FROM simulated_bank_account");
jdbc.update("DELETE FROM digital_wallet");
walletProjection.ensureForSubject(subject);
for (int i = 1; i <= 2; i++) {
jdbc.update("INSERT INTO commercial_bank_currency (currency_id,source_currency_id,source_batch_id,"
+ "issuance_request_id,bank_code,organization_id,denomination,currency,complete_currency,"
@ -93,6 +99,9 @@ class ExchangeControllerTest {
@AfterEach
void cleanExchangeData() {
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID);
jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID);
jdbc.update("DELETE FROM exchange_step_log");
jdbc.update("DELETE FROM wallet_balance_ledger");
jdbc.update("DELETE FROM central_bank_currency_ownership");
@ -117,6 +126,8 @@ class ExchangeControllerTest {
@Test
void completesTheFiveStepExchangeAndKeepsTheResultIdempotent() throws Exception {
int walletRowsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet", Integer.class);
int accountRowsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM simulated_bank_account", Integer.class);
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(user()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.bankCardLast4").value("4567"))
@ -128,8 +139,8 @@ class ExchangeControllerTest {
.andReturn().getResponse().getContentAsString();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM currency_exchange_order", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM exchange_step_log", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE user_id=?", Integer.class, USER_ID))
.isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet", Integer.class)).isEqualTo(walletRowsBefore);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM simulated_bank_account", Integer.class)).isEqualTo(accountRowsBefore);
String walletId = JsonPath.read(context, "$.data.walletId");
String accountId = JsonPath.read(context, "$.data.bankAccountId");
String body = "{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId
@ -144,8 +155,14 @@ class ExchangeControllerTest {
mockMvc.perform(post("/api/v1/exchanges/{id}/sign", id).with(user()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SIGNED"));
mockMvc.perform(post("/api/v1/exchanges/{id}/bank-process", id).with(user()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("BANK_PROCESSED"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("FUNDS_HELD"))
.andExpect(jsonPath("$.data.bankBalanceAfter").value(49800.00));
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 status FROM exchange_debit_record WHERE exchange_id=?",
String.class, id)).isEqualTo("FROZEN");
mockMvc.perform(post("/api/v1/exchanges/{id}/reserve-coins", id).with(user()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("COINS_RESERVED"))
.andExpect(jsonPath("$.data.coinCount").value(2));
@ -164,6 +181,12 @@ class ExchangeControllerTest {
.isEqualByComparingTo("200.00");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_balance_ledger WHERE exchange_id=?", Integer.class, id))
.isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?",
java.math.BigDecimal.class, accountId)).isEqualByComparingTo("49800.00");
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, id)).isEqualTo("SETTLED");
}
@Test

@ -0,0 +1,93 @@
package com.yau.digitalrmb.exchange.interfaces.rest;
import com.jayway.jsonpath.JsonPath;
import com.yau.digitalrmb.exchange.application.service.ExchangeTrainingActionService;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
import com.yau.digitalrmb.training.attempt.domain.AttemptStatus;
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Collections;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ExchangeTrainingAttemptControllerTest {
private static final String USER_ID = "00000000-0000-0000-0000-000000000488";
@Autowired private MockMvc mockMvc;
@MockBean private ExchangeTrainingActionService actionService;
@MockBean private CurrentUserService currentUserService;
@Test
void exposesLifecycleAndEveryExchangeActionThroughStepFour() throws Exception {
when(currentUserService.getCurrentUser()).thenReturn(user());
String created = mockMvc.perform(post("/api/v1/exchange/attempts").with(jwtForUser()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.moduleCode").value("EXCHANGE"))
.andReturn().getResponse().getContentAsString();
UUID attemptId = UUID.fromString(JsonPath.read(created, "$.data.attemptId"));
when(actionService.execute(any(UUID.class), anyString(), anyString(), any(), any(), any()))
.thenAnswer(invocation -> new ExperimentActionView<Object>(invocation.getArgument(0),
ExperimentModule.EXCHANGE, invocation.getArgument(1), invocation.getArgument(2),
ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, invocation.getArgument(1),
invocation.getArgument(2), Instant.now(), Collections.singletonMap("safe", true)));
String[][] actions = {
{"01", "refresh-wallet"}, {"01", "confirm-binding"}, {"01", "refresh-institution"},
{"02", "generate-timestamp"}, {"02", "concatenate-withdrawal"},
{"02", "output-withdrawal-request"}, {"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"}
};
for (String[] action : actions) {
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/steps/{step}/actions/{action}",
attemptId, action[0], action[1]).with(jwtForUser())
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.stepCode").value(action[0]))
.andExpect(jsonPath("$.data.actionCode").value(action[1]));
}
mockMvc.perform(get("/api/v1/exchange/attempts/current").with(jwtForUser()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptId").value(attemptId.toString()));
mockMvc.perform(get("/api/v1/exchange/attempts/{id}", attemptId).with(jwtForUser()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptId").value(attemptId.toString()));
mockMvc.perform(post("/api/v1/exchange/attempts/{id}/cancel", attemptId).with(jwtForUser()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptStatus").value("CANCELLED"));
}
private CurrentUser user() {
return new CurrentUser("1001", "延安大学", null, null, null, null, 4L,
USER_ID, "exchange", "兑换用户", "2001", "测试班", "exchange");
}
private org.springframework.test.web.servlet.request.RequestPostProcessor jwtForUser() {
return jwt().jwt(token -> token.subject(USER_ID).claim("userId", USER_ID));
}
}
Loading…
Cancel
Save