feat: project completed wallet prerequisites on demand

agent/payment-training-progress
chenyuan 2 weeks ago
parent 8388a3fb46
commit ab5c2df7ed

@ -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<InstitutionKeySubject> 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<Activation> 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<Application> 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<Certificate> 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<String> 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<Contract> 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<Institution> 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<BankAccount> 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<Wallet> 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<WalletCertificate> 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<WalletContract> 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<String> 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<BankAccount> 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;
}
}
}

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

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

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

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

Loading…
Cancel
Save