diff --git a/src/main/java/com/yau/digitalrmb/exchange/application/command/CreateExchangeCommand.java b/src/main/java/com/yau/digitalrmb/exchange/application/command/CreateExchangeCommand.java index 45abd72..8632d81 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/application/command/CreateExchangeCommand.java +++ b/src/main/java/com/yau/digitalrmb/exchange/application/command/CreateExchangeCommand.java @@ -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; } } 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 8510076..78d2ab4 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,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 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 { 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 new file mode 100644 index 0000000..512f229 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/application/service/ExchangeTrainingActionService.java @@ -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 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 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 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 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 output = new LinkedHashMap(); + 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 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) action.getOutput(); + } + } + throw validation("缺少前置动作结果:" + step + ":" + actionCode); + } + + private Map value(String name, Object value) { + Map output = new LinkedHashMap(); + output.put(name, value); + return output; + } + + private ActionOutcome 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 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 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 values = new LinkedHashMap(); + 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); + } +} diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/DebitHoldResult.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/DebitHoldResult.java new file mode 100644 index 0000000..d35e428 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/DebitHoldResult.java @@ -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; } +} 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 0fab21e..6e41b7e 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 @@ -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; } diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java index b59f26a..df04313 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeStatus.java @@ -4,6 +4,7 @@ public enum ExchangeStatus { MESSAGE_PREPARED, SIGNED, BANK_PROCESSED, + FUNDS_HELD, COINS_RESERVED, SUCCESS, FAILED 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 ee31e3d..a2fa02e 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 @@ -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 reserveCoins(ExchangeOrder order, ExchangeActor actor); List findReservedCoins(ExchangeOrder order); OwnershipConfirmation confirmOwnership(ExchangeOrder order, List coins, diff --git a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeOrderRepository.java b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeOrderRepository.java index f3b7f2a..2af387a 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeOrderRepository.java +++ b/src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeOrderRepository.java @@ -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(), 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 eb2c167..c9afc68 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 @@ -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 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 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 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 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 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); } diff --git a/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java b/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java new file mode 100644 index 0000000..0bd3f70 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/interfaces/dto/ExchangeActionRequest.java @@ -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; } +} 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 62b049f..728475a 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 @@ -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 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 sign(@PathVariable UUID id) { return ok(service.sign(id, actor())); } + public ApiResponse 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 bankProcess(@PathVariable UUID id) { return ok(service.bankProcess(id, actor())); } + @Operation(summary = "货币兑换的步骤四:商业银行验签、校验余额与限额并冻结待扣资金") + public ApiResponse 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 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 ApiResponse ok(T data) { return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); } } diff --git a/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptController.java b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptController.java new file mode 100644 index 0000000..3f4864f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptController.java @@ -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 create() { + return ok(attempts.create(ExperimentModule.EXCHANGE, subject(users.getCurrentUser()))); + } + + @GetMapping("/current") + @Operation(summary = "恢复当前兑换实验") + public ApiResponse current() { + return ok(attempts.current(ExperimentModule.EXCHANGE, subject(users.getCurrentUser()))); + } + + @GetMapping("/{attemptId}") + @Operation(summary = "查询兑换实验详情和已保存动作") + public ApiResponse detail(@PathVariable UUID attemptId) { + return ok(attempts.detail(attemptId, subject(users.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/cancel") + @Operation(summary = "取消兑换实验") + public ApiResponse cancel(@PathVariable UUID attemptId) { + return ok(attempts.cancel(attemptId, subject(users.getCurrentUser()))); + } + + @PostMapping("/{attemptId}/steps/{stepCode}/actions/{actionCode}") + @Operation(summary = "执行一个兑换实验按钮动作") + public ApiResponse> 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 ApiResponse ok(T data) { + return ApiResponse.success(data, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java b/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java index 7a37cc0..db85e17 100644 --- a/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java +++ b/src/test/java/com/yau/digitalrmb/exchange/domain/model/ExchangeOrderTest.java @@ -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", 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 f1afd55..a885e7b 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 @@ -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 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 new file mode 100644 index 0000000..96b6f68 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeTrainingAttemptControllerTest.java @@ -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(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)); + } +}