Files
baiyue-firstleg/finance/src/test/java/com/baiyue/finance/service/OverseasFeeWalletClosureTest.java
T
2026-10-26 13:41:43 +08:00

184 lines
12 KiB
Java

package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */
class OverseasFeeWalletClosureTest {
private JdbcTemplate jdbc;
private FinanceWalletService wallet;
private TransactionTemplate transaction;
/** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */
@BeforeEach void setup() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
var sql = Files.readString(Path.of("src/main/resources/db/finance.sql"));
for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) {
var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql);
assertThat(matcher.find()).as(table).isTrue();
// H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。
var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "")
.replaceAll("(?i) UNSIGNED", "")
.replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "")
.replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL");
jdbc.execute(ddl);
}
var numbers = mock(FinanceDocumentNumberGenerator.class);
var sequence = new AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet());
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), numbers, "test", "", "test", "test");
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7));
}
/** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */
@Test void rejectionReleasesCashAndCreditExactlyOnce() {
freeze();
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00");
assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00");
var request = new WalletHoldActionRequest("金额录错,驳回费用");
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
var account = wallet.account(9);
assertThat(account.actualBalance()).isEqualByComparingTo("60.00");
assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero();
assertThat(account.availableBalance()).isEqualByComparingTo("160.00");
assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1);
}
/** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */
@Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() {
freeze();
var request = new WalletHoldActionRequest("审核通过扣款");
assertThatThrownBy(() -> transaction.execute(status -> {
wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request);
throw new IllegalStateException("模拟扣款事务提交前异常");
})).hasMessageContaining("模拟扣款");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("CONSUMPTION")).isZero();
for (int repeat = 0; repeat < 3; repeat++)
transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request));
assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request)))
.hasMessageContaining("已处理");
assertThat(wallet.account(9).actualBalance()).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1);
}
/** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */
@Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() {
var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(),
"OTHER", "TR-001 / 费用701", "退回多收操作费", 0);
var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
assertThat(second.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.remark()).isEqualTo("退回多收操作费");
assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701");
assertThat(first.operatorName()).isEqualTo("财务员");
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00");
assertThat(count("RECHARGE")).isEqualTo(1);
}
/** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */
@Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款")));
long original = hold.actionLedgerId();
var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
assertThat(replay.ledgerId()).isEqualTo(first.ledgerId());
assertThat(first.changeType()).isEqualTo("REFUND");
assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND");
assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original));
assertThat(wallet.account(9).creditUsed()).isZero();
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过");
transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款"));
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
assertThat(count("REFUND")).isEqualTo(2);
assertThat(count("CONSUMPTION")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00");
}
/** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */
@Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() {
freeze();
long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款");
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(10,'CNY',0,0,0,0,0,0,0,0,0)");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在");
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因");
assertThat(count("REFUND")).isZero();
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
assertThat(wallet.account(10).actualBalance()).isZero();
}
/** 不同请求键的并发退款同样受原消费可退余额约束。 */
@Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception {
freeze();
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
var start = new java.util.concurrent.CountDownLatch(1);
var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
try {
var results = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
for (int i = 0; i < 2; i++) {
final String reference = "concurrent-" + i;
results.add(executor.submit(() -> {
start.await();
try {
transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款"));
return true;
} catch (FinanceEventException ex) {
assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
return false;
}
}));
}
start.countDown();
int succeeded = 0;
for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++;
assertThat(succeeded).isEqualTo(1);
assertThat(count("REFUND")).isEqualTo(1);
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00");
} finally { executor.shutdownNow(); }
}
/** 冻结始终沿用同一海外费用业务身份。 */
private void freeze() {
transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest(
new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用")));
}
/** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */
private int count(String type) {
return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type);
}
}