From 3e76132ad9e6ef9b0182847113dd4dfb4025513c Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 20 Aug 2026 13:59:33 +0800 Subject: [PATCH] feat(training): integrate issuance and exchange workflows --- .../domain/model/ExchangeContext.java | 10 +- .../JdbcExchangeResourceRepository.java | 31 +++++- .../DigitalCurrencyProductionDigestView.java | 22 +++++ .../query/ReserveDeductionExecutionView.java | 7 +- ...tralBankIssuanceBusinessReviewService.java | 33 +++++-- ...DigitalCurrencyDraftProductionService.java | 17 +++- ...igitalCurrencyGenerationModuleGateway.java | 59 ++++++++++-- .../IssuanceAttemptCancellationHandler.java | 4 +- .../IssuanceTrainingActionService.java | 4 +- .../ReserveDeductionExecutionService.java | 43 ++++++++- .../ReserveDeductionNotificationService.java | 2 +- .../model/ReserveDeductionExecution.java | 32 +++++++ .../ReserveDeductionExecutionRepository.java | 1 + .../ReserveDeductionExecutionEntity.java | 4 + .../IssuanceSourceCurrencyUsageMapper.java | 7 ++ .../ReserveDeductionExecutionMapper.java | 4 + ...isReserveDeductionExecutionRepository.java | 16 +++- src/main/resources/schema.sql | 4 + .../rest/ExchangeControllerTest.java | 3 + ...alCurrencyGenerationModuleGatewayTest.java | 4 +- ...encyProductionAndOwnershipServiceTest.java | 2 +- ...ssuanceAttemptCancellationHandlerTest.java | 8 +- .../ReserveDeductionExecutionServiceTest.java | 96 +++++++++++++++++++ .../rest/IssuanceControllerTest.java | 21 ++++ .../testsupport/WalletOpeningTestData.java | 7 +- src/test/resources/schema.sql | 7 ++ 26 files changed, 414 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyProductionDigestView.java create mode 100644 src/test/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionServiceTest.java diff --git a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeContext.java b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeContext.java index 4fe8664..a06fd4f 100644 --- a/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeContext.java +++ b/src/main/java/com/yau/digitalrmb/exchange/domain/model/ExchangeContext.java @@ -4,6 +4,9 @@ import java.math.BigDecimal; import java.time.Instant; public final class ExchangeContext { + private final String userName; + private final String idNumber; + private final String phone; private final String walletId; private final String walletType; private final String walletStatus; @@ -25,13 +28,15 @@ public final class ExchangeContext { private final BigDecimal bankInventoryBalance; private final String organizationId; - public ExchangeContext(String walletId, String walletType, String walletStatus, BigDecimal walletBalance, + public ExchangeContext(String userName, String idNumber, String phone, + String walletId, String walletType, String walletStatus, BigDecimal walletBalance, BigDecimal singleLimit, BigDecimal dailyLimit, BigDecimal usedToday, BigDecimal annualLimit, BigDecimal balanceLimit, Instant contractValidUntil, String contractId, String walletPublicKey, String bankAccountId, String bankCode, String bankName, String bankCardNumber, String bankCardLast4, BigDecimal bankAccountBalance, BigDecimal bankInventoryBalance, String organizationId) { + this.userName = userName; this.idNumber = idNumber; this.phone = phone; this.walletId = walletId; this.walletType = walletType; this.walletStatus = walletStatus; this.walletBalance = walletBalance; this.singleLimit = singleLimit; this.dailyLimit = dailyLimit; this.annualLimit = annualLimit; this.balanceLimit = balanceLimit; this.contractValidUntil = contractValidUntil; @@ -42,6 +47,9 @@ public final class ExchangeContext { this.organizationId = organizationId; } + public String getUserName() { return userName; } + public String getIdNumber() { return idNumber; } + public String getPhone() { return phone; } public String getWalletId() { return walletId; } public String getWalletType() { return walletType; } public String getWalletStatus() { return walletStatus; } 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 1edbf2f..9c0f538 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 @@ -48,6 +48,7 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor @Override public ExchangeContext loadContext(ExchangeActor actor) { Institution institution = loadIssuedInstitution(actor); + UserProfile profile = loadUserProfile(actor); List values = jdbc.query( "SELECT w.wallet_id,w.wallet_type,w.status wallet_status,w.balance wallet_balance," + "c.contract_id,c.single_payment_limit,c.daily_payment_limit,c.annual_payment_limit," + @@ -74,7 +75,8 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor "c.daily_payment_limit,c.annual_payment_limit,c.balance_limit,c.valid_until," + "c.daily_counter_date,c.daily_used_amount,cert.public_key,a.account_id," + "a.bank_code,a.bank_name,a.card_number,a.card_last4,a.balance", - (rs, row) -> new ExchangeContext(rs.getString("wallet_id"), rs.getString("wallet_type"), + (rs, row) -> new ExchangeContext(profile.userName, profile.idNumber, profile.phone, + rs.getString("wallet_id"), rs.getString("wallet_type"), rs.getString("wallet_status"), rs.getBigDecimal("wallet_balance"), rs.getBigDecimal("single_payment_limit"), rs.getBigDecimal("daily_payment_limit"), rs.getBigDecimal("used_today"), rs.getBigDecimal("annual_payment_limit"), @@ -90,6 +92,21 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor return values.get(0); } + private UserProfile loadUserProfile(ExchangeActor actor) { + List values = jdbc.query( + "SELECT user_name,COALESCE(input_id_number,id_number) id_number," + + "COALESCE(input_phone,phone) phone FROM wallet_application " + + "WHERE user_id=? AND school_id=? AND class_id=? AND status='SUBMITTED' " + + "AND deleted=FALSE ORDER BY created_at DESC LIMIT 1", + (rs, row) -> new UserProfile(rs.getString("user_name"), rs.getString("id_number"), + rs.getString("phone")), + actor.getUserId(), actor.getSchoolId(), actor.getClassId()); + if (values.isEmpty()) { + throw validation("请先完成个人数字钱包开通实验的用户申请信息"); + } + return values.get(0); + } + @Override public void initializeContext(ExchangeActor actor) { walletPrerequisites.ensureForSubject(new InstitutionKeySubject( @@ -433,6 +450,18 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor } } + private static final class UserProfile { + private final String userName; + private final String idNumber; + private final String phone; + + private UserProfile(String userName, String idNumber, String phone) { + this.userName = userName; + this.idNumber = idNumber; + this.phone = phone; + } + } + private static final class AccountSnapshot { private final BigDecimal balance; private final BigDecimal frozen; diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyProductionDigestView.java b/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyProductionDigestView.java new file mode 100644 index 0000000..8fe9ab4 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/application/query/DigitalCurrencyProductionDigestView.java @@ -0,0 +1,22 @@ +package com.yau.digitalrmb.issuance.application.query; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.List; + +@Getter +@AllArgsConstructor +public class DigitalCurrencyProductionDigestView { + private final DigitalCurrencyProductionBatchView batch; + private final List digests; + + @Getter + @AllArgsConstructor + public static class CoinDigestView { + private final String coinId; + private final BigDecimal denomination; + private final String digest; + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/query/ReserveDeductionExecutionView.java b/src/main/java/com/yau/digitalrmb/issuance/application/query/ReserveDeductionExecutionView.java index 964ed23..1a3808b 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/query/ReserveDeductionExecutionView.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/query/ReserveDeductionExecutionView.java @@ -18,9 +18,14 @@ public class ReserveDeductionExecutionView { private final Instant executedAt; private final String executedByUserId; private final String executedBy; + private final BigDecimal reversedBalance; + private final Instant reversedAt; + private final String reversedByUserId; + private final String reversedBy; public static ReserveDeductionExecutionView from(ReserveDeductionExecution value) { return new ReserveDeductionExecutionView(value.getTransactionId(), value.getBeforeBalance(), value.getDeductionAmount(), - value.getAfterBalance(), value.getStatus(), value.getExecutedAt(), value.getExecutedByUserId(), value.getExecutedBy()); + value.getAfterBalance(), value.getStatus(), value.getExecutedAt(), value.getExecutedByUserId(), value.getExecutedBy(), + value.getReversedBalance(), value.getReversedAt(), value.getReversedByUserId(), value.getReversedBy()); } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceBusinessReviewService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceBusinessReviewService.java index 45a2ec3..eabefd4 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceBusinessReviewService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceBusinessReviewService.java @@ -2,6 +2,7 @@ package com.yau.digitalrmb.issuance.application.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.issuance.application.query.CentralBankIssuanceBusinessReviewView; import com.yau.digitalrmb.issuance.domain.model.CentralBankCurrencyVault; import com.yau.digitalrmb.issuance.domain.model.CentralBankInstitutionAccount; @@ -33,6 +34,7 @@ public class CentralBankIssuanceBusinessReviewService { private final CentralBankInstitutionAccountRepository accountRepository; private final CentralBankCurrencyVaultRepository vaultRepository; private final CentralBankIssuanceBusinessReviewRepository reviewRepository; + private final DigitalCurrencyGenerationModuleGateway generationModuleGateway; private final ObjectMapper objectMapper; public CentralBankIssuanceBusinessReviewService(CentralBankIssuanceReceiptRepository receiptRepository, @@ -40,17 +42,20 @@ public class CentralBankIssuanceBusinessReviewService { CentralBankInstitutionAccountRepository accountRepository, CentralBankCurrencyVaultRepository vaultRepository, CentralBankIssuanceBusinessReviewRepository reviewRepository, + DigitalCurrencyGenerationModuleGateway generationModuleGateway, ObjectMapper objectMapper) { this.receiptRepository = receiptRepository; this.verificationRepository = verificationRepository; this.accountRepository = accountRepository; this.vaultRepository = vaultRepository; this.reviewRepository = reviewRepository; + this.generationModuleGateway = generationModuleGateway; this.objectMapper = objectMapper; } @Transactional - public CentralBankIssuanceBusinessReviewView review(UUID requestId, IssuanceAuditActor actor) { + public CentralBankIssuanceBusinessReviewView review(UUID requestId, IssuanceAuditActor actor, + InstitutionKeySubject keySubject) { requireActor(actor); requireVerificationPassed(requestId); CentralBankIssuanceBusinessReview result; @@ -62,9 +67,14 @@ public class CentralBankIssuanceBusinessReviewService { String currency = text(payload, "currency"); String denominationSummary = text(payload, "denominations"); BigDecimal requestedAmount = new BigDecimal(text(payload, "totalAmount")); - BigDecimal verifiedAmount = denominationTotal(denominationSummary); + List denominations = + denominations(denominationSummary); + BigDecimal verifiedAmount = denominationTotal(denominations); + generationModuleGateway.validateIssuancePrerequisites( + keySubject, requestedAmount, organizationId, denominations); Optional account = accountRepository.findByBankCode(bankCode); - boolean accountValid = account.isPresent() && organizationId.equals(account.get().getOrganizationId()) && "NORMAL".equals(account.get().getStatus()); + boolean accountValid = account.isPresent() && "NORMAL".equals(account.get().getStatus()) + && generationModuleGateway.isCurrentConfirmedInstitutionIdentifier(keySubject, organizationId); Optional vault = vaultRepository.findByCurrency(currency); boolean vaultSufficient = vault.isPresent() && vault.get().getAvailableBalance().compareTo(requestedAmount) >= 0; boolean amountConsistent = requestedAmount.compareTo(verifiedAmount) == 0; @@ -101,8 +111,9 @@ public class CentralBankIssuanceBusinessReviewService { } } - private BigDecimal denominationTotal(String value) { - BigDecimal total = BigDecimal.ZERO; + private List denominations(String value) { + List result = + new ArrayList(); String[] items = value.split(","); if (items.length == 0) throw new IllegalArgumentException("面额明细不能为空"); for (String item : items) { @@ -111,7 +122,17 @@ public class CentralBankIssuanceBusinessReviewService { BigDecimal denomination = new BigDecimal(pair[0]); int quantity = Integer.parseInt(pair[1]); if (denomination.signum() <= 0 || quantity < 0) throw new IllegalArgumentException("面额明细数值无效:" + item); - total = total.add(denomination.multiply(BigDecimal.valueOf(quantity))); + result.add(new com.yau.digitalrmb.issuance.domain.model.DenominationItem( + denomination, quantity)); + } + return result; + } + + private BigDecimal denominationTotal( + List denominations) { + BigDecimal total = BigDecimal.ZERO; + for (com.yau.digitalrmb.issuance.domain.model.DenominationItem item : denominations) { + total = total.add(item.getDenomination().multiply(BigDecimal.valueOf(item.getQuantity()))); } return total.setScale(2); } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyDraftProductionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyDraftProductionService.java index dcdcf8a..c829f30 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyDraftProductionService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyDraftProductionService.java @@ -3,6 +3,7 @@ package com.yau.digitalrmb.issuance.application.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yau.digitalrmb.issuance.application.query.DigitalCurrencyProductionBatchView; +import com.yau.digitalrmb.issuance.application.query.DigitalCurrencyProductionDigestView; import com.yau.digitalrmb.issuance.domain.model.CentralBankIssuanceReceipt; import com.yau.digitalrmb.issuance.domain.model.DigitalCurrencyProductionBatch; import com.yau.digitalrmb.issuance.domain.model.DraftDigitalCurrency; @@ -65,7 +66,7 @@ public class DigitalCurrencyDraftProductionService { if (existing.isPresent()) return DigitalCurrencyProductionBatchView.from(existing.get()); ReserveDeductionNotification notification = notificationRepository.findByRequestId(requestId) .orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR, "请先发送准备金扣减通知")); - ReserveDeductionExecution execution = executionRepository.findByTransactionId(notification.getTransactionId()) + ReserveDeductionExecution execution = executionRepository.findByTransactionIdForUpdate(notification.getTransactionId()) .orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR, "请先完成存款准备金扣减")); if (!"DEDUCTED".equals(execution.getStatus())) { throw new BusinessException(ErrorCode.VALIDATION_ERROR, "存款准备金尚未扣减成功,不能生产数字货币"); @@ -108,6 +109,20 @@ public class DigitalCurrencyDraftProductionService { return productionRepository.findByRequestId(requestId).map(DigitalCurrencyProductionBatchView::from); } + @Transactional(readOnly = true) + public Optional digests(UUID requestId) { + return productionRepository.findByRequestId(requestId).map(batch -> { + List values = + new ArrayList(); + for (DraftDigitalCurrency coin : batch.getDraftCoins()) { + values.add(new DigitalCurrencyProductionDigestView.CoinDigestView( + coin.getCoinId(), coin.getDenomination(), + generationGateway.digest(coin.getSourceCompleteCurrency()))); + } + return new DigitalCurrencyProductionDigestView(DigitalCurrencyProductionBatchView.from(batch), values); + }); + } + private List buildCoins(String batchId, String organizationId, String currency, List reservedCurrencies, Instant createdAt, IssuanceAuditActor actor, diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java index 1d51ef8..7578b6a 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGateway.java @@ -2,6 +2,8 @@ package com.yau.digitalrmb.issuance.application.service; import com.yau.digitalrmb.institutionidentity.application.ControlSystemSignatureResult; import com.yau.digitalrmb.institutionidentity.application.ControlSystemSignatureService; +import com.yau.digitalrmb.institutionidentity.application.CommercialBankInstitutionIdentifierResult; +import com.yau.digitalrmb.institutionidentity.application.InstitutionIdentifierFeedbackService; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.application.QuotaControlBitResult; @@ -43,6 +45,7 @@ public class DigitalCurrencyGenerationModuleGateway { private final IssuanceSourceCurrencyUsageMapper sourceUsageMapper; private final JdbcTemplate jdbcTemplate; private final InstitutionIdentityCryptography cryptography; + private final InstitutionIdentifierFeedbackService identifierFeedbackService; @Autowired public DigitalCurrencyGenerationModuleGateway(InstitutionKeyService keyService, @@ -52,7 +55,8 @@ public class DigitalCurrencyGenerationModuleGateway { StandardCurrencyMapper standardCurrencyMapper, IssuanceSourceCurrencyUsageMapper sourceUsageMapper, JdbcTemplate jdbcTemplate, - InstitutionIdentityCryptography cryptography) { + InstitutionIdentityCryptography cryptography, + InstitutionIdentifierFeedbackService identifierFeedbackService) { this.keyService = keyService; this.quotaService = quotaService; this.controlSignatureService = controlSignatureService; @@ -61,12 +65,27 @@ public class DigitalCurrencyGenerationModuleGateway { this.sourceUsageMapper = sourceUsageMapper; this.jdbcTemplate = jdbcTemplate; this.cryptography = cryptography; + this.identifierFeedbackService = identifierFeedbackService; } public DigitalCurrencyGenerationModuleGateway(InstitutionKeyService keyService, QuotaControlBitService quotaService, ControlSystemSignatureService controlSignatureService) { - this(keyService, quotaService, controlSignatureService, null, null, null, null, null); + this(keyService, quotaService, controlSignatureService, null, null, null, null, null, null); + } + + public boolean isCurrentConfirmedInstitutionIdentifier(InstitutionKeySubject subject, + String institutionIdentifier) { + requiredSubject(subject); + if (identifierFeedbackService == null) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, + "Institution identifier feedback service is not configured"); + } + CommercialBankInstitutionIdentifierResult current = + identifierFeedbackService.commercialBankResult(subject); + return "FEEDBACKED".equals(current.getApplicationStatus()) + && required(institutionIdentifier, "issuance institution identifier") + .equals(current.getInstitutionIdentifier()); } public String signIssuanceDigest(InstitutionKeySubject subject, String digest) { @@ -100,6 +119,32 @@ public class DigitalCurrencyGenerationModuleGateway { public String commercialBankSigningKeyId() { return InstitutionKeyService.BANK_SECOND_KEY; } public String centralBankSigningKeyId() { return InstitutionKeyService.CENTRAL_FIRST_KEY; } + @Transactional(readOnly = true) + public void validateIssuancePrerequisites(InstitutionKeySubject subject, BigDecimal amount, + String institutionIdentifier, + List denominations) { + IssuanceControlMaterial material = requireControlMaterial(subject, amount, institutionIdentifier); + if (standardCurrencyBatchMapper == null || sourceUsageMapper == null) { + throw new BusinessException(ErrorCode.INTERNAL_ERROR, + "生成模块币串库存服务尚未配置"); + } + StandardCurrencyBatchEntity batch = standardCurrencyBatchMapper.selectOne( + new LambdaQueryWrapper() + .eq(StandardCurrencyBatchEntity::getQuotaControlBitId, + material.getQuotaControlBitId()) + .eq(StandardCurrencyBatchEntity::getUserId, subject.getUserId()) + .eq(StandardCurrencyBatchEntity::getSchoolId, subject.getSchoolId()) + .eq(StandardCurrencyBatchEntity::getClassId, subject.getClassId()) + .eq(StandardCurrencyBatchEntity::getStatus, "GENERATED") + .eq(StandardCurrencyBatchEntity::getDeleted, false) + .last("LIMIT 1")); + if (batch == null) { + throw validation("生成模块尚未生成与本次额度控制位对应的标准币串"); + } + selectExactQuantities(sourceUsageMapper.selectAvailableSources(batch.getId()), + requestedQuantities(denominations)); + } + @Transactional public List reserveGeneratedCurrencies(InstitutionKeySubject subject, Long quotaControlBitId, @@ -359,8 +404,8 @@ public class DigitalCurrencyGenerationModuleGateway { String institutionIdentifier) { QuotaControlBitResult quota = quotaService.detail(subject); ControlSystemSignatureResult signature = controlSignatureService.detail(subject); - requireCompleted("额度控制位", quota.getStatus()); - requireCompleted("控制系统签名", signature.getStatus()); + requireStatus("额度控制位", quota.getStatus(), "RECEIVED"); + requireStatus("控制系统签名", signature.getStatus(), "SENT"); if (amount == null || quota.getAmount() == null || signature.getAmount() == null || quota.getAmount().compareTo(amount) != 0 || signature.getAmount().compareTo(amount) != 0) { throw validation("生成模块的控制数据金额与发行批次金额不一致"); @@ -383,9 +428,9 @@ public class DigitalCurrencyGenerationModuleGateway { required(signature.getControlSignature(), "控制系统签名")); } - private void requireCompleted(String name, String status) { - if (!"RECEIVED".equals(status)) { - throw validation(name + "尚未完成接收,当前状态:" + (status == null ? "缺失" : status)); + private void requireStatus(String name, String status, String expectedStatus) { + if (!expectedStatus.equals(status)) { + throw validation(name + "尚未完成,当前状态:" + (status == null ? "缺失" : status)); } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java index d8adca1..4288d64 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandler.java @@ -44,7 +44,9 @@ public class IssuanceAttemptCancellationHandler implements AttemptCancellationHa Optional notification = notificationService.find(requestId); if (notification.isPresent() && executionService.find(notification.get().getTransactionId()).isPresent()) { - throw new BusinessException(ErrorCode.VALIDATION_ERROR, "准备金已扣减,不能取消发行实验"); + executionService.reverse(notification.get().getTransactionId(), + new IssuanceAuditActor(subject.getUserId(), subject.getUserId())); + return; } generationGateway.releaseGeneratedCurrencies(requestId, new IssuanceAuditActor(subject.getUserId(), subject.getUserId())); diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java index 4cd6932..a4842ba 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/IssuanceTrainingActionService.java @@ -148,7 +148,7 @@ public class IssuanceTrainingActionService { case "03:query-application-message": return completed(centralBankService.getCentralBankView(requestId)); case "04:auto-confirm-plan": - return completed(reviewService.review(requestId, actor)); + return completed(reviewService.review(requestId, actor, keySubject)); case "04:generate-reserve-deduction-request": return completed(notificationService.send(requestId, actor)); case "05:confirm-receipt": @@ -167,7 +167,7 @@ public class IssuanceTrainingActionService { case "06:confirm-production-receipt": return completed(productionService.produce(requestId, actor, keySubject)); case "06:generate-production-digest": - return completed(productionService.find(requestId) + return completed(productionService.digests(requestId) .orElseThrow(() -> validation("请先生成数字货币生产批次"))); case "07:central-confirm-ownership": return completed(ownershipService.confirm(requestId, actor, keySubject)); diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionService.java index 9f80e64..3712df2 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionService.java @@ -6,6 +6,7 @@ import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; import com.yau.digitalrmb.issuance.domain.model.ReserveDeductionExecution; import com.yau.digitalrmb.issuance.domain.model.ReserveDeductionNotification; import com.yau.digitalrmb.issuance.domain.repository.CentralBankInstitutionAccountRepository; +import com.yau.digitalrmb.issuance.domain.repository.DigitalCurrencyProductionRepository; import com.yau.digitalrmb.issuance.domain.repository.ReserveDeductionExecutionRepository; import com.yau.digitalrmb.issuance.domain.repository.ReserveDeductionNotificationRepository; import com.yau.digitalrmb.shared.api.ErrorCode; @@ -22,13 +23,16 @@ public class ReserveDeductionExecutionService { private final ReserveDeductionNotificationRepository notificationRepository; private final ReserveDeductionExecutionRepository executionRepository; private final CentralBankInstitutionAccountRepository accountRepository; + private final DigitalCurrencyProductionRepository productionRepository; public ReserveDeductionExecutionService(ReserveDeductionNotificationRepository notificationRepository, ReserveDeductionExecutionRepository executionRepository, - CentralBankInstitutionAccountRepository accountRepository) { + CentralBankInstitutionAccountRepository accountRepository, + DigitalCurrencyProductionRepository productionRepository) { this.notificationRepository = notificationRepository; this.executionRepository = executionRepository; this.accountRepository = accountRepository; + this.productionRepository = productionRepository; } @Transactional @@ -77,6 +81,43 @@ public class ReserveDeductionExecutionService { return executionRepository.findByTransactionId(transactionId).map(ReserveDeductionExecutionView::from); } + @Transactional + public ReserveDeductionExecutionView reverse(String transactionId, IssuanceAuditActor actor) { + requireActor(actor); + String requiredTransactionId = required(transactionId, "transaction id"); + ReserveDeductionExecution execution = executionRepository.findByTransactionIdForUpdate(requiredTransactionId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "Reserve deduction execution does not exist")); + if ("REVERSED".equals(execution.getStatus())) { + return ReserveDeductionExecutionView.from(execution); + } + if (!"DEDUCTED".equals(execution.getStatus())) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "Only a deducted reserve execution can be reversed"); + } + if (productionRepository.findByRequestId(execution.getRequestId()).isPresent()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "Digital currency has already been produced; reserve deduction cannot be reversed"); + } + ReserveDeductionNotification notification = notificationRepository + .findByTransactionId(requiredTransactionId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "Reserve deduction notification does not exist")); + CentralBankInstitutionAccount account = accountRepository.findByBankCodeForUpdate(notification.getBankCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "Reserve account does not exist")); + if (!notification.getReserveAccountNo().equals(account.getReserveAccountNo()) + || !notification.getReserveAccountName().equals(account.getReserveAccountName())) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "Reserve account does not match the original deduction notification"); + } + BigDecimal reversedBalance = account.getReserveBalance().add(execution.getDeductionAmount()); + ReserveDeductionExecution reversed = execution.reversed(reversedBalance, Instant.now(), actor); + accountRepository.updateReserveBalance(account.getBankCode(), reversedBalance); + executionRepository.save(reversed); + return ReserveDeductionExecutionView.from(reversed); + } + private void requireNotificationMatches(ReserveDeductionNotification notification, String reserveAccountNo, String reserveAccountName, BigDecimal deductionAmount) { if (!"SUBMITTED".equals(notification.getStatus())) { diff --git a/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionNotificationService.java b/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionNotificationService.java index 3415245..c910515 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionNotificationService.java +++ b/src/main/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionNotificationService.java @@ -65,7 +65,7 @@ public class ReserveDeductionNotificationService { BigDecimal deductionAmount = new BigDecimal(requiredText(payload, "totalAmount")); CentralBankInstitutionAccount account = accountRepository.findByBankCode(bankCode) .orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR, "申请机构未开立准备金账户")); - if (!organizationId.equals(account.getOrganizationId()) || !"NORMAL".equals(account.getStatus())) { + if (!"NORMAL".equals(account.getStatus())) { throw new BusinessException(ErrorCode.VALIDATION_ERROR, "申请机构准备金账户状态异常"); } if (blank(account.getReserveAccountName())) { diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/model/ReserveDeductionExecution.java b/src/main/java/com/yau/digitalrmb/issuance/domain/model/ReserveDeductionExecution.java index e811eed..e7590ec 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/domain/model/ReserveDeductionExecution.java +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/model/ReserveDeductionExecution.java @@ -15,10 +15,22 @@ public final class ReserveDeductionExecution { private final Instant executedAt; private final String executedByUserId; private final String executedBy; + private final BigDecimal reversedBalance; + private final Instant reversedAt; + private final String reversedByUserId; + private final String reversedBy; public ReserveDeductionExecution(UUID requestId, String transactionId, BigDecimal beforeBalance, BigDecimal deductionAmount, BigDecimal afterBalance, String status, Instant executedAt, String executedByUserId, String executedBy) { + this(requestId, transactionId, beforeBalance, deductionAmount, afterBalance, status, executedAt, + executedByUserId, executedBy, null, null, null, null); + } + + public ReserveDeductionExecution(UUID requestId, String transactionId, BigDecimal beforeBalance, BigDecimal deductionAmount, + BigDecimal afterBalance, String status, Instant executedAt, + String executedByUserId, String executedBy, BigDecimal reversedBalance, + Instant reversedAt, String reversedByUserId, String reversedBy) { this.requestId = Objects.requireNonNull(requestId, "发行请求标识不能为空"); this.transactionId = Objects.requireNonNull(transactionId, "交易信息标识不能为空"); this.beforeBalance = Objects.requireNonNull(beforeBalance, "扣款前余额不能为空"); @@ -28,7 +40,23 @@ public final class ReserveDeductionExecution { this.executedAt = Objects.requireNonNull(executedAt, "扣款时间不能为空"); this.executedByUserId = Objects.requireNonNull(executedByUserId, "扣款操作人ID不能为空"); this.executedBy = Objects.requireNonNull(executedBy, "扣款操作人名称不能为空"); + this.reversedBalance = reversedBalance; + this.reversedAt = reversedAt; + this.reversedByUserId = reversedByUserId; + this.reversedBy = reversedBy; } + + public ReserveDeductionExecution reversed(BigDecimal balance, Instant time, IssuanceAuditActor actor) { + if (!"DEDUCTED".equals(status)) { + throw new IllegalStateException("only a deducted execution can be reversed"); + } + return new ReserveDeductionExecution(requestId, transactionId, beforeBalance, deductionAmount, + afterBalance, "REVERSED", executedAt, executedByUserId, executedBy, + Objects.requireNonNull(balance, "reversed balance must not be null"), + Objects.requireNonNull(time, "reversed time must not be null"), + Objects.requireNonNull(actor, "reversal actor must not be null").getUserId(), actor.getUsername()); + } + public UUID getRequestId() { return requestId; } public String getTransactionId() { return transactionId; } public BigDecimal getBeforeBalance() { return beforeBalance; } @@ -38,4 +66,8 @@ public final class ReserveDeductionExecution { public Instant getExecutedAt() { return executedAt; } public String getExecutedByUserId() { return executedByUserId; } public String getExecutedBy() { return executedBy; } + public BigDecimal getReversedBalance() { return reversedBalance; } + public Instant getReversedAt() { return reversedAt; } + public String getReversedByUserId() { return reversedByUserId; } + public String getReversedBy() { return reversedBy; } } diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/repository/ReserveDeductionExecutionRepository.java b/src/main/java/com/yau/digitalrmb/issuance/domain/repository/ReserveDeductionExecutionRepository.java index 62b781c..278f581 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/domain/repository/ReserveDeductionExecutionRepository.java +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/repository/ReserveDeductionExecutionRepository.java @@ -7,4 +7,5 @@ import java.util.Optional; public interface ReserveDeductionExecutionRepository { void save(ReserveDeductionExecution execution); Optional findByTransactionId(String transactionId); + Optional findByTransactionIdForUpdate(String transactionId); } diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/ReserveDeductionExecutionEntity.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/ReserveDeductionExecutionEntity.java index 0021365..f6c0a4f 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/ReserveDeductionExecutionEntity.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/entity/ReserveDeductionExecutionEntity.java @@ -22,4 +22,8 @@ public class ReserveDeductionExecutionEntity { private LocalDateTime executedAt; private String executedByUserId; private String executedBy; + private BigDecimal reversedBalance; + private LocalDateTime reversedAt; + private String reversedByUserId; + private String reversedBy; } diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/IssuanceSourceCurrencyUsageMapper.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/IssuanceSourceCurrencyUsageMapper.java index 1e4370d..52856ea 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/IssuanceSourceCurrencyUsageMapper.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/IssuanceSourceCurrencyUsageMapper.java @@ -26,6 +26,13 @@ public interface IssuanceSourceCurrencyUsageMapper extends BaseMapper selectAvailableSourcesForUpdate(@Param("batchId") Long batchId); + @Select("SELECT sc.* FROM standard_currency sc WHERE sc.batch_id = #{batchId} " + + "AND sc.status = '待生效' AND sc.deleted = FALSE " + + "AND NOT EXISTS (SELECT 1 FROM issuance_source_currency_usage u " + + "WHERE u.source_currency_id = sc.id AND u.status IN ('RESERVED','ISSUED')) " + + "ORDER BY sc.sequence_number") + List selectAvailableSources(@Param("batchId") Long batchId); + @Select("SELECT * FROM issuance_source_currency_usage WHERE source_currency_id = #{sourceCurrencyId} FOR UPDATE") IssuanceSourceCurrencyUsageEntity selectBySourceCurrencyIdForUpdate( @Param("sourceCurrencyId") Long sourceCurrencyId); diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/ReserveDeductionExecutionMapper.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/ReserveDeductionExecutionMapper.java index 7076046..fc34475 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/ReserveDeductionExecutionMapper.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/mapper/ReserveDeductionExecutionMapper.java @@ -3,7 +3,11 @@ package com.yau.digitalrmb.issuance.infrastructure.persistence.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.yau.digitalrmb.issuance.infrastructure.persistence.entity.ReserveDeductionExecutionEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper public interface ReserveDeductionExecutionMapper extends BaseMapper { + @Select("SELECT * FROM reserve_deduction_execution WHERE transaction_id = #{transactionId} FOR UPDATE") + ReserveDeductionExecutionEntity selectByTransactionIdForUpdate(@Param("transactionId") String transactionId); } diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisReserveDeductionExecutionRepository.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisReserveDeductionExecutionRepository.java index c26da20..252fe4f 100644 --- a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisReserveDeductionExecutionRepository.java +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence/repository/MybatisReserveDeductionExecutionRepository.java @@ -21,11 +21,19 @@ public class MybatisReserveDeductionExecutionRepository implements ReserveDeduct } @Override public Optional findByTransactionId(String transactionId) { - ReserveDeductionExecutionEntity entity = mapper.selectById(transactionId); + return toDomain(mapper.selectById(transactionId)); + } + @Override + public Optional findByTransactionIdForUpdate(String transactionId) { + return toDomain(mapper.selectByTransactionIdForUpdate(transactionId)); + } + private Optional toDomain(ReserveDeductionExecutionEntity entity) { return entity == null ? Optional.empty() : Optional.of(new ReserveDeductionExecution( UUID.fromString(entity.getRequestId()), entity.getTransactionId(), entity.getBeforeBalance(), entity.getDeductionAmount(), entity.getAfterBalance(), entity.getStatus(), entity.getExecutedAt().toInstant(ZoneOffset.UTC), - entity.getExecutedByUserId(), entity.getExecutedBy())); + entity.getExecutedByUserId(), entity.getExecutedBy(), entity.getReversedBalance(), + entity.getReversedAt() == null ? null : entity.getReversedAt().toInstant(ZoneOffset.UTC), + entity.getReversedByUserId(), entity.getReversedBy())); } private ReserveDeductionExecutionEntity toEntity(ReserveDeductionExecution execution) { ReserveDeductionExecutionEntity entity = new ReserveDeductionExecutionEntity(); @@ -34,6 +42,10 @@ public class MybatisReserveDeductionExecutionRepository implements ReserveDeduct entity.setAfterBalance(execution.getAfterBalance()); entity.setStatus(execution.getStatus()); entity.setExecutedAt(execution.getExecutedAt().atOffset(ZoneOffset.UTC).toLocalDateTime()); entity.setExecutedByUserId(execution.getExecutedByUserId()); entity.setExecutedBy(execution.getExecutedBy()); + entity.setReversedBalance(execution.getReversedBalance()); + entity.setReversedAt(execution.getReversedAt() == null ? null + : execution.getReversedAt().atOffset(ZoneOffset.UTC).toLocalDateTime()); + entity.setReversedByUserId(execution.getReversedByUserId()); entity.setReversedBy(execution.getReversedBy()); return entity; } } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 5efd4fc..66a81b4 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -270,6 +270,10 @@ CREATE TABLE IF NOT EXISTS reserve_deduction_execution ( executed_at TIMESTAMP NOT NULL, executed_by_user_id VARCHAR(36) NOT NULL, executed_by VARCHAR(64) NOT NULL, + reversed_balance DECIMAL(20, 2) NULL, + reversed_at TIMESTAMP NULL, + reversed_by_user_id VARCHAR(36) NULL, + reversed_by VARCHAR(64) NULL, CONSTRAINT fk_reserve_deduction_execution_notification FOREIGN KEY (request_id) REFERENCES reserve_deduction_notification(request_id) ); diff --git a/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java b/src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java index 9f16107..d5f67f6 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 @@ -134,6 +134,9 @@ class ExchangeControllerTest { 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.userName").value("测试用户")) + .andExpect(jsonPath("$.data.idNumber").value("610100200001010000")) + .andExpect(jsonPath("$.data.phone").value("13800000000")) .andExpect(jsonPath("$.data.bankCardLast4").value("4567")) .andExpect(jsonPath("$.data.bankCardNumber").value("6216610100001234567")) .andExpect(jsonPath("$.data.contractId").value("CONTRACT_990100")) diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGatewayTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGatewayTest.java index acd757d..4c40f59 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGatewayTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyGenerationModuleGatewayTest.java @@ -60,7 +60,7 @@ class DigitalCurrencyGenerationModuleGatewayTest { when(control.getInstitutionIdentifier()).thenReturn("ORG_001"); when(control.getTransactionIdentifier()).thenReturn("TXN_001"); when(control.getControlSignature()).thenReturn("CONTROL_SIGNATURE"); - when(control.getStatus()).thenReturn("RECEIVED"); + when(control.getStatus()).thenReturn("SENT"); IssuanceControlMaterial material = gateway.requireControlMaterial( subject, new BigDecimal("50000.00"), "ORG_001"); @@ -78,7 +78,7 @@ class DigitalCurrencyGenerationModuleGatewayTest { when(quotaService.detail(subject)).thenReturn(quota); when(controlSignatureService.detail(subject)).thenReturn(control); when(quota.getStatus()).thenReturn("RECEIVED"); - when(control.getStatus()).thenReturn("RECEIVED"); + when(control.getStatus()).thenReturn("SENT"); when(quota.getAmount()).thenReturn(new BigDecimal("50000.00")); when(control.getAmount()).thenReturn(new BigDecimal("50000.00")); when(quota.getInstitutionIdentifier()).thenReturn("ORG_OTHER"); diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java index ffe627c..46fd30f 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/DigitalCurrencyProductionAndOwnershipServiceTest.java @@ -47,7 +47,7 @@ class DigitalCurrencyProductionAndOwnershipServiceTest { DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class); when(productionRepository.findByRequestId(requestId)).thenReturn(Optional.empty()); when(notificationRepository.findByRequestId(requestId)).thenReturn(Optional.of(notification(requestId))); - when(executionRepository.findByTransactionId("RESERVE_001")).thenReturn(Optional.of(execution(requestId))); + when(executionRepository.findByTransactionIdForUpdate("RESERVE_001")).thenReturn(Optional.of(execution(requestId))); when(receiptRepository.findByRequestId(requestId)).thenReturn(Optional.of(receipt(requestId))); when(gateway.requireControlMaterial(subject, new BigDecimal("100.00"), "ORG_001")) .thenReturn(new IssuanceControlMaterial(81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE")); diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java index 939ba02..691d8d3 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/IssuanceAttemptCancellationHandlerTest.java @@ -2,7 +2,6 @@ package com.yau.digitalrmb.issuance.application.service; import com.yau.digitalrmb.issuance.application.query.ReserveDeductionExecutionView; import com.yau.digitalrmb.issuance.application.query.ReserveDeductionNotificationView; -import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt; import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; @@ -12,7 +11,6 @@ import java.time.Instant; import java.util.Optional; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -39,7 +37,7 @@ class IssuanceAttemptCancellationHandlerTest { } @Test - void rejectsCancellationAfterReserveDeduction() { + void reversesReserveDeductionWithoutTouchingProductionReservations() { UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000402"); ExperimentAttempt attempt = boundAttempt(requestId); ReserveDeductionNotificationView notification = mock(ReserveDeductionNotificationView.class); @@ -47,7 +45,9 @@ class IssuanceAttemptCancellationHandlerTest { when(notifications.find(requestId)).thenReturn(Optional.of(notification)); when(executions.find("RESERVE-402")).thenReturn(Optional.of(mock(ReserveDeductionExecutionView.class))); - assertThatThrownBy(() -> handler.cancel(attempt, subject)).isInstanceOf(BusinessException.class); + handler.cancel(attempt, subject); + + verify(executions).reverse(org.mockito.ArgumentMatchers.eq("RESERVE-402"), any()); verify(gateway, never()).releaseGeneratedCurrencies(any(UUID.class), any()); } diff --git a/src/test/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionServiceTest.java new file mode 100644 index 0000000..0b28295 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/application/service/ReserveDeductionExecutionServiceTest.java @@ -0,0 +1,96 @@ +package com.yau.digitalrmb.issuance.application.service; + +import com.yau.digitalrmb.issuance.application.query.ReserveDeductionExecutionView; +import com.yau.digitalrmb.issuance.domain.model.CentralBankInstitutionAccount; +import com.yau.digitalrmb.issuance.domain.model.DigitalCurrencyProductionBatch; +import com.yau.digitalrmb.issuance.domain.model.IssuanceAuditActor; +import com.yau.digitalrmb.issuance.domain.model.ReserveDeductionExecution; +import com.yau.digitalrmb.issuance.domain.model.ReserveDeductionNotification; +import com.yau.digitalrmb.issuance.domain.repository.CentralBankInstitutionAccountRepository; +import com.yau.digitalrmb.issuance.domain.repository.DigitalCurrencyProductionRepository; +import com.yau.digitalrmb.issuance.domain.repository.ReserveDeductionExecutionRepository; +import com.yau.digitalrmb.issuance.domain.repository.ReserveDeductionNotificationRepository; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReserveDeductionExecutionServiceTest { + private final ReserveDeductionNotificationRepository notifications = mock(ReserveDeductionNotificationRepository.class); + private final ReserveDeductionExecutionRepository executions = mock(ReserveDeductionExecutionRepository.class); + private final CentralBankInstitutionAccountRepository accounts = mock(CentralBankInstitutionAccountRepository.class); + private final DigitalCurrencyProductionRepository productions = mock(DigitalCurrencyProductionRepository.class); + private final ReserveDeductionExecutionService service = + new ReserveDeductionExecutionService(notifications, executions, accounts, productions); + + @Test + void restoresCurrentBalanceAndPreservesOriginalDeductionAudit() { + UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000501"); + ReserveDeductionExecution original = execution(requestId); + ReserveDeductionNotification notification = notification(requestId); + when(executions.findByTransactionIdForUpdate("RESERVE-501")).thenReturn(Optional.of(original)); + when(productions.findByRequestId(requestId)).thenReturn(Optional.empty()); + when(notifications.findByTransactionId("RESERVE-501")).thenReturn(Optional.of(notification)); + when(accounts.findByBankCodeForUpdate("BANK-501")).thenReturn(Optional.of(account("125000.00"))); + + ReserveDeductionExecutionView result = service.reverse("RESERVE-501", new IssuanceAuditActor("user-2", "reviewer")); + + assertThat(result.getStatus()).isEqualTo("REVERSED"); + assertThat(result.getAfterBalance()).isEqualByComparingTo("150000.00"); + assertThat(result.getReversedBalance()).isEqualByComparingTo("175000.00"); + assertThat(result.getExecutedBy()).isEqualTo("operator"); + assertThat(result.getReversedBy()).isEqualTo("reviewer"); + verify(accounts).updateReserveBalance("BANK-501", new BigDecimal("175000.00")); + ArgumentCaptor saved = ArgumentCaptor.forClass(ReserveDeductionExecution.class); + verify(executions).save(saved.capture()); + assertThat(saved.getValue().getBeforeBalance()).isEqualByComparingTo("200000.00"); + assertThat(saved.getValue().getAfterBalance()).isEqualByComparingTo("150000.00"); + } + + @Test + void rejectsReversalWhenProductionBatchExists() { + UUID requestId = UUID.fromString("00000000-0000-0000-0000-000000000502"); + when(executions.findByTransactionIdForUpdate("RESERVE-501")).thenReturn(Optional.of(execution(requestId))); + DigitalCurrencyProductionBatch batch = new DigitalCurrencyProductionBatch("MINT-502", requestId, + "RESERVE-501", "ORG-501", "CNY", new BigDecimal("50000.00"), "WAITING_OWNERSHIP", + Instant.parse("2026-08-20T01:30:00Z"), "user-1", "operator", Collections.emptyList()); + when(productions.findByRequestId(requestId)).thenReturn(Optional.of(batch)); + + assertThatThrownBy(() -> service.reverse("RESERVE-501", new IssuanceAuditActor("user-2", "reviewer"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("already been produced"); + verify(accounts, never()).updateReserveBalance(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(BigDecimal.class)); + verify(executions, never()).save(org.mockito.ArgumentMatchers.any(ReserveDeductionExecution.class)); + } + + private ReserveDeductionExecution execution(UUID requestId) { + return new ReserveDeductionExecution(requestId, "RESERVE-501", new BigDecimal("200000.00"), + new BigDecimal("50000.00"), new BigDecimal("150000.00"), "DEDUCTED", + Instant.parse("2026-08-20T01:00:00Z"), "user-1", "operator"); + } + + private ReserveDeductionNotification notification(UUID requestId) { + return new ReserveDeductionNotification(requestId, "RESERVE-501", "BANK-501", "ORG-501", + "ACCOUNT-501", "Bank 501 reserve", "CNY", new BigDecimal("50000.00"), "SUBMITTED", + Instant.parse("2026-08-20T00:30:00Z"), "user-1", "operator"); + } + + private CentralBankInstitutionAccount account(String balance) { + return new CentralBankInstitutionAccount("BANK-501", "ORG-501", "ACCOUNT-501", "Bank 501 reserve", + "NORMAL", LocalDate.parse("2020-01-01"), new BigDecimal(balance)); + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java index 98b4840..72c0746 100644 --- a/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceControllerTest.java @@ -29,7 +29,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @SpringBootTest @@ -49,6 +51,7 @@ class IssuanceControllerTest { when(generationGateway.centralBankSigningKeyId()).thenReturn(InstitutionKeyService.CENTRAL_FIRST_KEY); when(generationGateway.signIssuanceDigest(any(), anyString())).thenReturn("BANK_SIGNATURE"); when(generationGateway.verifyIssuanceDigest(any(), anyString(), anyString())).thenReturn(true); + when(generationGateway.isCurrentConfirmedInstitutionIdentifier(any(), anyString())).thenReturn(true); when(generationGateway.requireControlMaterial(any(), any(BigDecimal.class), anyString())) .thenReturn(new IssuanceControlMaterial(81L, 71L, "CONTROL_BIT", "CONTROL_SIGNATURE")); when(generationGateway.signOwnership(any(), anyString())).thenReturn("OWNERSHIP_SIGNATURE"); @@ -183,6 +186,8 @@ class IssuanceControllerTest { .andExpect(jsonPath("$.data.verifiedAmount").value(50000.00)) .andExpect(jsonPath("$.data.reviewedByUserId").value("00000000-0000-0000-0000-000000000487")) .andExpect(jsonPath("$.data.reviewedBy").value("tzs001")); + verify(generationGateway).validateIssuancePrerequisites(any(), any(BigDecimal.class), + anyString(), anyList()); mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId) .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487")))) @@ -256,6 +261,22 @@ class IssuanceControllerTest { .andExpect(jsonPath("$.data.status").value("WAITING_OWNERSHIP")) .andExpect(jsonPath("$.data.draftCoinCount").value(930)); + String attemptResponse = mockMvc.perform(get("/api/v1/issuance/attempts/current") + .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487") + .claim("userId", "00000000-0000-0000-0000-000000000487")))) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + String attemptId = JsonPath.read(attemptResponse, "$.data.attemptId"); + mockMvc.perform(post("/api/v1/issuance/attempts/{attemptId}/steps/06/actions/generate-production-digest", + attemptId) + .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487") + .claim("userId", "00000000-0000-0000-0000-000000000487"))) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.output.batch.batchId").value(org.hamcrest.Matchers.startsWith("MINT_"))) + .andExpect(jsonPath("$.data.output.digests.length()").value(930)) + .andExpect(jsonPath("$.data.output.digests[0].digest") + .value("0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")); + mockMvc.perform(post("/api/v1/central-banks/issuance/requests/{id}/digital-currency-ownership", requestId) .with(jwt().jwt(jwt -> jwt.subject("00000000-0000-0000-0000-000000000487") .claim("userId", "00000000-0000-0000-0000-000000000487") diff --git a/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java b/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java index f4dacbd..72b70c4 100644 --- a/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java +++ b/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java @@ -12,10 +12,11 @@ public final class WalletOpeningTestData { long schoolId, long classId, String walletId, String privateKey, String publicKey, String bankName, String cardNumber, BigDecimal bankBalance) { - jdbc.update("INSERT INTO wallet_application (id,user_id,school_id,class_id,account_bank,bank_card_number," + + jdbc.update("INSERT INTO wallet_application (id,user_id,school_id,class_id,user_name,id_number,phone,account_bank,bank_card_number," + "account_balance,selected_bank,wallet_type,application_id,status,created_at,updated_at,created_by,updated_by,deleted) " + - "VALUES (?,?,?,?,?,?,?,?,?,?,'SUBMITTED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", - baseId, userId, schoolId, classId, bankName, cardNumber, bankBalance, bankName, + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'SUBMITTED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", + baseId, userId, schoolId, classId, "测试用户", "610100200001010000", "13800000000", + bankName, cardNumber, bankBalance, bankName, "TYPE_II", "APP_" + baseId); jdbc.update("INSERT INTO wallet_identifier_generation (id,user_id,school_id,class_id,wallet_identifier," + "cert_private_key,cert_public_key,cert_serial_number,cert_issued_time,status,created_at,updated_at,created_by,updated_by,deleted) " + diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 68817eb..7b32a55 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -385,6 +385,8 @@ CREATE TABLE IF NOT EXISTS reserve_deduction_execution ( before_balance DECIMAL(20, 2) NOT NULL, deduction_amount DECIMAL(20, 2) NOT NULL, after_balance DECIMAL(20, 2) NOT NULL, status VARCHAR(32) NOT NULL, executed_at TIMESTAMP NOT NULL, executed_by_user_id VARCHAR(36) NOT NULL, executed_by VARCHAR(64) NOT NULL, + reversed_balance DECIMAL(20, 2) NULL, reversed_at TIMESTAMP NULL, + reversed_by_user_id VARCHAR(36) NULL, reversed_by VARCHAR(64) NULL, CONSTRAINT fk_reserve_deduction_execution_notification FOREIGN KEY (request_id) REFERENCES reserve_deduction_notification(request_id) ); CREATE TABLE IF NOT EXISTS digital_currency_production_batch ( @@ -529,6 +531,11 @@ CREATE TABLE IF NOT EXISTS wallet_application ( user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL, + user_name VARCHAR(100), + id_number VARCHAR(20), + phone VARCHAR(20), + input_phone VARCHAR(20), + input_id_number VARCHAR(20), account_bank VARCHAR(100), bank_card_number VARCHAR(30), account_balance DECIMAL(20,2),