feat(wallet): add teacher-only runtime reset for exchange and payment
Completed exchanges keep the shared wallet projection pinned to the old module 3 result and the cancel guard refuses to roll the coins back, so a retrained card could never reach modules 4 and 5. The new endpoint wipes that user's exchange and payment history, returns the coins to the commercial bank vault, and re-projects from the latest module 3 result in the same transaction so the read-only context endpoints stay usable.chore/issuance-comments-zh
parent
7a7aed1652
commit
3dd516d0ff
@ -0,0 +1,31 @@
|
|||||||
|
package com.yau.digitalrmb.shared.wallet;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
public class WalletRuntimeResetResult {
|
||||||
|
@Schema(description = "删除的兑换订单数")
|
||||||
|
private final int deletedExchangeOrderCount;
|
||||||
|
@Schema(description = "删除的支付订单数")
|
||||||
|
private final int deletedPaymentOrderCount;
|
||||||
|
@Schema(description = "回退到商业银行库的币串数")
|
||||||
|
private final int restoredCoinCount;
|
||||||
|
@Schema(description = "删除的共享钱包数")
|
||||||
|
private final int deletedWalletCount;
|
||||||
|
@Schema(description = "删除的实验attempt数")
|
||||||
|
private final int deletedAttemptCount;
|
||||||
|
|
||||||
|
public WalletRuntimeResetResult(int deletedExchangeOrderCount, int deletedPaymentOrderCount,
|
||||||
|
int restoredCoinCount, int deletedWalletCount, int deletedAttemptCount) {
|
||||||
|
this.deletedExchangeOrderCount = deletedExchangeOrderCount;
|
||||||
|
this.deletedPaymentOrderCount = deletedPaymentOrderCount;
|
||||||
|
this.restoredCoinCount = restoredCoinCount;
|
||||||
|
this.deletedWalletCount = deletedWalletCount;
|
||||||
|
this.deletedAttemptCount = deletedAttemptCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDeletedExchangeOrderCount() { return deletedExchangeOrderCount; }
|
||||||
|
public int getDeletedPaymentOrderCount() { return deletedPaymentOrderCount; }
|
||||||
|
public int getRestoredCoinCount() { return restoredCoinCount; }
|
||||||
|
public int getDeletedWalletCount() { return deletedWalletCount; }
|
||||||
|
public int getDeletedAttemptCount() { return deletedAttemptCount; }
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
package com.yau.digitalrmb.shared.wallet;
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath;
|
||||||
|
import com.yau.digitalrmb.institutionidentity.application.InstitutionKeyService;
|
||||||
|
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.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.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = "file.path=target/test-files")
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class WalletRuntimeResetTest {
|
||||||
|
private static final String USER_ID = "00000000-0000-0000-0000-000000000995";
|
||||||
|
private static final long SCHOOL_ID = 1995L;
|
||||||
|
private static final long CLASS_ID = 2995L;
|
||||||
|
private static final String BANK_CODE = "BKCHCNBJ00995";
|
||||||
|
private static final String WALLET_ID = "WALLET_RUNTIME_RESET";
|
||||||
|
private static final long WALLET_FACT_ID = 995100L;
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private JdbcTemplate jdbc;
|
||||||
|
@Autowired private InstitutionIdentityCryptography cryptography;
|
||||||
|
@Autowired private InstitutionKeyService keyService;
|
||||||
|
@Autowired private WalletPrerequisiteProjectionService walletProjection;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seedModuleThreeAndIssuedCoins() {
|
||||||
|
clean();
|
||||||
|
jdbc.update("INSERT INTO sys_user (user_id,student_id,password,user_name,class_id,class_name,school_id,school_name,"
|
||||||
|
+ "role_id,is_deleted,zy_user_id) VALUES (?,?,?,?,?,?,?,?,4,0,?)",
|
||||||
|
USER_ID, "runtime-reset-test", "unused", "runtime-reset-test", "2995", "重置测试班", "1995", "延安大学", "995");
|
||||||
|
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 " +
|
||||||
|
"(995001,?,?,1995,2995,'20260918090000','TEST','ISSUED','ORG_RUNTIME_RESET',1,0," +
|
||||||
|
"CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,'test','test',FALSE)", BANK_CODE, USER_ID);
|
||||||
|
InstitutionKeySubject subject = new InstitutionKeySubject(USER_ID, SCHOOL_ID, CLASS_ID);
|
||||||
|
keyService.commercialBankKey(subject, "test");
|
||||||
|
keyService.centralBankKey(subject, "test");
|
||||||
|
String issuanceSignature = keyService.signCentralBank(subject, "RUNTIME_RESET_ORIGINAL");
|
||||||
|
InstitutionSm2KeyPair walletKey = cryptography.generateSm2KeyPair();
|
||||||
|
WalletOpeningTestData.insertCompletedWallet(jdbc, WALLET_FACT_ID, USER_ID, SCHOOL_ID, CLASS_ID,
|
||||||
|
WALLET_ID, walletKey.getPrivateKey(), walletKey.getPublicKey(), "中国银行北京分行",
|
||||||
|
"6216610100001234567", new BigDecimal("50000.00"));
|
||||||
|
walletProjection.ensureForSubject(subject);
|
||||||
|
for (int i = 1; i <= 2; i++) {
|
||||||
|
jdbc.update("INSERT INTO commercial_bank_currency (currency_id,source_currency_id,source_batch_id,"
|
||||||
|
+ "issuance_request_id,bank_code,organization_id,denomination,currency,complete_currency,"
|
||||||
|
+ "issuance_ownership_original_text,issuance_ownership_signature,status,"
|
||||||
|
+ "received_at,received_by_user_id,received_by) VALUES (?,?,?,?,?,?,100,'DC',?,"
|
||||||
|
+ "'RUNTIME_RESET_ORIGINAL',?,'AVAILABLE',CURRENT_TIMESTAMP,?,'test')",
|
||||||
|
"DC_RUNTIME_RESET_" + i, 995000 + i, 995000, "RUNTIME_RESET_ISSUANCE", BANK_CODE,
|
||||||
|
"ORG_RUNTIME_RESET", "DC_RUNTIME_COMPLETE_" + i, issuanceSignature, USER_ID);
|
||||||
|
jdbc.update("INSERT INTO central_bank_currency_ownership (currency_id,owner_type,owner_id,status," +
|
||||||
|
"last_transaction_id,updated_at) VALUES (?,'BANK',?,'AVAILABLE',NULL,CURRENT_TIMESTAMP)",
|
||||||
|
"DC_RUNTIME_RESET_" + i, BANK_CODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clean() {
|
||||||
|
jdbc.update("DELETE FROM exchange_step_log WHERE operator_user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM wallet_balance_ledger WHERE wallet_id=?", WALLET_ID);
|
||||||
|
jdbc.update("DELETE FROM exchange_coin_reservation WHERE reserved_by='test' AND currency_id LIKE 'DC_RUNTIME_RESET_%'");
|
||||||
|
jdbc.update("DELETE FROM currency_ownership_transfer WHERE to_wallet_id=?", WALLET_ID);
|
||||||
|
jdbc.update("DELETE FROM exchange_debit_record WHERE bank_account_id IN " +
|
||||||
|
"(SELECT account_id FROM simulated_bank_account WHERE user_id=?)", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM currency_exchange_order WHERE user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " +
|
||||||
|
"(SELECT id FROM training_experiment_attempt WHERE user_id=?)", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=?", USER_ID);
|
||||||
|
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=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM digital_wallet WHERE user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM central_bank_currency_ownership WHERE currency_id LIKE 'DC_RUNTIME_RESET_%'");
|
||||||
|
jdbc.update("DELETE FROM commercial_bank_currency WHERE issuance_request_id='RUNTIME_RESET_ISSUANCE'");
|
||||||
|
WalletOpeningTestData.deleteWalletFacts(jdbc, USER_ID);
|
||||||
|
jdbc.update("DELETE FROM institution_sm2_key_audit WHERE user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM institution_sm2_key WHERE user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM institution_identifier_application WHERE user_id=?", USER_ID);
|
||||||
|
jdbc.update("DELETE FROM sys_user WHERE user_id=?", USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void teacherResetRollsBackCompletedExchangeAndReprojectsFreshModuleThree() throws Exception {
|
||||||
|
String context = mockMvc.perform(get("/api/v1/exchanges/context").with(role(4)))
|
||||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||||
|
String walletId = JsonPath.read(context, "$.data.walletId");
|
||||||
|
String accountId = JsonPath.read(context, "$.data.bankAccountId");
|
||||||
|
|
||||||
|
String created = mockMvc.perform(post("/api/v1/exchanges").with(role(4))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"walletId\":\"" + walletId + "\",\"bankAccountId\":\"" + accountId
|
||||||
|
+ "\",\"amount\":200.00}"))
|
||||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
|
||||||
|
String id = JsonPath.read(created, "$.data.id");
|
||||||
|
mockMvc.perform(post("/api/v1/exchanges/{id}/sign", id).with(role(4))).andExpect(status().isOk());
|
||||||
|
mockMvc.perform(post("/api/v1/exchanges/{id}/bank-process", id).with(role(4))).andExpect(status().isOk());
|
||||||
|
mockMvc.perform(post("/api/v1/exchanges/{id}/reserve-coins", id).with(role(4))).andExpect(status().isOk());
|
||||||
|
mockMvc.perform(post("/api/v1/exchanges/{id}/confirm-ownership", id).with(role(4)))
|
||||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("SUCCESS"));
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM central_bank_currency_ownership " +
|
||||||
|
"WHERE owner_type='WALLET' AND owner_id=?", Integer.class, walletId)).isEqualTo(2);
|
||||||
|
|
||||||
|
// 模块三重新实训:换卡换余额。共享账户已有兑换历史,属于不能被自动跟随的在用账户。
|
||||||
|
retrainModuleThreeWith("6216610100009876543", new BigDecimal("8800.00"));
|
||||||
|
mockMvc.perform(get("/api/v1/exchanges/context").with(role(4)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.bankCardNumber").value("6216610100001234567"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/wallet-runtime/reset").with(role(3)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.deletedExchangeOrderCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.restoredCoinCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.deletedWalletCount").value(1));
|
||||||
|
|
||||||
|
assertThat(userCount("currency_exchange_order")).isZero();
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM exchange_step_log WHERE operator_user_id=?",
|
||||||
|
Integer.class, USER_ID)).isZero();
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM wallet_balance_ledger WHERE wallet_id=?",
|
||||||
|
Integer.class, walletId)).isZero();
|
||||||
|
assertThat(userCount("training_experiment_attempt")).isZero();
|
||||||
|
assertThat(jdbc.queryForObject("SELECT owner_type || '|' || owner_id || '|' || status " +
|
||||||
|
"FROM central_bank_currency_ownership WHERE currency_id='DC_RUNTIME_RESET_1'", String.class))
|
||||||
|
.isEqualTo("BANK|" + BANK_CODE + "|AVAILABLE");
|
||||||
|
|
||||||
|
// 重置接口当场按最新的模块三结果重建了投影:卡号与余额取自新的模块三快照,
|
||||||
|
// 证书/合约/绑定是否随之齐备由最后的 context 断言验证(其查询要求三者 INNER JOIN 命中)
|
||||||
|
assertThat(userCount("digital_wallet")).isEqualTo(1);
|
||||||
|
assertThat(userCount("simulated_bank_account")).isEqualTo(1);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT card_number FROM simulated_bank_account WHERE user_id=?",
|
||||||
|
String.class, USER_ID)).isEqualTo("6216610100009876543");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT balance FROM simulated_bank_account WHERE user_id=?",
|
||||||
|
BigDecimal.class, USER_ID)).isEqualByComparingTo("8800.00");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT balance FROM digital_wallet WHERE user_id=?",
|
||||||
|
BigDecimal.class, USER_ID)).isEqualByComparingTo("0.00");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v1/exchanges/context").with(role(4)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.bankCardNumber").value("6216610100009876543"))
|
||||||
|
.andExpect(jsonPath("$.data.bankAccountBalance").value(8800.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void retrainModuleThreeWith(String cardNumber, BigDecimal accountBalance) {
|
||||||
|
jdbc.update("UPDATE wallet_application SET bank_card_number=?,account_balance=?," +
|
||||||
|
"updated_at=CURRENT_TIMESTAMP WHERE id=?", cardNumber, accountBalance, WALLET_FACT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void studentRoleCannotReset() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/v1/wallet-runtime/reset").with(role(4)))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(post("/api/v1/wallet-runtime/reset").with(role(1)))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int userCount(String table) {
|
||||||
|
Integer value = jdbc.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE user_id=?",
|
||||||
|
Integer.class, USER_ID);
|
||||||
|
return value == null ? 0 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestPostProcessor role(int roleId) {
|
||||||
|
return jwt().jwt(jwt -> jwt.subject(USER_ID).claim("userId", USER_ID)
|
||||||
|
.claim("preferred_username", "runtime-reset-test")
|
||||||
|
.claim("schoolId", String.valueOf(SCHOOL_ID)).claim("classId", String.valueOf(CLASS_ID))
|
||||||
|
.claim("roleId", roleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue