feat(training): integrate issuance and exchange workflows

master
chenyuan 1 week ago
parent c92d26bdce
commit 3e76132ad9

@ -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; }

@ -48,6 +48,7 @@ public class JdbcExchangeResourceRepository implements ExchangeResourceRepositor
@Override
public ExchangeContext loadContext(ExchangeActor actor) {
Institution institution = loadIssuedInstitution(actor);
UserProfile profile = loadUserProfile(actor);
List<ExchangeContext> 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<UserProfile> 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;

@ -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<CoinDigestView> digests;
@Getter
@AllArgsConstructor
public static class CoinDigestView {
private final String coinId;
private final BigDecimal denomination;
private final String digest;
}
}

@ -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());
}
}

@ -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<com.yau.digitalrmb.issuance.domain.model.DenominationItem> denominations =
denominations(denominationSummary);
BigDecimal verifiedAmount = denominationTotal(denominations);
generationModuleGateway.validateIssuancePrerequisites(
keySubject, requestedAmount, organizationId, denominations);
Optional<CentralBankInstitutionAccount> 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<CentralBankCurrencyVault> 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<com.yau.digitalrmb.issuance.domain.model.DenominationItem> denominations(String value) {
List<com.yau.digitalrmb.issuance.domain.model.DenominationItem> result =
new ArrayList<com.yau.digitalrmb.issuance.domain.model.DenominationItem>();
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<com.yau.digitalrmb.issuance.domain.model.DenominationItem> 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);
}

@ -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<DigitalCurrencyProductionDigestView> digests(UUID requestId) {
return productionRepository.findByRequestId(requestId).map(batch -> {
List<DigitalCurrencyProductionDigestView.CoinDigestView> values =
new ArrayList<DigitalCurrencyProductionDigestView.CoinDigestView>();
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<DraftDigitalCurrency> buildCoins(String batchId, String organizationId, String currency,
List<GeneratedCurrencyStockItem> reservedCurrencies,
Instant createdAt, IssuanceAuditActor actor,

@ -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<DenominationItem> denominations) {
IssuanceControlMaterial material = requireControlMaterial(subject, amount, institutionIdentifier);
if (standardCurrencyBatchMapper == null || sourceUsageMapper == null) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR,
"生成模块币串库存服务尚未配置");
}
StandardCurrencyBatchEntity batch = standardCurrencyBatchMapper.selectOne(
new LambdaQueryWrapper<StandardCurrencyBatchEntity>()
.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<GeneratedCurrencyStockItem> 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));
}
}

@ -44,7 +44,9 @@ public class IssuanceAttemptCancellationHandler implements AttemptCancellationHa
Optional<ReserveDeductionNotificationView> 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()));

@ -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));

@ -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())) {

@ -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())) {

@ -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; }
}

@ -7,4 +7,5 @@ import java.util.Optional;
public interface ReserveDeductionExecutionRepository {
void save(ReserveDeductionExecution execution);
Optional<ReserveDeductionExecution> findByTransactionId(String transactionId);
Optional<ReserveDeductionExecution> findByTransactionIdForUpdate(String transactionId);
}

@ -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;
}

@ -26,6 +26,13 @@ public interface IssuanceSourceCurrencyUsageMapper extends BaseMapper<IssuanceSo
+ "ORDER BY sc.sequence_number FOR UPDATE")
List<StandardCurrencyEntity> 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<StandardCurrencyEntity> 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);

@ -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<ReserveDeductionExecutionEntity> {
@Select("SELECT * FROM reserve_deduction_execution WHERE transaction_id = #{transactionId} FOR UPDATE")
ReserveDeductionExecutionEntity selectByTransactionIdForUpdate(@Param("transactionId") String transactionId);
}

@ -21,11 +21,19 @@ public class MybatisReserveDeductionExecutionRepository implements ReserveDeduct
}
@Override
public Optional<ReserveDeductionExecution> findByTransactionId(String transactionId) {
ReserveDeductionExecutionEntity entity = mapper.selectById(transactionId);
return toDomain(mapper.selectById(transactionId));
}
@Override
public Optional<ReserveDeductionExecution> findByTransactionIdForUpdate(String transactionId) {
return toDomain(mapper.selectByTransactionIdForUpdate(transactionId));
}
private Optional<ReserveDeductionExecution> toDomain(ReserveDeductionExecutionEntity entity) {
return entity == null ? Optional.<ReserveDeductionExecution>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;
}
}

@ -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)
);

@ -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"))

@ -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");

@ -47,7 +47,7 @@ class DigitalCurrencyProductionAndOwnershipServiceTest {
DigitalCurrencyGenerationModuleGateway gateway = mock(DigitalCurrencyGenerationModuleGateway.class);
when(productionRepository.findByRequestId(requestId)).thenReturn(Optional.<DigitalCurrencyProductionBatch>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"));

@ -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());
}

@ -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<ReserveDeductionExecution> 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));
}
}

@ -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")

@ -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) " +

@ -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),

Loading…
Cancel
Save