From 3dd516d0ff5c3fcd21e6599d04205ab8089e2a46 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Fri, 18 Sep 2026 17:30:23 +0800 Subject: [PATCH] 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. --- .../wallet/WalletRuntimeResetController.java | 45 ++++ .../wallet/WalletRuntimeResetResult.java | 31 +++ .../wallet/WalletRuntimeResetService.java | 187 +++++++++++++++++ .../shared/wallet/WalletRuntimeResetTest.java | 192 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetController.java create mode 100644 src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetResult.java create mode 100644 src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java create mode 100644 src/test/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetTest.java diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetController.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetController.java new file mode 100644 index 0000000..ce10d8a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetController.java @@ -0,0 +1,45 @@ +package com.yau.digitalrmb.shared.wallet; + +import com.yau.digitalrmb.identity.domain.UserRole; +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import com.yau.digitalrmb.security.context.AuthContextHolder; +import com.yau.digitalrmb.security.context.JwtUser; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +@RestController +@RequestMapping("/api/v1/wallet-runtime") +@Tag(name = "钱包运行数据-彻底重开", description = "清空兑换/支付历史并重建模块三投影(教师/管理员专用)") +public class WalletRuntimeResetController { + + @Resource + private WalletRuntimeResetService resetService; + + @PostMapping("/reset") + @Operation(summary = "彻底重开兑换与支付实验", + description = "删除当前用户的全部兑换/支付订单与流水(含已确权完成的),把涉及币串回退到商业银行库," + + "并清空模块三投影出的共享钱包/银行账户/证书/合约/绑定与实验attempt," + + "随后立即按最新模块三结果重新投影,模块四/五可直接重新开始。仅教师和管理员可调用。") + public ApiResponse reset() { + JwtUser jwtUser = AuthContextHolder.get(); + Integer roleId = jwtUser.getRoleId(); + if (roleId == null || (roleId != UserRole.ADMIN.getId() && roleId != UserRole.TEACHER.getId())) { + throw new BusinessException(ErrorCode.FORBIDDEN, "仅教师或管理员可以彻底重开兑换与支付实验"); + } + return ApiResponse.success(resetService.reset(InstitutionKeySubject.from(jwtUser)), traceId()); + } + + private String traceId() { + return MDC.get(TraceIdFilter.MDC_KEY); + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetResult.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetResult.java new file mode 100644 index 0000000..35b1be8 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetResult.java @@ -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; } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java new file mode 100644 index 0000000..c8de82e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetService.java @@ -0,0 +1,187 @@ +package com.yau.digitalrmb.shared.wallet; + +import com.yau.digitalrmb.institutionidentity.application.InstitutionKeySubject; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +public class WalletRuntimeResetService { + private final JdbcTemplate jdbc; + private final WalletPrerequisiteProjectionService projectionService; + + public WalletRuntimeResetService(JdbcTemplate jdbc, WalletPrerequisiteProjectionService projectionService) { + this.jdbc = jdbc; + this.projectionService = projectionService; + } + + @Transactional + public WalletRuntimeResetResult reset(InstitutionKeySubject subject) { + String userId = subject.getUserId(); + List exchangeIds = jdbc.queryForList( + "SELECT id FROM currency_exchange_order WHERE user_id=?", String.class, userId); + List paymentIds = jdbc.queryForList( + "SELECT id FROM payment_order WHERE payer_user_id=? OR payee_user_id=?", + String.class, userId, userId); + List ownWalletIds = jdbc.queryForList( + "SELECT wallet_id FROM digital_wallet WHERE user_id=?", String.class, userId); + + int restoredCoins = restoreCoinsToBankVault(exchangeIds, paymentIds, ownWalletIds); + reverseCounterpartyBalances(paymentIds, userId); + deleteExchangeRows(exchangeIds, userId); + deletePaymentRows(paymentIds); + int deletedWallets = deleteSharedRuntime(userId, ownWalletIds); + int deletedAttempts = deleteAttempts(userId); + deleteTaskProgress(userId); + // 实训4/5 的上下文接口只读取共享投影,不会自行重建;删除后必须立即按最新的实训3结果重新投影, + // 否则清空后页面会一直停在「请先完成实训3」。实训3本身已被重新实训(无完成结果)时跳过重建。 + if (hasCompletedModuleThree(subject)) { + projectionService.ensureForSubject(subject); + } + return new WalletRuntimeResetResult(exchangeIds.size(), paymentIds.size(), restoredCoins, + deletedWallets, deletedAttempts); + } + + private boolean hasCompletedModuleThree(InstitutionKeySubject subject) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) 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", + Integer.class, subject.getUserId(), subject.getSchoolId(), subject.getClassId()); + return count != null && count > 0; + } + + private int restoreCoinsToBankVault(List exchangeIds, List paymentIds, + List ownWalletIds) { + Set currencyIds = new LinkedHashSet<>(); + if (!exchangeIds.isEmpty()) { + currencyIds.addAll(queryCurrencyIdsByBusiness("EXCHANGE", exchangeIds)); + } + if (!paymentIds.isEmpty()) { + currencyIds.addAll(queryCurrencyIdsByBusiness("PAYMENT", paymentIds)); + currencyIds.addAll(jdbc.queryForList("SELECT DISTINCT currency_id FROM payment_coin_reservation WHERE " + + in(paymentIds.size()), String.class, paymentIds.toArray())); + } + if (!ownWalletIds.isEmpty()) { + currencyIds.addAll(jdbc.queryForList("SELECT currency_id FROM central_bank_currency_ownership " + + "WHERE owner_type='WALLET' AND owner_id IN (" + in(ownWalletIds.size()) + ")", + String.class, ownWalletIds.toArray())); + } + for (String currencyId : currencyIds) { + List bankCodes = jdbc.queryForList( + "SELECT bank_code FROM commercial_bank_currency WHERE currency_id=?", + String.class, currencyId); + if (bankCodes.isEmpty()) continue; + jdbc.update("UPDATE central_bank_currency_ownership SET owner_type='BANK',owner_id=?," + + "status='AVAILABLE',lock_business_type=NULL,lock_business_id=NULL," + + "last_transaction_id=NULL,updated_at=CURRENT_TIMESTAMP WHERE currency_id=?", + bankCodes.get(0), currencyId); + } + return currencyIds.size(); + } + + private List queryCurrencyIdsByBusiness(String businessType, List businessIds) { + List args = new ArrayList<>(); + args.add(businessType); + args.addAll(businessIds); + return jdbc.queryForList("SELECT currency_id FROM central_bank_currency_ownership " + + "WHERE lock_business_type=? AND lock_business_id IN (" + in(businessIds.size()) + ")", + String.class, args.toArray()); + } + + private void reverseCounterpartyBalances(List paymentIds, String userId) { + for (String paymentId : paymentIds) { + List flows = jdbc.query("SELECT payer_user_id,payer_wallet_id,payee_user_id," + + "payee_wallet_id,amount FROM payment_order WHERE id=?", + (rs, row) -> new CounterpartyFlow(rs.getString("payer_user_id"), rs.getString("payer_wallet_id"), + rs.getString("payee_user_id"), rs.getString("payee_wallet_id"), + rs.getBigDecimal("amount")), paymentId); + if (flows.isEmpty()) continue; + CounterpartyFlow flow = flows.get(0); + if (!userId.equals(flow.payerUserId)) { + jdbc.update("UPDATE digital_wallet SET balance=balance+?,updated_at=CURRENT_TIMESTAMP " + + "WHERE wallet_id=?", flow.amount, flow.payerWalletId); + } + if (!userId.equals(flow.payeeUserId)) { + jdbc.update("UPDATE digital_wallet SET balance=balance-?,updated_at=CURRENT_TIMESTAMP " + + "WHERE wallet_id=?", flow.amount, flow.payeeWalletId); + } + } + } + + private void deleteExchangeRows(List exchangeIds, String userId) { + deleteIn("exchange_debit_record", "exchange_id", exchangeIds); + deleteIn("wallet_balance_ledger", "exchange_id", exchangeIds); + deleteIn("exchange_coin_reservation", "exchange_id", exchangeIds); + deleteIn("currency_ownership_transfer", "exchange_id", exchangeIds); + deleteIn("exchange_step_log", "exchange_id", exchangeIds); + jdbc.update("DELETE FROM currency_exchange_order WHERE user_id=?", userId); + } + + private void deletePaymentRows(List paymentIds) { + deleteIn("payment_step_log", "payment_id", paymentIds); + deleteIn("payment_notification", "payment_id", paymentIds); + deleteIn("payment_wallet_ledger", "payment_id", paymentIds); + deleteIn("payment_clearing_record", "payment_id", paymentIds); + deleteIn("payment_ownership_transfer", "payment_id", paymentIds); + deleteIn("payment_coin_reservation", "payment_id", paymentIds); + deleteIn("payment_order", "id", paymentIds); + } + + private int deleteSharedRuntime(String userId, List ownWalletIds) { + deleteIn("wallet_bank_binding", "wallet_id", ownWalletIds); + deleteIn("wallet_contract", "wallet_id", ownWalletIds); + deleteIn("wallet_certificate", "wallet_id", ownWalletIds); + jdbc.update("DELETE FROM digital_wallet WHERE user_id=?", userId); + jdbc.update("DELETE FROM simulated_bank_account WHERE user_id=?", userId); + return ownWalletIds.size(); + } + + private int deleteAttempts(String userId) { + jdbc.update("DELETE FROM training_experiment_action WHERE attempt_id IN " + + "(SELECT id FROM training_experiment_attempt WHERE user_id=? AND module_code IN ('EXCHANGE','PAYMENT'))", + userId); + return jdbc.update("DELETE FROM training_experiment_attempt WHERE user_id=? " + + "AND module_code IN ('EXCHANGE','PAYMENT')", userId); + } + + private void deleteTaskProgress(String userId) { + jdbc.update("DELETE FROM training_task_step_progress WHERE user_id=? " + + "AND module_code IN ('CURRENCY_EXCHANGE','CURRENCY_PAYMENT')", userId); + } + + private int deleteIn(String table, String column, List values) { + if (values.isEmpty()) return 0; + return jdbc.update("DELETE FROM " + table + " WHERE " + column + " IN (" + in(values.size()) + ")", + values.toArray()); + } + + private String in(int count) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < count; i++) { + if (i > 0) builder.append(','); + builder.append('?'); + } + return builder.toString(); + } + + private static final class CounterpartyFlow { + private final String payerUserId; + private final String payerWalletId; + private final String payeeUserId; + private final String payeeWalletId; + private final BigDecimal amount; + + private CounterpartyFlow(String payerUserId, String payerWalletId, String payeeUserId, + String payeeWalletId, BigDecimal amount) { + this.payerUserId = payerUserId; this.payerWalletId = payerWalletId; + this.payeeUserId = payeeUserId; this.payeeWalletId = payeeWalletId; + this.amount = amount; + } + } +} diff --git a/src/test/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetTest.java b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetTest.java new file mode 100644 index 0000000..cc8eb95 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/shared/wallet/WalletRuntimeResetTest.java @@ -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)); + } +}