184 lines
12 KiB
Java
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);
|
|
}
|
|
}
|