diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java new file mode 100644 index 0000000..fdd87fa --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionService.java @@ -0,0 +1,448 @@ +package com.yau.digitalrmb.shared.wallet; + +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.UUID; + +@Service +public class WalletPrerequisiteProjectionService { + private static final DateTimeFormatter MODULE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final JdbcTemplate jdbc; + private final WalletPrivateKeyCipher privateKeyCipher; + + public WalletPrerequisiteProjectionService(JdbcTemplate jdbc, WalletPrivateKeyCipher privateKeyCipher) { + this.jdbc = jdbc; + this.privateKeyCipher = privateKeyCipher; + } + + @Transactional + public WalletPrerequisiteReference ensureForSubject(InstitutionKeySubject subject) { + SourceWallet source = loadCompletedSource(subject); + Institution institution = loadInstitution(subject); + String accountId = ensureBankAccount(source, institution); + ensureWallet(source); + ensureCertificate(source); + ensureContract(source); + ensureBinding(source.walletId, accountId); + return new WalletPrerequisiteReference(source.userId, source.schoolId, source.classId, + source.walletId, institution.bankCode, institution.organizationId); + } + + @Transactional + public WalletPrerequisiteReference ensureForWallet(String walletId) { + if (blank(walletId)) throw prerequisite(); + List subjects = jdbc.query( + "SELECT DISTINCT user_id,school_id,class_id FROM central_wallet_activation " + + "WHERE wallet_id=? AND wallet_activated=TRUE AND final_sent=TRUE " + + "AND cb_final_signature IS NOT NULL AND deleted=FALSE", + (rs, row) -> new InstitutionKeySubject(rs.getString("user_id"), + rs.getLong("school_id"), rs.getLong("class_id")), walletId.trim()); + if (subjects.isEmpty()) throw prerequisite(); + if (subjects.size() != 1) throw conflict("钱包标识对应多个实验主体"); + return ensureForSubject(subjects.get(0)); + } + + private SourceWallet loadCompletedSource(InstitutionKeySubject subject) { + List activations = jdbc.query( + "SELECT wallet_id,cb_final_signature,final_time FROM central_wallet_activation " + + "WHERE user_id=? AND school_id=? AND class_id=? AND wallet_activated=TRUE " + + "AND final_sent=TRUE AND cb_final_signature IS NOT NULL AND status='FINAL_SENT' " + + "AND deleted=FALSE ORDER BY created_at DESC LIMIT 1", + (rs, row) -> new Activation(rs.getString("wallet_id"), + rs.getString("cb_final_signature"), rs.getString("final_time")), + subject.getUserId(), subject.getSchoolId(), subject.getClassId()); + if (activations.isEmpty()) throw prerequisite(); + Activation activation = activations.get(0); + + List applications = jdbc.query( + "SELECT COALESCE(account_bank,selected_bank) bank_name,bank_card_number,account_balance,wallet_type " + + "FROM wallet_application WHERE user_id=? AND school_id=? AND class_id=? " + + "AND status='SUBMITTED' AND deleted=FALSE AND bank_card_number IS NOT NULL " + + "AND account_balance IS NOT NULL AND COALESCE(account_bank,selected_bank) IS NOT NULL " + + "ORDER BY created_at DESC LIMIT 1", + (rs, row) -> new Application(rs.getString("bank_name"), rs.getString("bank_card_number"), + rs.getBigDecimal("account_balance"), rs.getString("wallet_type")), + subject.getUserId(), subject.getSchoolId(), subject.getClassId()); + List certificates = jdbc.query( + "SELECT cert_private_key,cert_public_key,cert_serial_number,cert_issued_time " + + "FROM wallet_identifier_generation WHERE user_id=? AND school_id=? AND class_id=? " + + "AND wallet_identifier=? AND status='CERT_ISSUED' AND deleted=FALSE " + + "AND cert_private_key IS NOT NULL AND cert_public_key IS NOT NULL " + + "AND cert_serial_number IS NOT NULL ORDER BY created_at DESC LIMIT 1", + (rs, row) -> new Certificate(rs.getString("cert_private_key"), rs.getString("cert_public_key"), + rs.getString("cert_serial_number"), rs.getString("cert_issued_time")), + subject.getUserId(), subject.getSchoolId(), subject.getClassId(), activation.walletId); + List rootSignatures = jdbc.query( + "SELECT cb_root_signature FROM central_wallet_registration " + + "WHERE user_id=? AND school_id=? AND class_id=? AND wallet_id=? " + + "AND wallet_registered=TRUE AND sent=TRUE AND cb_root_signature IS NOT NULL " + + "AND deleted=FALSE ORDER BY created_at DESC LIMIT 1", + (rs, row) -> rs.getString("cb_root_signature"), subject.getUserId(), + subject.getSchoolId(), subject.getClassId(), activation.walletId); + List contracts = jdbc.query( + "SELECT wallet_type,single_payment_limit,daily_payment_limit,annual_payment_limit,balance_ceiling," + + "contract_plaintext,contract_digest,contract_id,contract_effective_time " + + "FROM smart_contract_generation WHERE user_id=? AND school_id=? AND class_id=? " + + "AND wallet_id=? AND sent=TRUE AND status='SENT' AND deleted=FALSE " + + "AND contract_id IS NOT NULL AND single_payment_limit IS NOT NULL " + + "AND daily_payment_limit IS NOT NULL AND annual_payment_limit IS NOT NULL " + + "AND balance_ceiling IS NOT NULL AND contract_plaintext IS NOT NULL " + + "AND contract_digest IS NOT NULL ORDER BY created_at DESC LIMIT 1", + (rs, row) -> new Contract(rs.getString("wallet_type"), + decimal(rs.getString("single_payment_limit")), decimal(rs.getString("daily_payment_limit")), + decimal(rs.getString("annual_payment_limit")), decimal(rs.getString("balance_ceiling")), + rs.getString("contract_plaintext"), rs.getString("contract_digest"), + rs.getString("contract_id"), rs.getString("contract_effective_time")), + subject.getUserId(), subject.getSchoolId(), subject.getClassId(), activation.walletId); + if (applications.isEmpty() || certificates.isEmpty() || rootSignatures.isEmpty() || contracts.isEmpty()) { + throw prerequisite(); + } + Application application = applications.get(0); + Contract contract = contracts.get(0); + if (!blank(application.walletType) && !blank(contract.walletType) + && !application.walletType.equals(contract.walletType)) { + throw conflict("模块三申请钱包类型与合约钱包类型不一致"); + } + return new SourceWallet(subject.getUserId(), subject.getSchoolId(), subject.getClassId(), + activation.walletId, activation.finalSignature, timestamp(activation.finalTime), application, + certificates.get(0), rootSignatures.get(0), contract); + } + + private Institution loadInstitution(InstitutionKeySubject subject) { + List institutions = jdbc.query( + "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", + (rs, row) -> new Institution(rs.getString("bank_code"), + rs.getString("institution_identifier")), subject.getUserId(), + subject.getSchoolId(), subject.getClassId()); + if (institutions.isEmpty()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "请先完成机构标识实验并取得当前用户、学校和班级对应的机构标识"); + } + return institutions.get(0); + } + + private String ensureBankAccount(SourceWallet source, Institution institution) { + String expectedId = "ACCOUNT_" + UUID.nameUUIDFromBytes( + (source.userId + "|" + institution.bankCode).getBytes(StandardCharsets.UTF_8)); + List existing = bankAccounts(source.userId, institution.bankCode); + if (!existing.isEmpty()) { + validateAccount(existing.get(0), source, institution); + return existing.get(0).accountId; + } + 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 (?,?,?,?,?,?,?,0,'ACTIVE',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", + expectedId, source.userId, institution.bankCode, source.application.bankName, + source.application.cardNumber, lastFour(source.application.cardNumber), + source.application.accountBalance); + } catch (DuplicateKeyException exception) { + existing = bankAccounts(source.userId, institution.bankCode); + if (existing.isEmpty()) throw exception; + validateAccount(existing.get(0), source, institution); + return existing.get(0).accountId; + } + return expectedId; + } + + private void ensureWallet(SourceWallet source) { + List existing = jdbc.query( + "SELECT wallet_id,user_id,wallet_type,central_bank_confirmation_signature " + + "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")), + source.walletId, source.userId); + if (!existing.isEmpty()) { + if (existing.size() != 1) throw conflict("共享钱包标识或用户归属冲突"); + validateWallet(existing.get(0), source); + return; + } + 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 (?,?,?,'ACTIVE',0,0,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", + source.walletId, source.userId, source.contract.walletType, + source.finalSignature, source.openedAt); + } catch (DuplicateKeyException exception) { + ensureWallet(source); + } + } + + private void ensureCertificate(SourceWallet source) { + List existing = jdbc.query( + "SELECT certificate_serial,wallet_id,public_key,central_bank_root_signature " + + "FROM wallet_certificate WHERE wallet_id=? OR certificate_serial=?", + (rs, row) -> new WalletCertificate(rs.getString("certificate_serial"), + rs.getString("wallet_id"), rs.getString("public_key"), + rs.getString("central_bank_root_signature")), + source.walletId, source.certificate.serialNumber); + if (!existing.isEmpty()) { + if (existing.size() != 1) throw conflict("共享钱包证书序列号或钱包归属冲突"); + WalletCertificate value = existing.get(0); + if (!source.walletId.equals(value.walletId) + || !source.certificate.serialNumber.equals(value.serialNumber) + || !source.certificate.publicKey.equals(value.publicKey) + || !source.rootSignature.equals(value.rootSignature)) { + throw conflict("共享钱包证书与模块三结果不一致"); + } + return; + } + 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',?,'VALID',?)", + source.certificate.serialNumber, source.walletId, source.certificate.publicKey, + privateKeyCipher.encrypt(source.certificate.privateKey), source.rootSignature, + timestamp(source.certificate.issuedTime)); + } catch (DuplicateKeyException exception) { + ensureCertificate(source); + } + } + + private void ensureContract(SourceWallet source) { + List existing = jdbc.query( + "SELECT contract_id,wallet_id,wallet_type FROM wallet_contract WHERE wallet_id=? OR contract_id=?", + (rs, row) -> new WalletContract(rs.getString("contract_id"), + rs.getString("wallet_id"), rs.getString("wallet_type")), + source.walletId, source.contract.contractId); + if (!existing.isEmpty()) { + if (existing.size() != 1) throw conflict("共享钱包合约标识或钱包归属冲突"); + WalletContract value = existing.get(0); + if (!source.walletId.equals(value.walletId) + || !source.contract.contractId.equals(value.contractId) + || !source.contract.walletType.equals(value.walletType)) { + throw conflict("共享钱包合约与模块三结果不一致"); + } + return; + } + try { + jdbc.update("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)", + source.contract.contractId, source.walletId, source.contract.walletType, + source.contract.singleLimit, source.contract.dailyLimit, source.contract.annualLimit, + source.contract.balanceLimit, source.contract.originalText, source.contract.digest); + } catch (DuplicateKeyException exception) { + ensureContract(source); + } + } + + private void ensureBinding(String walletId, String accountId) { + List existing = jdbc.query( + "SELECT bank_account_id FROM wallet_bank_binding WHERE wallet_id=?", + (rs, row) -> rs.getString("bank_account_id"), walletId); + if (!existing.isEmpty()) { + if (existing.size() != 1 || !accountId.equals(existing.get(0))) { + throw conflict("共享钱包银行卡绑定与模块三结果不一致"); + } + return; + } + try { + jdbc.update("INSERT INTO wallet_bank_binding (wallet_id,bank_account_id,status,bound_at) " + + "VALUES (?,?,'BOUND',CURRENT_TIMESTAMP)", walletId, accountId); + } catch (DuplicateKeyException exception) { + ensureBinding(walletId, accountId); + } + } + + 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=?", + (rs, row) -> new BankAccount(rs.getString("account_id"), rs.getString("user_id"), + rs.getString("bank_code"), rs.getString("card_number")), userId, bankCode); + } + + private void validateAccount(BankAccount value, SourceWallet source, Institution institution) { + if (!source.userId.equals(value.userId) || !institution.bankCode.equals(value.bankCode) + || !source.application.cardNumber.equals(value.cardNumber)) { + throw conflict("共享银行账户与模块三银行卡结果不一致"); + } + } + + private void validateWallet(Wallet value, SourceWallet source) { + if (!source.walletId.equals(value.walletId) || !source.userId.equals(value.userId) + || !source.contract.walletType.equals(value.walletType) + || !source.finalSignature.equals(value.finalSignature)) { + throw conflict("共享钱包与模块三最终结果不一致"); + } + } + + private Timestamp timestamp(String value) { + if (!blank(value)) { + try { + return Timestamp.valueOf(LocalDateTime.parse(value.trim(), MODULE_TIMESTAMP)); + } catch (DateTimeParseException ignored) { + // Module 3 historical rows may use display text; initialization time is the safe fallback. + } + } + return Timestamp.valueOf(LocalDateTime.now()); + } + + private BigDecimal decimal(String value) { + try { + return new BigDecimal(value).setScale(2); + } catch (RuntimeException exception) { + throw prerequisite(); + } + } + + private String lastFour(String cardNumber) { + if (blank(cardNumber) || cardNumber.trim().length() < 4) throw prerequisite(); + String normalized = cardNumber.trim(); + return normalized.substring(normalized.length() - 4); + } + + private boolean blank(String value) { + return value == null || value.trim().isEmpty(); + } + + private BusinessException prerequisite() { + return new BusinessException(ErrorCode.VALIDATION_ERROR, + "请先完成个人数字钱包开通实验的申请、证书备案、合约生成和央行最终确认"); + } + + private BusinessException conflict(String message) { + return new BusinessException(ErrorCode.VALIDATION_ERROR, message); + } + + private static final class Activation { + private final String walletId; + private final String finalSignature; + private final String finalTime; + private Activation(String walletId, String finalSignature, String finalTime) { + this.walletId = walletId; this.finalSignature = finalSignature; this.finalTime = finalTime; + } + } + + private static final class Application { + private final String bankName; + private final String cardNumber; + private final BigDecimal accountBalance; + private final String walletType; + private Application(String bankName, String cardNumber, BigDecimal accountBalance, String walletType) { + this.bankName = bankName; this.cardNumber = cardNumber; + this.accountBalance = accountBalance; this.walletType = walletType; + } + } + + private static final class Certificate { + private final String privateKey; + private final String publicKey; + private final String serialNumber; + private final String issuedTime; + private Certificate(String privateKey, String publicKey, String serialNumber, String issuedTime) { + this.privateKey = privateKey; this.publicKey = publicKey; + this.serialNumber = serialNumber; this.issuedTime = issuedTime; + } + } + + private static final class Contract { + private final String walletType; + private final BigDecimal singleLimit; + private final BigDecimal dailyLimit; + private final BigDecimal annualLimit; + private final BigDecimal balanceLimit; + private final String originalText; + private final String digest; + private final String contractId; + private final String effectiveTime; + private Contract(String walletType, BigDecimal singleLimit, BigDecimal dailyLimit, + BigDecimal annualLimit, BigDecimal balanceLimit, String originalText, + String digest, String contractId, String effectiveTime) { + this.walletType = walletType; this.singleLimit = singleLimit; this.dailyLimit = dailyLimit; + this.annualLimit = annualLimit; this.balanceLimit = balanceLimit; this.originalText = originalText; + this.digest = digest; this.contractId = contractId; this.effectiveTime = effectiveTime; + } + } + + private static final class SourceWallet { + private final String userId; + private final long schoolId; + private final long classId; + private final String walletId; + private final String finalSignature; + private final Timestamp openedAt; + private final Application application; + private final Certificate certificate; + private final String rootSignature; + private final Contract contract; + private SourceWallet(String userId, long schoolId, long classId, String walletId, + String finalSignature, Timestamp openedAt, Application application, + Certificate certificate, String rootSignature, Contract contract) { + this.userId = userId; this.schoolId = schoolId; this.classId = classId; + this.walletId = walletId; this.finalSignature = finalSignature; this.openedAt = openedAt; + this.application = application; this.certificate = certificate; + this.rootSignature = rootSignature; this.contract = contract; + } + } + + private static final class Institution { + private final String bankCode; + private final String organizationId; + private Institution(String bankCode, String organizationId) { + this.bankCode = bankCode; this.organizationId = organizationId; + } + } + + private static final class BankAccount { + private final String accountId; + private final String userId; + private final String bankCode; + private final String cardNumber; + private BankAccount(String accountId, String userId, String bankCode, String cardNumber) { + this.accountId = accountId; this.userId = userId; + this.bankCode = bankCode; this.cardNumber = cardNumber; + } + } + + private static final class Wallet { + private final String walletId; + private final String userId; + private final String walletType; + private final String finalSignature; + private Wallet(String walletId, String userId, String walletType, String finalSignature) { + this.walletId = walletId; this.userId = userId; + this.walletType = walletType; this.finalSignature = finalSignature; + } + } + + private static final class WalletCertificate { + private final String serialNumber; + private final String walletId; + private final String publicKey; + private final String rootSignature; + private WalletCertificate(String serialNumber, String walletId, String publicKey, String rootSignature) { + this.serialNumber = serialNumber; this.walletId = walletId; + this.publicKey = publicKey; this.rootSignature = rootSignature; + } + } + + private static final class WalletContract { + private final String contractId; + private final String walletId; + private final String walletType; + private WalletContract(String contractId, String walletId, String walletType) { + this.contractId = contractId; this.walletId = walletId; this.walletType = walletType; + } + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteReference.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteReference.java new file mode 100644 index 0000000..8377086 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteReference.java @@ -0,0 +1,27 @@ +package com.yau.digitalrmb.shared.wallet; + +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; } +} diff --git a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java new file mode 100644 index 0000000..d5ba861 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletPrerequisiteProjectionServiceTest.java @@ -0,0 +1,146 @@ +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.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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.sql.Timestamp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +class WalletPrerequisiteProjectionServiceTest { + private static final String SUBJECT_USER = "00000000-0000-0000-0000-000000000880"; + private static final String OTHER_USER = "00000000-0000-0000-0000-000000000881"; + private static final long SCHOOL_ID = 1880L; + private static final long CLASS_ID = 2880L; + private static final String WALLET_ID = "WALLET_PROJECTION_TEST"; + private static final String BANK_CODE = "BKCHCNBJ00880"; + private static final long ACTIVATION_ID = 980004L; + + @Autowired private JdbcTemplate jdbc; + @Autowired private InstitutionIdentityCryptography cryptography; + @Autowired private WalletPrerequisiteProjectionService service; + + private InstitutionSm2KeyPair keyPair; + + @BeforeEach + void setUp() { + clean(); + keyPair = cryptography.generateSm2KeyPair(); + } + + @AfterEach + void tearDown() { + clean(); + } + + @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(count("digital_wallet", "user_id", SUBJECT_USER)).isZero(); + assertThat(count("simulated_bank_account", "user_id", SUBJECT_USER)).isZero(); + } + + @Test + void completedModuleThreeCreatesAllSharedRuntimeRows() { + seedCompleted(); + + WalletPrerequisiteReference reference = service.ensureForSubject(subject()); + + assertThat(reference.getWalletId()).isEqualTo(WALLET_ID); + assertThat(reference.getOrganizationId()).isEqualTo("ORG_EXPECTED"); + assertThat(count("digital_wallet", "wallet_id", WALLET_ID)).isEqualTo(1); + assertThat(count("wallet_certificate", "wallet_id", WALLET_ID)).isEqualTo(1); + assertThat(count("wallet_contract", "wallet_id", WALLET_ID)).isEqualTo(1); + assertThat(count("wallet_bank_binding", "wallet_id", WALLET_ID)).isEqualTo(1); + String encrypted = jdbc.queryForObject( + "SELECT encrypted_private_key FROM wallet_certificate WHERE wallet_id=?", String.class, WALLET_ID); + assertThat(encrypted).isNotEqualTo(keyPair.getPrivateKey()); + } + + @Test + void repeatedProjectionDoesNotOverwriteMutableRuntimeStateOrModuleThreeFacts() { + seedCompleted(); + 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); + Timestamp activationUpdatedAt = jdbc.queryForObject( + "SELECT updated_at FROM central_wallet_activation WHERE id=?", Timestamp.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 frozen_amount FROM digital_wallet WHERE wallet_id=?", + BigDecimal.class, WALLET_ID)).isEqualByComparingTo("20.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 updated_at FROM central_wallet_activation WHERE id=?", + Timestamp.class, ACTIVATION_ID)).isEqualTo(activationUpdatedAt); + } + + @Test + void sameBankCodeFromAnotherScopeCannotSupplyOrganizationId() { + seedCompleted(); + seedInstitutionIdentifier(980011L, OTHER_USER, SCHOOL_ID + 1, CLASS_ID + 1, + BANK_CODE, "ORG_WRONG"); + + assertThat(service.ensureForWallet(WALLET_ID).getOrganizationId()).isEqualTo("ORG_EXPECTED"); + } + + private void seedCompleted() { + WalletOpeningTestData.insertCompletedWallet(jdbc, 980000L, SUBJECT_USER, SCHOOL_ID, CLASS_ID, + WALLET_ID, keyPair.getPrivateKey(), keyPair.getPublicKey(), "测试银行", + "6216610100001234567", new BigDecimal("50000.00")); + seedInstitutionIdentifier(980010L, SUBJECT_USER, SCHOOL_ID, CLASS_ID, BANK_CODE, "ORG_EXPECTED"); + } + + private void seedInstitutionIdentifier(long id, String userId, long schoolId, long classId, + String bankCode, String organizationId) { + jdbc.update("INSERT INTO institution_identifier_application (id,bank_code,user_id,school_id,class_id," + + "request_timestamp,original_text,status,institution_identifier,training_round,scoring_criteria," + + "created_at,updated_at,created_by,updated_by,deleted) VALUES " + + "(?,?,?,?,?,'20260818090000','TEST','ISSUED',?,1,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?,?,FALSE)", + id, bankCode, userId, schoolId, classId, organizationId, userId, userId); + } + + private InstitutionKeySubject subject() { + return new InstitutionKeySubject(SUBJECT_USER, SCHOOL_ID, CLASS_ID); + } + + private int count(String table, String column, String value) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + "=?", + Integer.class, value); + } + + private void clean() { + 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); + jdbc.update("DELETE FROM simulated_bank_account WHERE user_id=?", SUBJECT_USER); + jdbc.update("DELETE FROM digital_wallet WHERE user_id=?", SUBJECT_USER); + WalletOpeningTestData.deleteWalletFacts(jdbc, SUBJECT_USER); + WalletOpeningTestData.deleteWalletFacts(jdbc, OTHER_USER); + jdbc.update("DELETE FROM institution_identifier_application WHERE id IN (980010,980011)"); + } +} diff --git a/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java b/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java new file mode 100644 index 0000000..f4dacbd --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/testsupport/WalletOpeningTestData.java @@ -0,0 +1,50 @@ +package com.yau.digitalrmb.testsupport; + +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; + +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); + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 6161feb..937445e 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -418,6 +418,104 @@ CREATE TABLE IF NOT EXISTS wallet_bank_binding ( CONSTRAINT fk_wallet_bank_binding_wallet FOREIGN KEY(wallet_id) REFERENCES digital_wallet(wallet_id), CONSTRAINT fk_wallet_bank_binding_account FOREIGN KEY(bank_account_id) REFERENCES simulated_bank_account(account_id) ); + +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 +); CREATE TABLE IF NOT EXISTS currency_exchange_order ( id CHAR(36) PRIMARY KEY, request_no VARCHAR(96) NOT NULL UNIQUE, user_id VARCHAR(36) NOT NULL, wallet_id VARCHAR(96) NOT NULL, bank_account_id VARCHAR(96) NOT NULL, bank_code VARCHAR(32) NOT NULL,