docs: plan downstream wallet prerequisite projection
parent
dd9ab44590
commit
05468a12af
@ -0,0 +1,645 @@
|
||||
# Downstream Wallet Prerequisite Projection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make modules 4 and 5 pull completed module 3 wallet-opening results on first access and initialize shared wallet runtime state without module 3 writing downstream process data.
|
||||
|
||||
**Architecture:** A shared transactional `WalletPrerequisiteProjectionService` reads completed module 3 fact tables with full user/school/class isolation and inserts only missing shared wallet runtime rows. Exchange and payment repositories invoke it before their existing runtime-state queries; existing rows are validated and never overwritten, so real balances and limit counters remain authoritative.
|
||||
|
||||
**Tech Stack:** Java 8-compatible code, Spring Boot 2.7, `JdbcTemplate`, Spring transactions, JUnit 5, MockMvc, AssertJ, H2/MySQL-compatible SQL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-downstream-wallet-prerequisite-projection-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Module 3 fact tables remain read-only to modules 4 and 5.
|
||||
- Every module 1 and module 3 lookup is scoped by `user_id`, `school_id`, and `class_id`.
|
||||
- Projection is pull-based, transactional, idempotent, and insert-only for existing shared runtime rows.
|
||||
- Entering a context endpoint must not create exchange orders, payment orders, or step logs.
|
||||
- A module 5 payer needs module 4 wallet-owned currency; a payee only needs a completed module 3 wallet.
|
||||
- Do not address unrelated full-suite error-code assertion failures.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Put wallet private-key encryption in the shared boundary
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrivateKeyCipher.java`
|
||||
- Delete: `src/main/java/com/yau/digitalrmb/exchange/infrastructure/crypto/WalletPrivateKeyCipher.java`
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java`
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java`
|
||||
- Modify: `src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java`
|
||||
- Modify: `src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SecurityProperties.getJwt().getSecret()`.
|
||||
- Produces: `WalletPrivateKeyCipher.encrypt(String)` and `WalletPrivateKeyCipher.decrypt(String)` in `com.yau.digitalrmb.shared.wallet`.
|
||||
|
||||
- [ ] **Step 1: Move the component without changing its implementation**
|
||||
|
||||
Create the class under the shared package with the existing constructor and methods:
|
||||
|
||||
```java
|
||||
package com.yau.digitalrmb.shared.wallet;
|
||||
|
||||
import com.yau.digitalrmb.security.config.SecurityProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class WalletPrivateKeyCipher {
|
||||
private static final int IV_LENGTH = 12;
|
||||
private final SecretKeySpec key;
|
||||
|
||||
public WalletPrivateKeyCipher(SecurityProperties properties) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
this.key = new SecretKeySpec(digest, "AES");
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("钱包私钥加密组件初始化失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String privateKey) {
|
||||
try {
|
||||
byte[] iv = new byte[IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
|
||||
byte[] encrypted = cipher.doFinal(privateKey.getBytes(StandardCharsets.US_ASCII));
|
||||
byte[] output = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, output, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
|
||||
return Base64.getEncoder().encodeToString(output);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("钱包私钥加密失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String value) {
|
||||
try {
|
||||
byte[] input = Base64.getDecoder().decode(value);
|
||||
byte[] iv = new byte[IV_LENGTH];
|
||||
byte[] encrypted = new byte[input.length - IV_LENGTH];
|
||||
System.arraycopy(input, 0, iv, 0, iv.length);
|
||||
System.arraycopy(input, iv.length, encrypted, 0, encrypted.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
|
||||
return new String(cipher.doFinal(encrypted), StandardCharsets.US_ASCII);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("钱包私钥解密失败", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Delete the old class and replace all four imports with:
|
||||
|
||||
```java
|
||||
import com.yau.digitalrmb.shared.wallet.WalletPrivateKeyCipher;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Compile and run the existing exchange/payment tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -DskipTests compile
|
||||
mvn "-Dtest=ExchangeControllerTest,PaymentControllerTest" test
|
||||
```
|
||||
|
||||
Expected: compilation succeeds; 3 tests pass with 0 failures.
|
||||
|
||||
- [ ] **Step 3: Commit the boundary-only refactor**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrivateKeyCipher.java src/main/java/com/yau/digitalrmb/exchange/infrastructure/crypto/WalletPrivateKeyCipher.java src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java
|
||||
git commit -m "refactor: share wallet private key cipher"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Project completed module 3 facts into shared wallet runtime state
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteReference.java`
|
||||
- Create: `src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java`
|
||||
- Create: `src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java`
|
||||
- Create: `src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java`
|
||||
- Modify: `src/test/resources/schema.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `InstitutionKeySubject`, module 3 fact rows, module 1 institution identifier, `WalletPrivateKeyCipher`.
|
||||
- Produces:
|
||||
|
||||
```java
|
||||
public final class WalletPrerequisiteReference {
|
||||
public String getUserId();
|
||||
public long getSchoolId();
|
||||
public long getClassId();
|
||||
public String getWalletId();
|
||||
public String getBankCode();
|
||||
public String getOrganizationId();
|
||||
}
|
||||
|
||||
public class WalletPrerequisiteProjectionService {
|
||||
public WalletPrerequisiteReference ensureForSubject(InstitutionKeySubject subject);
|
||||
public WalletPrerequisiteReference ensureForWallet(String walletId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Add module 3 source tables to the H2 test schema**
|
||||
|
||||
Append H2-compatible definitions matching the production columns used by the projector. Define all columns below, including audit/scope columns, so production SQL is exercised unchanged:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS wallet_application (
|
||||
id BIGINT PRIMARY KEY, user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL,
|
||||
account_bank VARCHAR(100), bank_card_number VARCHAR(30), account_balance DECIMAL(20,2), selected_bank VARCHAR(100),
|
||||
wallet_type VARCHAR(20), application_id VARCHAR(64), status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, created_by VARCHAR(64) NOT NULL,
|
||||
updated_by VARCHAR(64) NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wallet_identifier_generation (
|
||||
id BIGINT PRIMARY KEY, user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL,
|
||||
wallet_identifier VARCHAR(128), cert_private_key VARCHAR(512), cert_public_key VARCHAR(512),
|
||||
cert_serial_number VARCHAR(64), cert_issued_time VARCHAR(20), status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, created_by VARCHAR(64) NOT NULL,
|
||||
updated_by VARCHAR(64) NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS central_wallet_registration (
|
||||
id BIGINT PRIMARY KEY, user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL,
|
||||
wallet_id VARCHAR(128), cb_root_signature VARCHAR(512), wallet_registered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sent BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, created_by VARCHAR(64) NOT NULL,
|
||||
updated_by VARCHAR(64) NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS smart_contract_generation (
|
||||
id BIGINT PRIMARY KEY, user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL,
|
||||
wallet_id VARCHAR(128), wallet_type VARCHAR(30), single_payment_limit VARCHAR(30), daily_payment_limit VARCHAR(30),
|
||||
annual_payment_limit VARCHAR(30), balance_ceiling VARCHAR(30), validity VARCHAR(20), contract_plaintext VARCHAR(1000),
|
||||
contract_digest VARCHAR(128), contract_id VARCHAR(64), contract_effective_time VARCHAR(20), sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL,
|
||||
created_by VARCHAR(64) NOT NULL, updated_by VARCHAR(64) NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS central_wallet_activation (
|
||||
id BIGINT PRIMARY KEY, user_id VARCHAR(36) NOT NULL, school_id BIGINT NOT NULL, class_id BIGINT NOT NULL,
|
||||
wallet_id VARCHAR(128), wallet_activated BOOLEAN NOT NULL DEFAULT FALSE, wallet_register_status VARCHAR(32),
|
||||
cb_final_signature VARCHAR(512), final_time VARCHAR(20), final_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL,
|
||||
created_by VARCHAR(64) NOT NULL, updated_by VARCHAR(64) NOT NULL, deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing integration tests for incomplete, complete, idempotent, and isolated projection**
|
||||
|
||||
Create a shared test-data utility so all three integration test classes seed the same module 3 completion contract:
|
||||
|
||||
```java
|
||||
public final class WalletOpeningTestData {
|
||||
private WalletOpeningTestData() {}
|
||||
|
||||
public static void insertCompletedWallet(JdbcTemplate jdbc, long baseId, String userId,
|
||||
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," +
|
||||
"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, "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) " +
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,'CERT_ISSUED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)",
|
||||
baseId + 1, userId, schoolId, classId, walletId, privateKey, publicKey, "CERT_" + baseId, "20260818090000");
|
||||
jdbc.update("INSERT INTO central_wallet_registration (id,user_id,school_id,class_id,wallet_id,cb_root_signature," +
|
||||
"wallet_registered,sent,status,created_at,updated_at,created_by,updated_by,deleted) " +
|
||||
"VALUES (?,?,?,?,?,'CB_ROOT_TEST',TRUE,TRUE,'SENT',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)",
|
||||
baseId + 2, userId, schoolId, classId, walletId);
|
||||
jdbc.update("INSERT INTO smart_contract_generation (id,user_id,school_id,class_id,wallet_id,wallet_type," +
|
||||
"single_payment_limit,daily_payment_limit,annual_payment_limit,balance_ceiling,validity,contract_plaintext," +
|
||||
"contract_digest,contract_id,contract_effective_time,sent,status,created_at,updated_at,created_by,updated_by,deleted) " +
|
||||
"VALUES (?,?,?,?,?,'TYPE_II','50000.00','100000.00','500000.00','500000.00','长期有效'," +
|
||||
"'CONTRACT_TEXT','0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',?," +
|
||||
"'20260818090000',TRUE,'SENT',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)",
|
||||
baseId + 3, userId, schoolId, classId, walletId, "CONTRACT_" + baseId);
|
||||
jdbc.update("INSERT INTO central_wallet_activation (id,user_id,school_id,class_id,wallet_id,wallet_activated," +
|
||||
"wallet_register_status,cb_final_signature,final_time,final_sent,status,created_at,updated_at,created_by,updated_by,deleted) " +
|
||||
"VALUES (?,?,?,?,?,TRUE,'AVAILABLE','CB_FINAL_TEST','20260818090000',TRUE,'FINAL_SENT'," +
|
||||
"CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)",
|
||||
baseId + 4, userId, schoolId, classId, walletId);
|
||||
}
|
||||
|
||||
public static void deleteWalletFacts(JdbcTemplate jdbc, String userId) {
|
||||
jdbc.update("DELETE FROM central_wallet_activation WHERE user_id=?", userId);
|
||||
jdbc.update("DELETE FROM smart_contract_generation WHERE user_id=?", userId);
|
||||
jdbc.update("DELETE FROM central_wallet_registration WHERE user_id=?", userId);
|
||||
jdbc.update("DELETE FROM wallet_identifier_generation WHERE user_id=?", userId);
|
||||
jdbc.update("DELETE FROM wallet_application WHERE user_id=?", userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `@SpringBootTest` tests using real `JdbcTemplate` and the real cipher. Seed unique IDs and clean them in `@AfterEach`.
|
||||
|
||||
```java
|
||||
@Test
|
||||
void incompleteModuleThreeCreatesNoSharedRuntimeRows() {
|
||||
jdbc.update("INSERT INTO central_wallet_activation (id,user_id,school_id,class_id,wallet_id,wallet_activated," +
|
||||
"final_sent,status,created_at,updated_at,created_by,updated_by,deleted) " +
|
||||
"VALUES (?,?,?,?,?,FALSE,FALSE,'PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)",
|
||||
ACTIVATION_ID, SUBJECT_USER, SCHOOL_ID, CLASS_ID, WALLET_ID);
|
||||
assertThatThrownBy(() -> service.ensureForSubject(subject()))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("个人数字钱包开通");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE user_id=?", Integer.class, SUBJECT_USER)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM simulated_bank_account WHERE user_id=?", Integer.class, SUBJECT_USER)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedModuleThreeCreatesAllSharedRuntimeRows() {
|
||||
WalletOpeningTestData.insertCompletedWallet(jdbc, 980000L, SUBJECT_USER, SCHOOL_ID, CLASS_ID,
|
||||
WALLET_ID, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", "6216610100001234567", new BigDecimal("50000.00"));
|
||||
seedInstitutionIdentifier(SUBJECT_USER, SCHOOL_ID, CLASS_ID, BANK_CODE, "ORG_EXPECTED", true);
|
||||
WalletPrerequisiteReference reference = service.ensureForSubject(subject());
|
||||
assertThat(reference.getWalletId()).isEqualTo(WALLET_ID);
|
||||
assertThat(reference.getOrganizationId()).isEqualTo("ORG_EXPECTED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id=?", Integer.class, WALLET_ID)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_certificate WHERE wallet_id=?", Integer.class, WALLET_ID)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_contract WHERE wallet_id=?", Integer.class, WALLET_ID)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_bank_binding WHERE wallet_id=?", Integer.class, WALLET_ID)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedProjectionDoesNotOverwriteMutableRuntimeStateOrModuleThreeFacts() {
|
||||
WalletOpeningTestData.insertCompletedWallet(jdbc, 980000L, SUBJECT_USER, SCHOOL_ID, CLASS_ID,
|
||||
WALLET_ID, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", "6216610100001234567", new BigDecimal("50000.00"));
|
||||
seedInstitutionIdentifier(SUBJECT_USER, SCHOOL_ID, CLASS_ID, BANK_CODE, "ORG_EXPECTED", true);
|
||||
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);
|
||||
String activationUpdatedAt = jdbc.queryForObject("SELECT CAST(updated_at AS VARCHAR) FROM central_wallet_activation WHERE id=?", String.class, ACTIVATION_ID);
|
||||
service.ensureForSubject(subject());
|
||||
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE wallet_id=?", BigDecimal.class, WALLET_ID)).isEqualByComparingTo("321.00");
|
||||
assertThat(jdbc.queryForObject("SELECT daily_used_amount FROM wallet_contract WHERE wallet_id=?", BigDecimal.class, WALLET_ID)).isEqualByComparingTo("45.00");
|
||||
assertThat(jdbc.queryForObject("SELECT CAST(updated_at AS VARCHAR) FROM central_wallet_activation WHERE id=?", String.class, ACTIVATION_ID)).isEqualTo(activationUpdatedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameBankCodeFromAnotherScopeCannotSupplyOrganizationId() {
|
||||
WalletOpeningTestData.insertCompletedWallet(jdbc, 980000L, SUBJECT_USER, SCHOOL_ID, CLASS_ID,
|
||||
WALLET_ID, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", "6216610100001234567", new BigDecimal("50000.00"));
|
||||
seedInstitutionIdentifier(SUBJECT_USER, SCHOOL_ID, CLASS_ID, BANK_CODE, "ORG_EXPECTED", true);
|
||||
seedInstitutionIdentifier("other-user", SCHOOL_ID + 1, CLASS_ID + 1, BANK_CODE, "ORG_WRONG", true);
|
||||
assertThat(service.ensureForWallet(WALLET_ID).getOrganizationId()).isEqualTo("ORG_EXPECTED");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the new test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=WalletPrerequisiteProjectionServiceTest" test
|
||||
```
|
||||
|
||||
Expected: test compilation fails because `WalletPrerequisiteProjectionService` and `WalletPrerequisiteReference` do not exist. This is the intended RED condition.
|
||||
|
||||
- [ ] **Step 4: Implement the immutable reference**
|
||||
|
||||
```java
|
||||
public final class WalletPrerequisiteReference {
|
||||
private final String userId;
|
||||
private final long schoolId;
|
||||
private final long classId;
|
||||
private final String walletId;
|
||||
private final String bankCode;
|
||||
private final String organizationId;
|
||||
|
||||
public WalletPrerequisiteReference(String userId, long schoolId, long classId,
|
||||
String walletId, String bankCode, String organizationId) {
|
||||
this.userId = userId;
|
||||
this.schoolId = schoolId;
|
||||
this.classId = classId;
|
||||
this.walletId = walletId;
|
||||
this.bankCode = bankCode;
|
||||
this.organizationId = organizationId;
|
||||
}
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public long getSchoolId() { return schoolId; }
|
||||
public long getClassId() { return classId; }
|
||||
public String getWalletId() { return walletId; }
|
||||
public String getBankCode() { return bankCode; }
|
||||
public String getOrganizationId() { return organizationId; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement transactional source validation and lookup**
|
||||
|
||||
Annotate both public methods with `@Transactional`. `ensureForSubject` must query each module 3 table using the same three scope columns, `deleted=FALSE`, expected completion state, descending `created_at`, and `LIMIT 1`. Link identifier, registration, and contract rows to the activation wallet ID. Query module 1 with all scope columns:
|
||||
|
||||
```sql
|
||||
SELECT bank_code,institution_identifier
|
||||
FROM institution_identifier_application
|
||||
WHERE user_id=? AND school_id=? AND class_id=?
|
||||
AND status IN ('ISSUED','FEEDBACKED') AND deleted=FALSE
|
||||
AND bank_code IS NOT NULL AND institution_identifier IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
```
|
||||
|
||||
`ensureForWallet` must locate exactly one completed activation by `wallet_id`, `wallet_activated=TRUE`, `final_sent=TRUE`, non-null final signature, and `deleted=FALSE`, then delegate with the located subject. Zero matches returns the same prerequisite validation error; more than one scope returns a data-conflict error.
|
||||
|
||||
- [ ] **Step 6: Implement insert-only runtime projection**
|
||||
|
||||
Use deterministic account ID `ACCOUNT_` plus `UUID.nameUUIDFromBytes((userId + "|" + bankCode).getBytes(UTF_8))`. Parse `yyyyMMddHHmmss` timestamps when possible, otherwise use `CURRENT_TIMESTAMP`. Insert in this order:
|
||||
|
||||
```sql
|
||||
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 (?,?,?,?,?,?,?,0,'ACTIVE',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
|
||||
|
||||
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 (?,?,?,'ACTIVE',0,0,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
|
||||
|
||||
INSERT INTO wallet_certificate
|
||||
(certificate_serial,wallet_id,public_key,encrypted_private_key,filing_status,central_bank_root_signature,status,issued_at)
|
||||
VALUES (?,?,?,?, 'REGISTERED',?,'VALID',?)
|
||||
|
||||
INSERT INTO wallet_contract
|
||||
(contract_id,wallet_id,wallet_type,single_payment_limit,daily_payment_limit,annual_payment_limit,balance_limit,
|
||||
valid_until,original_text,digest,status,daily_used_amount,daily_counter_date,annual_used_amount,annual_counter_year,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,NULL,?,?,'ACTIVE',0,NULL,0,NULL,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
|
||||
|
||||
INSERT INTO wallet_bank_binding (wallet_id,bank_account_id,status,bound_at)
|
||||
VALUES (?,?,'BOUND',CURRENT_TIMESTAMP)
|
||||
```
|
||||
|
||||
Before each insert, query by its primary/unique business key. If a row exists, validate immutable ownership fields and skip the insert. Catch `DuplicateKeyException`, re-read, and run the same validation; never use an updating upsert. Encrypt the module 3 certificate private key with `WalletPrivateKeyCipher.encrypt` only when inserting a missing certificate.
|
||||
|
||||
- [ ] **Step 7: Run the new test and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=WalletPrerequisiteProjectionServiceTest" test
|
||||
```
|
||||
|
||||
Expected: 4 tests pass with 0 failures and 0 errors.
|
||||
|
||||
- [ ] **Step 8: Commit the shared projection**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/yau/digitalrmb/shared/wallet src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java src/test/resources/schema.sql
|
||||
git commit -m "feat: project completed wallet prerequisites on demand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Make module 4 pull module 3 prerequisites
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java`
|
||||
- Modify: `src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `WalletPrerequisiteProjectionService.ensureForSubject(InstitutionKeySubject)`.
|
||||
- Produces: unchanged `ExchangeResourceRepository.loadContext(ExchangeActor)` API.
|
||||
|
||||
- [ ] **Step 1: Rewrite exchange test setup to seed module 3 facts, not shared runtime rows**
|
||||
|
||||
Keep module 1 identifiers, keys, module 2 bank currency, and ownership setup. Replace direct inserts into the five shared wallet tables with:
|
||||
|
||||
```java
|
||||
WalletOpeningTestData.insertCompletedWallet(jdbc, 990100L, USER_ID, 1999L, 2999L,
|
||||
"WALLET_EXCHANGE_TEST", walletKey.getPrivateKey(), walletKey.getPublicKey(),
|
||||
"中国银行北京分行", "6216610100001234567", new BigDecimal("50000.00"));
|
||||
```
|
||||
|
||||
Add assertions to the successful context test:
|
||||
|
||||
```java
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM currency_exchange_order", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM exchange_step_log", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE user_id=?", Integer.class, USER_ID)).isEqualTo(1);
|
||||
```
|
||||
|
||||
Change `doesNotCreateDemoWalletWhenWalletOpeningOutputIsMissing` so it deletes module 3 fact rows as well as shared rows and retains the assertion that no wallet is created.
|
||||
|
||||
- [ ] **Step 2: Run exchange tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=ExchangeControllerTest" test
|
||||
```
|
||||
|
||||
Expected: the successful context test returns 400 because the repository still reads shared rows without invoking the projector.
|
||||
|
||||
- [ ] **Step 3: Inject and invoke the projector**
|
||||
|
||||
Add `WalletPrerequisiteProjectionService` to the repository constructor and start `loadContext` with:
|
||||
|
||||
```java
|
||||
InstitutionKeySubject subject = new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId());
|
||||
walletPrerequisiteProjectionService.ensureForSubject(subject);
|
||||
Institution institution = loadIssuedInstitution(actor);
|
||||
```
|
||||
|
||||
Do not invoke the projector from module 3 services or controllers.
|
||||
|
||||
- [ ] **Step 4: Run exchange tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=ExchangeControllerTest" test
|
||||
```
|
||||
|
||||
Expected: both exchange tests pass. The full five-step exchange still updates shared balances and ownership, while the missing-prerequisite test creates no shared rows.
|
||||
|
||||
- [ ] **Step 5: Commit module 4 integration**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/yau/digitalrmb/exchange/infrastructure/persistence/JdbcExchangeResourceRepository.java src/test/java/com/yau/digitalrmb/exchange/interfaces/rest/ExchangeControllerTest.java
|
||||
git commit -m "feat: load module three wallet data for exchange"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Make module 5 pull both parties and enforce scoped identifiers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java`
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java`
|
||||
- Modify: `src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java`
|
||||
- Modify: `src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ensureForSubject` for payer and `ensureForWallet` for payee.
|
||||
- Changes repository API to:
|
||||
|
||||
```java
|
||||
PaymentContext loadContext(PaymentActor actor, String payeeWalletId);
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Add failing payment association tests**
|
||||
|
||||
Seed completed module 3 facts for both users. For the payer, retain an already-existing shared wallet with balance `200.00` and module 4 `WALLET/AVAILABLE` coins; this represents completed module 4. Do not seed payee shared rows. Assert context access projects only the payee and creates no payment order:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void loadsPayeeFromCompletedModuleThreeWithoutCreatingPaymentOrder() throws Exception {
|
||||
jdbc.update("DELETE FROM wallet_bank_binding WHERE wallet_id=?", PAYEE_WALLET);
|
||||
jdbc.update("DELETE FROM wallet_certificate WHERE wallet_id=?", PAYEE_WALLET);
|
||||
jdbc.update("DELETE FROM simulated_bank_account WHERE user_id=?", PAYEE_USER);
|
||||
jdbc.update("DELETE FROM digital_wallet WHERE wallet_id=?", PAYEE_WALLET);
|
||||
|
||||
mockMvc.perform(get("/api/v1/payments/context")
|
||||
.param("payeeWalletId", PAYEE_WALLET).with(payer()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.payee.walletId").value(PAYEE_WALLET));
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM digital_wallet WHERE wallet_id=?", Integer.class, PAYEE_WALLET)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM payment_order", Integer.class)).isZero();
|
||||
}
|
||||
```
|
||||
|
||||
Add a second test with the same bank code in another school/class and assert payer/payee `organizationId` values come from their own scoped module 1 records. Add a third test where the payer has completed module 3 but has no `WALLET/AVAILABLE` coins; create and sign the request, then assert `/payer-bank-process` returns 400 and no ownership transfer is created.
|
||||
|
||||
- [ ] **Step 2: Run payment tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=PaymentControllerTest" test
|
||||
```
|
||||
|
||||
Expected: payee context returns 400 because no shared payee row exists, or the scope assertion returns the other user's organization identifier.
|
||||
|
||||
- [ ] **Step 3: Pass the complete actor through the payment repository boundary**
|
||||
|
||||
Change both service call sites:
|
||||
|
||||
```java
|
||||
return resourceRepository.loadContext(actor, payeeWalletId);
|
||||
PaymentContext context = resourceRepository.loadContext(actor, command.getPayeeWalletId());
|
||||
```
|
||||
|
||||
Update the interface and implementation signature to use `PaymentActor`.
|
||||
|
||||
- [ ] **Step 4: Pull payer and payee prerequisites before participant queries**
|
||||
|
||||
Implement:
|
||||
|
||||
```java
|
||||
WalletPrerequisiteReference payerRef = projector.ensureForSubject(
|
||||
new InstitutionKeySubject(actor.getUserId(), actor.getSchoolId(), actor.getClassId()));
|
||||
WalletPrerequisiteReference payeeRef = projector.ensureForWallet(payeeWalletId);
|
||||
PaymentParticipant payer = loadParticipant(payerRef);
|
||||
PaymentParticipant payee = loadParticipant(payeeRef);
|
||||
```
|
||||
|
||||
Replace the dynamic `where` SQL and unscoped `organizationId(bankCode)` method with a fixed wallet query:
|
||||
|
||||
```java
|
||||
private PaymentParticipant loadParticipant(WalletPrerequisiteReference reference) {
|
||||
List<PaymentParticipant> values = jdbc.query(
|
||||
"SELECT w.user_id,w.wallet_id,w.wallet_type,cert.certificate_serial,cert.public_key," +
|
||||
"a.bank_code,a.bank_name,w.balance,COALESCE(w.frozen_amount,0) frozen_amount " +
|
||||
"FROM digital_wallet w JOIN wallet_certificate cert ON cert.wallet_id=w.wallet_id " +
|
||||
"AND cert.status='VALID' AND cert.filing_status='REGISTERED' " +
|
||||
"JOIN wallet_bank_binding binding ON binding.wallet_id=w.wallet_id AND binding.status='BOUND' " +
|
||||
"JOIN simulated_bank_account a ON a.account_id=binding.bank_account_id AND a.status='ACTIVE' " +
|
||||
"WHERE w.wallet_id=? AND w.user_id=? AND w.status='ACTIVE' " +
|
||||
"AND w.central_bank_confirmation_signature IS NOT NULL",
|
||||
(rs, row) -> new PaymentParticipant(rs.getString("user_id"), rs.getString("wallet_id"),
|
||||
rs.getString("wallet_type"), rs.getString("certificate_serial"), rs.getString("public_key"),
|
||||
rs.getString("bank_code"), rs.getString("bank_name"), reference.getOrganizationId(),
|
||||
rs.getBigDecimal("balance"), rs.getBigDecimal("frozen_amount")),
|
||||
reference.getWalletId(), reference.getUserId());
|
||||
if (values.isEmpty()) throw validation("付款方或收款方钱包运行态不存在或状态异常");
|
||||
return values.get(0);
|
||||
}
|
||||
```
|
||||
|
||||
Delete the query that selects `institution_identifier_application` by `bank_code` only. The scoped organization ID must come from the projection reference.
|
||||
|
||||
- [ ] **Step 5: Run payment tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=PaymentControllerTest" test
|
||||
```
|
||||
|
||||
Expected: all payment tests pass; payee-only module 3 data is readable, scoped organization IDs are correct, and a payer without module 4 coins cannot proceed through payer-bank processing.
|
||||
|
||||
- [ ] **Step 6: Commit module 5 integration**
|
||||
|
||||
```bash
|
||||
git add src/main/java/com/yau/digitalrmb/payment/domain/repository/PaymentResourceRepository.java src/main/java/com/yau/digitalrmb/payment/application/service/PaymentApplicationService.java src/main/java/com/yau/digitalrmb/payment/infrastructure/persistence/JdbcPaymentResourceRepository.java src/test/java/com/yau/digitalrmb/payment/interfaces/rest/PaymentControllerTest.java
|
||||
git commit -m "feat: load scoped wallet prerequisites for payment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verify the complete module chain and document actual failures
|
||||
|
||||
**Files:**
|
||||
- Modify only if verification exposes a regression caused by Tasks 1-4.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: completed implementation from Tasks 1-4.
|
||||
- Produces: reproducible compile/test evidence and a clean tracked worktree.
|
||||
|
||||
- [ ] **Step 1: Check formatting, conflicts, and production compilation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
rg -n "^(<<<<<<<|=======|>>>>>>>)" src
|
||||
mvn -DskipTests compile
|
||||
```
|
||||
|
||||
Expected: `git diff --check` and compile exit 0; `rg` finds no conflict markers.
|
||||
|
||||
- [ ] **Step 2: Run the focused module-chain tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn "-Dtest=WalletPrerequisiteProjectionServiceTest,DigitalCurrencyGenerationStockIntegrationTest,ExchangeControllerTest,PaymentControllerTest" test
|
||||
```
|
||||
|
||||
Expected: all focused tests pass with 0 failures and 0 errors.
|
||||
|
||||
- [ ] **Step 3: Run the full suite without hiding existing failures**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
```
|
||||
|
||||
Expected: report the exact total, failure count, and test names. Fix only failures introduced by Tasks 1-4; retain and explicitly report unrelated existing failures such as string-versus-numeric API error-code assertions.
|
||||
|
||||
- [ ] **Step 4: Verify repository state and module 3 write isolation**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "UPDATE (wallet_application|bank_received_wallet_application|wallet_verification_record|wallet_identifier_generation|wallet_registration_request|central_wallet_registration|smart_contract_generation|central_wallet_activation)" src/main/java/com/yau/digitalrmb/exchange src/main/java/com/yau/digitalrmb/payment src/main/java/com/yau/digitalrmb/shared/wallet
|
||||
git status --short --branch
|
||||
git log --oneline -6
|
||||
```
|
||||
|
||||
Expected: no downstream update statements target module 3 fact tables. Only user-owned pre-existing untracked documents may remain outside the committed implementation.
|
||||
Loading…
Reference in New Issue