diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java index f911826..b7d321f 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/CurrencyGenerationVerificationService.java @@ -108,17 +108,13 @@ public class CurrencyGenerationVerificationService { return new CurrencyQuotaVerificationResult(value); } - // 执行额度验证 - // 本次申请金额从步骤二请求报文中获取(value.getAmount()) - // 已用额度暂时设为0,剩余额度 = 总额度 - 本次申请 - BigDecimal currentAmount = value.getAmount() != null ? value.getAmount() : BigDecimal.ZERO; - BigDecimal usedQuota = BigDecimal.ZERO; // 暂时不计算系统已用额度 - BigDecimal remainingQuota = TOTAL_QUOTA.subtract(currentAmount).max(BigDecimal.ZERO); + BigDecimal usedQuota = requestMapper.sumActiveAmount(); + BigDecimal remainingQuota = TOTAL_QUOTA.subtract(usedQuota).max(BigDecimal.ZERO); CurrencyGenerationVerification.Status expected = value.getStatus(); invoke(() -> value.verifyQuota(TOTAL_QUOTA, usedQuota, remainingQuota)); repository.update(value, expected, "VERIFY_QUOTA", - "验证全系统50000元实训额度,本次申请" + currentAmount.toPlainString() + "元", operator); + "验证全系统50000元实训额度,累计已用额度包含本次申请金额", operator); return new CurrencyQuotaVerificationResult(value); } diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/StandardCurrencyService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/StandardCurrencyService.java index e680c97..f877665 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/StandardCurrencyService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/StandardCurrencyService.java @@ -117,6 +117,40 @@ public class StandardCurrencyService { latest.getBatchNumber(), latest.getTotalCount(), latest.getStatus()); } + /** + * Keeps the original one-step rule-confirmation API available for existing clients. + * The newer UI may use {@link #confirmIdRule(InstitutionKeySubject, String)} instead. + */ + @Transactional + public StandardCurrencyBatchResult confirmRule(String prefix, String idRule, String currencyStatus, + InstitutionKeySubject subject, String operator) { + StandardCurrencyBatchEntity batch = requireLatest(subject); + if (batch.getDenominationSummary() == null || batch.getTotalAmount() == null) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "请先执行面额拆解"); + } + if (!"DC".equals(prefix == null ? null : prefix.trim().toUpperCase())) { + throw inputError(subject, "币串前缀必须为DC"); + } + if (!ID_RULE.equals(idRule == null ? null : idRule.trim())) { + throw inputError(subject, "币串ID规则必须为“批次号+枚序号+随机校验码”"); + } + if (!"待生效".equals(currencyStatus == null ? null : currencyStatus.trim())) { + throw inputError(subject, "币串初始状态必须为“待生效”"); + } + int updated = batchMapper.update(null, new LambdaUpdateWrapper() + .eq(StandardCurrencyBatchEntity::getId, batch.getId()) + .eq(StandardCurrencyBatchEntity::getStatus, batch.getStatus()) + .eq(StandardCurrencyBatchEntity::getDeleted, false) + .set(StandardCurrencyBatchEntity::getPrefix, "DC") + .set(StandardCurrencyBatchEntity::getIdRule, ID_RULE) + .set(StandardCurrencyBatchEntity::getCurrencyStatus, "待生效") + .set(StandardCurrencyBatchEntity::getStatus, "RULE_CONFIRMED") + .set(StandardCurrencyBatchEntity::getUpdatedAt, LocalDateTime.now()) + .set(StandardCurrencyBatchEntity::getUpdatedBy, operator)); + if (updated != 1) throw stateChanged(); + return result(requireLatest(subject)); + } + @Transactional public StandardCurrencyRuleResult confirmCurrencyRule(InstitutionKeySubject subject, String operator) { StandardCurrencyBatchEntity batch = requireLatest(subject); diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/application/TransactionInformationIdentifierService.java b/src/main/java/com/yau/digitalrmb/institutionidentity/application/TransactionInformationIdentifierService.java index 97e5e7e..dd2ce05 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/application/TransactionInformationIdentifierService.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/application/TransactionInformationIdentifierService.java @@ -177,30 +177,20 @@ public class TransactionInformationIdentifierService { // // 3. 条件满足,正常返回 // return new QuotaRequestSourceDataResult(value); //} -@Transactional(readOnly = true) - -public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) { - TransactionInformationIdentifier value = repository.findLatestBySubject( - subject.getUserId(), - subject.getSchoolId(), - subject.getClassId() - ).orElse(null); - - // 无记录 → 返回 null - if (value == null) { - return null; - } - - // 状态或标识不满足条件 → 返回 null - if (value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal() - || value.getTransactionIdentifier() == null - || !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) { - return null; + @Transactional(readOnly = true) + public QuotaRequestSourceDataResult quotaRequestSourceData(InstitutionKeySubject subject) { + TransactionInformationIdentifier value = repository.findLatestBySubject( + subject.getUserId(), subject.getSchoolId(), subject.getClassId()) + .orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR, + "步骤四尚未完成交易信息标识组装,不能查询步骤五前序数据")); + if (value.getStatus().ordinal() < TransactionInformationIdentifier.Status.ASSEMBLED.ordinal() + || value.getTransactionIdentifier() == null + || !value.getTransactionIdentifier().matches("TXN_[0-9A-F]{64}")) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "步骤四尚未完成交易信息标识组装,不能查询步骤五前序数据"); + } + return new QuotaRequestSourceDataResult(value); } - - // 正常返回 - return new QuotaRequestSourceDataResult(value); -} @Transactional public QuotaRequestDigestResult digestQuotaRequest(String originalText, InstitutionKeySubject subject, @@ -440,9 +430,10 @@ public QuotaRequestMessageResult quotaRequestMessageDetail(InstitutionKeySubject // .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, // "当前没有额度请求记录")); // } - private TransactionInformationIdentifier requireLatest(InstitutionKeySubject subject) { + private TransactionInformationIdentifier requireLatest(InstitutionKeySubject subject) { return repository.findLatestBySubject(subject.getUserId(), subject.getSchoolId(), subject.getClassId()) - .orElse(null); + .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, + "当前没有额度请求记录")); } private TransactionInformationIdentifier requireStatus(InstitutionKeySubject subject, diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRuleRequest.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRuleRequest.java new file mode 100644 index 0000000..33c621e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/dto/CurrencyRuleRequest.java @@ -0,0 +1,19 @@ +package com.yau.digitalrmb.institutionidentity.interfaces.dto; + +import lombok.Getter; +import lombok.Setter; + +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +public class CurrencyRuleRequest { + @NotBlank(message = "币串前缀不能为空") + private String prefix; + + @NotBlank(message = "币串ID规则不能为空") + private String idRule; + + @NotBlank(message = "币串初始状态不能为空") + private String currencyStatus; +} diff --git a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/StandardCurrencyController.java b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/StandardCurrencyController.java index c04085f..4a03962 100644 --- a/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/StandardCurrencyController.java +++ b/src/main/java/com/yau/digitalrmb/institutionidentity/interfaces/rest/StandardCurrencyController.java @@ -8,6 +8,7 @@ import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyListIt import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyOperationResult; import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyRuleResult; import com.yau.digitalrmb.institutionidentity.application.StandardCurrencyService; +import com.yau.digitalrmb.institutionidentity.interfaces.dto.CurrencyRuleRequest; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.web.TraceIdFilter; import com.yau.digitalrmb.security.context.AuthContextHolder; @@ -18,10 +19,12 @@ import org.slf4j.MDC; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; +import javax.validation.Valid; import java.util.List; @Validated @@ -42,6 +45,17 @@ public class StandardCurrencyController { result.getTotalCount(), result.getBreakdown()), traceId()); } + @PostMapping("/steps/confirm-rule") + @Operation(summary = "确认币串生成规则(兼容接口)", description = "校验DC前缀、币串ID规则和待生效初始状态。") + public ApiResponse confirmRule( + @Valid @RequestBody CurrencyRuleRequest request) { + JwtUser user = AuthContextHolder.get(); + StandardCurrencyBatchResult result = service.confirmRule(request.getPrefix(), request.getIdRule(), + request.getCurrencyStatus(), InstitutionKeySubject.from(user), user.getUsername()); + return ApiResponse.success(new StandardCurrencyOperationResult( + result.getStatus(), result.getTotalCount()), traceId()); + } + @PostMapping("/steps/confirm-id-rule") @Operation(summary = "确认币串ID生成规则", description = "无需请求体。后端自动设置DC前缀和批次号+枚序号+随机校验码ID规则,返回ID规则明细表(批次号、枚序号、随机校验码、完整币串ID)。") public ApiResponse confirmIdRule() { diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java index 1b7e8e9..c0997c1 100644 --- a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java @@ -37,14 +37,17 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository private final InstitutionKeyService keyService; private final WalletPrerequisiteProjectionService walletProjection; private final CurrencyOwnershipLockRepository ownershipLocks; + private final TeachingPaymentPayeeWhitelist teachingPayeeWhitelist; public JdbcPaymentResourceRepository(JdbcTemplate jdbc, InstitutionIdentityCryptography cryptography, WalletPrivateKeyCipher walletCipher, InstitutionKeyService keyService, WalletPrerequisiteProjectionService walletProjection, - CurrencyOwnershipLockRepository ownershipLocks) { + CurrencyOwnershipLockRepository ownershipLocks, + TeachingPaymentPayeeWhitelist teachingPayeeWhitelist) { this.jdbc = jdbc; this.cryptography = cryptography; this.walletCipher = walletCipher; this.keyService = keyService; this.walletProjection = walletProjection; this.ownershipLocks = ownershipLocks; + this.teachingPayeeWhitelist = teachingPayeeWhitelist; } @Override @@ -62,7 +65,9 @@ public class JdbcPaymentResourceRepository implements PaymentResourceRepository @Override public PaymentContext loadContext(PaymentActor actor, String payeeWalletId) { PaymentParticipant payer = loadPayer(actor); - PaymentParticipant payee = loadPayee(payeeWalletId); + PaymentParticipant payee = payeeWalletId == null || payeeWalletId.trim().isEmpty() + ? loadParticipant(teachingPayeeWhitelist.resolveForDifferentBank(payer.getBankCode())) + : loadPayee(payeeWalletId); if (payer.getWalletId().equals(payee.getWalletId())) throw validation("付款钱包和收款钱包不能相同"); if (payer.getBankCode().equals(payee.getBankCode())) throw validation("本实验仅支持商业银行A向商业银行B的跨行支付"); return new PaymentContext(payer, payee); diff --git a/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java new file mode 100644 index 0000000..5124cc5 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/TeachingPaymentPayeeWhitelist.java @@ -0,0 +1,189 @@ +package com.yau.digitalrmb.payment.infrastructure.persistence; + +import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; +import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.wallet.WalletPrerequisiteReference; +import com.yau.digitalrmb.shared.wallet.WalletPrivateKeyCipher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Provides the real, backend-managed receiving wallets used by the payment + * training when a learner has no second account to act as the payee. + */ +@Component +public class TeachingPaymentPayeeWhitelist { + private static final String BANK_A = "BKCHCNBJ00001"; + private static final String BANK_B = "BKCHCNBJ00002"; + private static final String PAYEE_A_USER = "00000000-0000-0000-0000-000000000951"; + private static final String PAYEE_B_USER = "00000000-0000-0000-0000-000000000952"; + private static final String PAYEE_A_WALLET = "WALLET_TRAINING_PAYEE_A"; + private static final String PAYEE_B_WALLET = "WALLET_TRAINING_PAYEE_B"; + + private final JdbcTemplate jdbc; + private final InstitutionIdentityCryptography cryptography; + private final WalletPrivateKeyCipher walletCipher; + + public TeachingPaymentPayeeWhitelist(JdbcTemplate jdbc, InstitutionIdentityCryptography cryptography, + WalletPrivateKeyCipher walletCipher) { + this.jdbc = jdbc; + this.cryptography = cryptography; + this.walletCipher = walletCipher; + } + + @Transactional + public WalletPrerequisiteReference resolveForDifferentBank(String payerBankCode) { + PayeeDefinition payee = BANK_B.equals(payerBankCode) + ? new PayeeDefinition(PAYEE_A_USER, PAYEE_A_WALLET, BANK_A, "教学商业银行A", "ORG_TRAINING_PAYEE_A") + : new PayeeDefinition(PAYEE_B_USER, PAYEE_B_WALLET, BANK_B, "教学商业银行B", "ORG_TRAINING_PAYEE_B"); + ensureWallet(payee); + ensureCertificate(payee); + ensureBankAccount(payee); + ensureBinding(payee); + return new WalletPrerequisiteReference(payee.userId, 0L, 0L, payee.walletId, + payee.bankCode, payee.organizationId); + } + + private void ensureWallet(PayeeDefinition payee) { + List wallets = jdbc.query("SELECT user_id,wallet_type,status FROM digital_wallet WHERE wallet_id=?", + (rs, row) -> new WalletRow(rs.getString("user_id"), rs.getString("wallet_type"), + rs.getString("status")), payee.walletId); + if (wallets.isEmpty()) { + try { + jdbc.update("INSERT INTO digital_wallet (wallet_id,user_id,wallet_type,status,balance,frozen_amount," + + "central_bank_confirmation_signature,opened_at,created_at,updated_at) " + + "VALUES (?,?, 'TYPE_II','ACTIVE',0,0,'TEACHING_PAYEE_CONFIRMED',CURRENT_TIMESTAMP," + + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", payee.walletId, payee.userId); + } catch (DuplicateKeyException exception) { + ensureWallet(payee); + } + return; + } + WalletRow wallet = wallets.get(0); + if (!payee.userId.equals(wallet.userId) || !"TYPE_II".equals(wallet.walletType) + || !"ACTIVE".equals(wallet.status)) { + throw conflict("教学收款方钱包状态或归属异常"); + } + } + + private void ensureCertificate(PayeeDefinition payee) { + List certificates = jdbc.query("SELECT status,filing_status FROM wallet_certificate WHERE wallet_id=?", + (rs, row) -> new CertificateRow(rs.getString("status"), rs.getString("filing_status")), payee.walletId); + if (certificates.isEmpty()) { + InstitutionSm2KeyPair keyPair = cryptography.generateSm2KeyPair(); + try { + jdbc.update("INSERT INTO wallet_certificate (certificate_serial,wallet_id,public_key,encrypted_private_key," + + "filing_status,central_bank_root_signature,status,issued_at) " + + "VALUES (?,?,?,?, 'REGISTERED','TEACHING_PAYEE_ROOT','VALID',CURRENT_TIMESTAMP)", + "CERT_" + payee.walletId, payee.walletId, keyPair.getPublicKey(), + walletCipher.encrypt(keyPair.getPrivateKey())); + } catch (DuplicateKeyException exception) { + ensureCertificate(payee); + } + return; + } + CertificateRow certificate = certificates.get(0); + if (!"VALID".equals(certificate.status) || !"REGISTERED".equals(certificate.filingStatus)) { + throw conflict("教学收款方证书状态异常"); + } + } + + private void ensureBankAccount(PayeeDefinition payee) { + List accounts = jdbc.query("SELECT bank_code,status FROM simulated_bank_account WHERE account_id=?", + (rs, row) -> new AccountRow(rs.getString("bank_code"), rs.getString("status")), payee.accountId()); + if (accounts.isEmpty()) { + try { + jdbc.update("INSERT INTO simulated_bank_account (account_id,user_id,bank_code,bank_name,card_number," + + "card_last4,balance,frozen_amount,status,created_at,updated_at) " + + "VALUES (?,?,?,?, '6222020000000000','0000',0,0,'ACTIVE',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", + payee.accountId(), payee.userId, payee.bankCode, payee.bankName); + } catch (DuplicateKeyException exception) { + ensureBankAccount(payee); + } + return; + } + AccountRow account = accounts.get(0); + if (!payee.bankCode.equals(account.bankCode) || !"ACTIVE".equals(account.status)) { + throw conflict("教学收款方银行账户状态异常"); + } + } + + private void ensureBinding(PayeeDefinition payee) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding " + + "WHERE wallet_id=? AND bank_account_id=? AND status='BOUND'", Integer.class, + payee.walletId, payee.accountId()); + if (count != null && count > 0) return; + try { + jdbc.update("INSERT INTO wallet_bank_binding (wallet_id,bank_account_id,status,bound_at) " + + "VALUES (?,?,'BOUND',CURRENT_TIMESTAMP)", payee.walletId, payee.accountId()); + } catch (DuplicateKeyException exception) { + Integer bound = jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding " + + "WHERE wallet_id=? AND bank_account_id=? AND status='BOUND'", Integer.class, + payee.walletId, payee.accountId()); + if (bound == null || bound == 0) throw exception; + } + } + + private BusinessException conflict(String message) { + return new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } + + private static final class PayeeDefinition { + private final String userId; + private final String walletId; + private final String bankCode; + private final String bankName; + private final String organizationId; + + private PayeeDefinition(String userId, String walletId, String bankCode, String bankName, + String organizationId) { + this.userId = userId; + this.walletId = walletId; + this.bankCode = bankCode; + this.bankName = bankName; + this.organizationId = organizationId; + } + + private String accountId() { + return "ACCOUNT_" + walletId; + } + } + + private static final class WalletRow { + private final String userId; + private final String walletType; + private final String status; + + private WalletRow(String userId, String walletType, String status) { + this.userId = userId; + this.walletType = walletType; + this.status = status; + } + } + + private static final class CertificateRow { + private final String status; + private final String filingStatus; + + private CertificateRow(String status, String filingStatus) { + this.status = status; + this.filingStatus = filingStatus; + } + } + + private static final class AccountRow { + private final String bankCode; + private final String status; + + private AccountRow(String bankCode, String status) { + this.bankCode = bankCode; + this.status = status; + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java index 0ba99e1..1a2ac09 100644 --- a/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java +++ b/src/main/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentController.java @@ -48,7 +48,9 @@ public class PaymentController { @GetMapping("/context") @Operation(summary = "用户支付数字货币:步骤一,初始化付款方和收款方钱包信息") - public ApiResponse context(@RequestParam String payeeWalletId) { return ok(service.context(payeeWalletId, actor())); } + public ApiResponse context(@RequestParam(required = false) String payeeWalletId) { + return ok(service.context(payeeWalletId, actor())); + } @PostMapping @Operation(summary = "用户支付数字货币:步骤二,发送支付请求") diff --git a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java index e4a5a02..96e5a8c 100644 --- a/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java +++ b/src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java @@ -198,6 +198,19 @@ class PaymentControllerTest { String.class)).isEqualTo("TRANSFERRED"); } + @Test + void loadsWhitelistedTeachingPayeeWhenWalletIsNotProvided() throws Exception { + mockMvc.perform(get("/api/v1/payments/context").with(payer())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.payer.walletId").value(PAYER_WALLET)) + .andExpect(jsonPath("$.data.payee.walletId").value("WALLET_TRAINING_PAYEE_B")) + .andExpect(jsonPath("$.data.payee.bankCode").value("BKCHCNBJ00002")); + + assertThat(jdbc.queryForObject("SELECT status FROM digital_wallet WHERE wallet_id=?", String.class, + "WALLET_TRAINING_PAYEE_B")).isEqualTo("ACTIVE"); + } + @Test void rejectsPaymentWhenPayerHasNoWalletOwnedCoins() throws Exception { String created = mockMvc.perform(post("/api/v1/payments").with(payer()).contentType(MediaType.APPLICATION_JSON)