fix: add teaching payment payee fallback

chore/issuance-comments-zh
陈沅 1 month ago
parent 718755f8a0
commit aea498726c

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

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

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

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

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

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

@ -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<WalletRow> 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<CertificateRow> 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<AccountRow> 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;
}
}
}

@ -48,7 +48,9 @@ public class PaymentController {
@GetMapping("/context")
@Operation(summary = "用户支付数字货币:步骤一,初始化付款方和收款方钱包信息")
public ApiResponse<PaymentContext> context(@RequestParam String payeeWalletId) { return ok(service.context(payeeWalletId, actor())); }
public ApiResponse<PaymentContext> context(@RequestParam(required = false) String payeeWalletId) {
return ok(service.context(payeeWalletId, actor()));
}
@PostMapping
@Operation(summary = "用户支付数字货币:步骤二,发送支付请求")

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

Loading…
Cancel
Save