From 4c1dc15d664e8a94f49cc7fae41fc186f22bc1b8 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Fri, 4 Sep 2026 10:35:35 +0800 Subject: [PATCH] fix(exchange): restore unused shared bank account from wallet opening --- .../WalletPrerequisiteProjectionService.java | 37 ++++- ...lletPrerequisiteProjectionServiceTest.java | 128 ++++++++++++++++++ 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java index dad57ca..f48a933 100644 --- a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java @@ -169,7 +169,8 @@ public class WalletPrerequisiteProjectionService { (source.userId + "|" + institution.bankCode).getBytes(StandardCharsets.UTF_8)); List existing = bankAccounts(source.userId, institution.bankCode); if (!existing.isEmpty()) { - validateAccount(existing.get(0), source, institution); + restoreUnusedBankAccount(existing.get(0), source, institution); + validateAccount(bankAccounts(source.userId, institution.bankCode).get(0), source, institution); return existing.get(0).accountId; } try { @@ -182,7 +183,8 @@ public class WalletPrerequisiteProjectionService { } catch (DuplicateKeyException exception) { existing = bankAccounts(source.userId, institution.bankCode); if (existing.isEmpty()) throw exception; - validateAccount(existing.get(0), source, institution); + restoreUnusedBankAccount(existing.get(0), source, institution); + validateAccount(bankAccounts(source.userId, institution.bankCode).get(0), source, institution); return existing.get(0).accountId; } return expectedId; @@ -291,10 +293,31 @@ public class WalletPrerequisiteProjectionService { } private List bankAccounts(String userId, String bankCode) { - return jdbc.query("SELECT account_id,user_id,bank_code,card_number FROM simulated_bank_account " + - "WHERE user_id=? AND bank_code=?", + return jdbc.query("SELECT account_id,user_id,bank_code,card_number,bank_name,balance,frozen_amount,status " + + "FROM simulated_bank_account WHERE user_id=? AND bank_code=? FOR UPDATE", (rs, row) -> new BankAccount(rs.getString("account_id"), rs.getString("user_id"), - rs.getString("bank_code"), rs.getString("card_number")), userId, bankCode); + rs.getString("bank_code"), rs.getString("card_number"), + blank(rs.getString("bank_name")) && rs.getBigDecimal("balance").signum() == 0 + && rs.getBigDecimal("frozen_amount").signum() == 0 + && "ACTIVE".equals(rs.getString("status"))), userId, bankCode); + } + + private void restoreUnusedBankAccount(BankAccount value, SourceWallet source, Institution institution) { + if (!value.emptyShell || !source.userId.equals(value.userId) + || !institution.bankCode.equals(value.bankCode) || blank(source.application.bankName) + || source.application.accountBalance.signum() < 0) { + return; + } + // Repair only unused legacy shells. Runtime balances must never be reset from an opening snapshot. + jdbc.update("UPDATE simulated_bank_account SET bank_name=?,card_number=?,card_last4=?,balance=?," + + "updated_at=CURRENT_TIMESTAMP WHERE account_id=? AND user_id=? AND bank_code=? " + + "AND TRIM(COALESCE(bank_name,''))='' AND balance=0 AND frozen_amount=0 AND status='ACTIVE' " + + "AND NOT EXISTS (SELECT 1 FROM currency_exchange_order WHERE bank_account_id=?) " + + "AND NOT EXISTS (SELECT 1 FROM exchange_debit_record WHERE bank_account_id=?) " + + "AND NOT EXISTS (SELECT 1 FROM wallet_bank_binding WHERE bank_account_id=? AND wallet_id<>?)", + source.application.bankName, source.application.cardNumber, lastFour(source.application.cardNumber), + source.application.accountBalance, value.accountId, source.userId, institution.bankCode, + value.accountId, value.accountId, value.accountId, source.walletId); } private void validateAccount(BankAccount value, SourceWallet source, Institution institution) { @@ -434,9 +457,11 @@ public class WalletPrerequisiteProjectionService { private final String userId; private final String bankCode; private final String cardNumber; - private BankAccount(String accountId, String userId, String bankCode, String cardNumber) { + private final boolean emptyShell; + private BankAccount(String accountId, String userId, String bankCode, String cardNumber, boolean emptyShell) { this.accountId = accountId; this.userId = userId; this.bankCode = bankCode; this.cardNumber = cardNumber; + this.emptyShell = emptyShell; } } diff --git a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java index 38efd22..e56d428 100644 --- a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java +++ b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java @@ -3,11 +3,16 @@ package com.yau.digitalrmb.shared.wallet; import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography; import com.yau.digitalrmb.institutionidentity.domain.InstitutionSm2KeyPair; +import com.yau.digitalrmb.exchange.domain.model.ExchangeActor; +import com.yau.digitalrmb.exchange.domain.model.ExchangeContext; +import com.yau.digitalrmb.exchange.domain.repository.ExchangeResourceRepository; import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.testsupport.WalletOpeningTestData; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; @@ -33,6 +38,7 @@ class WalletPrerequisiteProjectionServiceTest { @Autowired private JdbcTemplate jdbc; @Autowired private InstitutionIdentityCryptography cryptography; @Autowired private WalletPrerequisiteProjectionService service; + @Autowired private ExchangeResourceRepository exchangeResources; private InstitutionSm2KeyPair keyPair; @@ -109,6 +115,7 @@ class WalletPrerequisiteProjectionServiceTest { service.ensureForSubject(subject()); jdbc.update("UPDATE digital_wallet SET balance=321.00,frozen_amount=20.00 WHERE wallet_id=?", WALLET_ID); jdbc.update("UPDATE wallet_contract SET daily_used_amount=45.00 WHERE wallet_id=?", WALLET_ID); + jdbc.update("UPDATE simulated_bank_account SET balance=123.00,frozen_amount=10.00 WHERE user_id=?", SUBJECT_USER); Timestamp activationUpdatedAt = jdbc.queryForObject( "SELECT updated_at FROM central_wallet_activation WHERE id=?", Timestamp.class, ACTIVATION_ID); @@ -122,6 +129,125 @@ class WalletPrerequisiteProjectionServiceTest { BigDecimal.class, WALLET_ID)).isEqualByComparingTo("45.00"); assertThat(jdbc.queryForObject("SELECT updated_at FROM central_wallet_activation WHERE id=?", Timestamp.class, ACTIVATION_ID)).isEqualTo(activationUpdatedAt); + assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE user_id=?", + BigDecimal.class, SUBJECT_USER)).isEqualByComparingTo("123.00"); + assertThat(jdbc.queryForObject("SELECT frozen_amount FROM simulated_bank_account WHERE user_id=?", + BigDecimal.class, SUBJECT_USER)).isEqualByComparingTo("10.00"); + } + + @Test + void unusedLegacyShellIsRestoredInPlaceFromCompletedModuleThreeAndOnlyOnce() { + seedCompleted(); + String accountId = seedBoundShell(); + jdbc.update("UPDATE wallet_application SET input_bank_card_number='6216610100007654321' WHERE user_id=?", + SUBJECT_USER); + + service.ensureForSubject(subject()); + + assertThat(jdbc.queryForObject("SELECT card_number FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEqualTo("6216610100007654321"); + assertThat(jdbc.queryForObject("SELECT card_last4 FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEqualTo("4321"); + assertThat(jdbc.queryForObject("SELECT bank_name FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEqualTo("测试银行"); + assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?", + BigDecimal.class, accountId)).isEqualByComparingTo("50000.00"); + assertThat(jdbc.queryForObject("SELECT bank_account_id FROM wallet_bank_binding WHERE wallet_id=?", + String.class, WALLET_ID)).isEqualTo(accountId); + ExchangeContext context = exchangeResources.loadContext(new ExchangeActor(SUBJECT_USER, "test", SCHOOL_ID, CLASS_ID)); + assertThat(context.getBankAccountId()).isEqualTo(accountId); + assertThat(context.getBankCardNumber()).isEqualTo("6216610100007654321"); + assertThat(context.getBankCardLast4()).isEqualTo("4321"); + assertThat(context.getBankName()).isEqualTo("测试银行"); + assertThat(context.getBankAccountBalance()).isEqualByComparingTo("50000.00"); + + jdbc.update("UPDATE simulated_bank_account SET balance=49000.00 WHERE account_id=?", accountId); + service.ensureForSubject(subject()); + assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?", + BigDecimal.class, accountId)).isEqualByComparingTo("49000.00"); + } + + @ParameterizedTest + @ValueSource(strings = {"balance=1", "frozen_amount=1", "bank_name='已有开户行'", "status='DISABLED'"}) + void nonShellAccountWithDifferentCardIsNeverOverwritten(String state) { + seedCompleted(); + String accountId = seedBoundShell(); + jdbc.update("UPDATE simulated_bank_account SET " + state + " WHERE account_id=?", accountId); + + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("共享银行账户与模块三银行卡结果不一致"); + assertThat(jdbc.queryForObject("SELECT card_number FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEqualTo("6222020000000000000"); + } + + @Test + void evenCancelledExchangeHistoryPreventsShellRestoration() { + seedCompleted(); + String accountId = seedBoundShell(); + jdbc.update("INSERT INTO currency_exchange_order (id,request_no,user_id,wallet_id,bank_account_id,bank_code," + + "organization_id,amount,currency,request_timestamp,message_text,signing_original_text,digest," + + "status,created_at,updated_at,created_by,updated_by) VALUES " + + "('00000000-0000-0000-0000-000000000880','PROJECTION_HISTORY',?,?,?,?," + + "'ORG_EXPECTED',1,'CNY','20260904090000','TEST','TEST','TEST','CANCELLED'," + + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test')", + SUBJECT_USER, WALLET_ID, accountId, BANK_CODE); + + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("共享银行账户与模块三银行卡结果不一致"); + assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?", + BigDecimal.class, accountId)).isEqualByComparingTo("0"); + } + + @Test + void laterWalletConflictRollsBackShellRestoration() { + seedCompleted(); + String accountId = seedBoundShell(); + jdbc.update("UPDATE digital_wallet SET central_bank_confirmation_signature='OTHER' WHERE wallet_id=?", WALLET_ID); + + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("共享钱包与模块三最终结果不一致"); + assertThat(jdbc.queryForObject("SELECT card_number FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEqualTo("6222020000000000000"); + assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE account_id=?", + BigDecimal.class, accountId)).isEqualByComparingTo("0"); + } + + @Test + void incompleteModuleThreeCannotRestoreExistingShell() { + seedCompleted(); + String accountId = seedBoundShell(); + jdbc.update("UPDATE central_wallet_activation SET final_sent=FALSE WHERE id=?", ACTIVATION_ID); + + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("个人数字钱包开通"); + assertThat(jdbc.queryForObject("SELECT bank_name FROM simulated_bank_account WHERE account_id=?", + String.class, accountId)).isEmpty(); + } + + @Test + void accountBoundToAnotherWalletCannotBeRestored() { + seedCompleted(); + String accountId = seedBoundShell(); + 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 " + + "('WALLET_PROJECTION_OTHER',?,'TYPE_II','ACTIVE',0,0,'OTHER'," + + "CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", OTHER_USER); + try { + jdbc.update("INSERT INTO wallet_bank_binding (wallet_id,bank_account_id,status,bound_at) " + + "VALUES ('WALLET_PROJECTION_OTHER',?,'BOUND',CURRENT_TIMESTAMP)", accountId); + assertThatThrownBy(() -> service.ensureForSubject(subject())) + .isInstanceOf(BusinessException.class).hasMessageContaining("共享银行账户与模块三银行卡结果不一致"); + } finally { + jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id='WALLET_PROJECTION_OTHER'"); + jdbc.update("DELETE FROM digital_wallet WHERE wallet_id='WALLET_PROJECTION_OTHER'"); + } + } + + private String seedBoundShell() { + service.ensureForSubject(subject()); + jdbc.update("UPDATE simulated_bank_account SET bank_name='',card_number='6222020000000000000'," + + "card_last4='0000',balance=0,frozen_amount=0 WHERE user_id=?", SUBJECT_USER); + return jdbc.queryForObject("SELECT account_id FROM simulated_bank_account WHERE user_id=?", String.class, SUBJECT_USER); } @Test @@ -159,6 +285,8 @@ class WalletPrerequisiteProjectionServiceTest { } private void clean() { + jdbc.update("DELETE FROM exchange_debit_record WHERE exchange_id IN (SELECT id FROM currency_exchange_order WHERE user_id=?)", SUBJECT_USER); + jdbc.update("DELETE FROM currency_exchange_order WHERE user_id=?", SUBJECT_USER); jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", WALLET_ID); jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", WALLET_ID); jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", WALLET_ID);