fix(wallet): retire unused stale wallet after module 3 retraining

Module 3 retraining projects a new wallet_id/signature via
loadCompletedSource(), but ensureWallet() only ever validated the
existing digital_wallet row against it and threw a conflict when the
stale row (from before retraining) didn't match. If that stale wallet
was never actually used downstream (zero balance/frozen amount, no
exchange/payment/ledger history), retire it and its dependent
certificate/contract/binding rows so the fresh module 3 result can be
projected instead of surfacing '共享钱包与模块三最终结果不一致'.

Wallets that were actually used still correctly conflict.
chore/issuance-comments-zh
chenyuan 4 weeks ago
parent 87f93ae614
commit 93bed6a2a4

@ -192,16 +192,24 @@ public class WalletPrerequisiteProjectionService {
private void ensureWallet(SourceWallet source) {
List<Wallet> existing = jdbc.query(
"SELECT wallet_id,user_id,wallet_type,central_bank_confirmation_signature " +
"SELECT wallet_id,user_id,wallet_type,central_bank_confirmation_signature,balance,frozen_amount " +
"FROM digital_wallet WHERE wallet_id=? OR user_id=?",
(rs, row) -> new Wallet(rs.getString("wallet_id"), rs.getString("user_id"),
rs.getString("wallet_type"), rs.getString("central_bank_confirmation_signature")),
rs.getString("wallet_type"), rs.getString("central_bank_confirmation_signature"),
rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount")),
source.walletId, source.userId);
if (!existing.isEmpty()) {
if (existing.size() != 1) throw conflict("共享钱包标识或用户归属冲突");
validateWallet(existing.get(0), source);
Wallet value = existing.get(0);
if (!isRetrainedUnusedWallet(value, source)) {
validateWallet(value, source);
return;
}
// Module 3 was redone after this wallet was first projected here, and the stale wallet
// was never actually used downstream, so retire it and let the fresh module 3 result
// through instead of surfacing a spurious "共享钱包与模块三最终结果不一致" conflict.
retireUnusedWalletChain(value.walletId);
}
try {
jdbc.update("INSERT INTO digital_wallet " +
"(wallet_id,user_id,wallet_type,status,balance,frozen_amount,central_bank_confirmation_signature," +
@ -213,6 +221,25 @@ public class WalletPrerequisiteProjectionService {
}
}
private boolean isRetrainedUnusedWallet(Wallet value, SourceWallet source) {
if (!source.userId.equals(value.userId) || source.walletId.equals(value.walletId)) return false;
if (value.balance.signum() != 0 || value.frozenAmount.signum() != 0) return false;
Integer usageCount = jdbc.queryForObject(
"SELECT (SELECT COUNT(*) FROM currency_exchange_order WHERE wallet_id=?) + " +
"(SELECT COUNT(*) FROM payment_order WHERE payer_wallet_id=? OR payee_wallet_id=?) + " +
"(SELECT COUNT(*) FROM wallet_balance_ledger WHERE wallet_id=?) + " +
"(SELECT COUNT(*) FROM payment_wallet_ledger WHERE wallet_id=?)",
Integer.class, value.walletId, value.walletId, value.walletId, value.walletId, value.walletId);
return usageCount != null && usageCount == 0;
}
private void retireUnusedWalletChain(String staleWalletId) {
jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", staleWalletId);
jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", staleWalletId);
jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", staleWalletId);
jdbc.update("DELETE FROM digital_wallet WHERE wallet_id=?", staleWalletId);
}
private void ensureCertificate(SourceWallet source) {
List<WalletCertificate> existing = jdbc.query(
"SELECT certificate_serial,wallet_id,public_key,central_bank_root_signature " +
@ -470,9 +497,13 @@ public class WalletPrerequisiteProjectionService {
private final String userId;
private final String walletType;
private final String finalSignature;
private Wallet(String walletId, String userId, String walletType, String finalSignature) {
private final BigDecimal balance;
private final BigDecimal frozenAmount;
private Wallet(String walletId, String userId, String walletType, String finalSignature,
BigDecimal balance, BigDecimal frozenAmount) {
this.walletId = walletId; this.userId = userId;
this.walletType = walletType; this.finalSignature = finalSignature;
this.balance = balance; this.frozenAmount = frozenAmount;
}
}

@ -250,6 +250,55 @@ class WalletPrerequisiteProjectionServiceTest {
return jdbc.queryForObject("SELECT account_id FROM simulated_bank_account WHERE user_id=?", String.class, SUBJECT_USER);
}
@Test
void retrainedModuleThreeReplacesUnusedStaleWalletInsteadOfConflicting() {
seedCompleted();
service.ensureForSubject(subject());
String newWalletId = "WALLET_PROJECTION_TEST_RETRAIN";
WalletOpeningTestData.deleteWalletFacts(jdbc, SUBJECT_USER);
WalletOpeningTestData.insertCompletedWallet(jdbc, 980100L, SUBJECT_USER, SCHOOL_ID, CLASS_ID,
newWalletId, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行",
"6216610100001234567", new BigDecimal("50000.00"));
WalletPrerequisiteReference reference = service.ensureForSubject(subject());
assertThat(reference.getWalletId()).isEqualTo(newWalletId);
assertThat(count("digital_wallet", "wallet_id", WALLET_ID)).isZero();
assertThat(count("digital_wallet", "wallet_id", newWalletId)).isEqualTo(1);
assertThat(count("wallet_certificate", "wallet_id", newWalletId)).isEqualTo(1);
assertThat(count("wallet_contract", "wallet_id", newWalletId)).isEqualTo(1);
assertThat(count("wallet_bank_binding", "wallet_id", newWalletId)).isEqualTo(1);
jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", newWalletId);
jdbc.update("DELETE FROM wallet_contract WHERE wallet_id=?", newWalletId);
jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", newWalletId);
jdbc.update("DELETE FROM digital_wallet WHERE wallet_id=?", newWalletId);
}
@Test
void retrainedModuleThreeStillConflictsWhenStaleWalletWasActuallyUsed() {
seedCompleted();
service.ensureForSubject(subject());
String accountId = jdbc.queryForObject(
"SELECT account_id FROM simulated_bank_account WHERE user_id=?", String.class, SUBJECT_USER);
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_USED',?,?,?,?," +
"'ORG_EXPECTED',1,'CNY','20260904090000','TEST','TEST','TEST','CANCELLED'," +
"CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test')",
SUBJECT_USER, WALLET_ID, accountId, BANK_CODE);
String newWalletId = "WALLET_PROJECTION_TEST_RETRAIN_USED";
WalletOpeningTestData.deleteWalletFacts(jdbc, SUBJECT_USER);
WalletOpeningTestData.insertCompletedWallet(jdbc, 980200L, SUBJECT_USER, SCHOOL_ID, CLASS_ID,
newWalletId, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行",
"6216610100001234567", new BigDecimal("50000.00"));
assertThatThrownBy(() -> service.ensureForSubject(subject()))
.isInstanceOf(BusinessException.class).hasMessageContaining("共享钱包与模块三最终结果不一致");
}
@Test
void sameBankCodeFromAnotherScopeCannotSupplyOrganizationId() {
seedCompleted();

Loading…
Cancel
Save