新加额度池配置功能
This commit is contained in:
+19
-2
@@ -22,8 +22,10 @@ import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditApply;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
|
||||
import com.ruoyi.customer.customermoney.service.ICreditQuotaService;
|
||||
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
|
||||
import com.ruoyi.customer.customermoney.vo.CustomerAccountStatusVO;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
|
||||
/**
|
||||
* 客户余额/收款/流水 Controller
|
||||
@@ -41,6 +43,9 @@ public class CustomerMoneyController extends BaseController
|
||||
@Autowired
|
||||
private ICustomerAccountApi customerAccountApi;
|
||||
|
||||
@Autowired
|
||||
private ICreditQuotaService creditQuotaService;
|
||||
|
||||
/**
|
||||
* 查询客户账号状态(收货扣件准入控制用)。支持 customerId 或 customerCode 二选一。
|
||||
* 只读轻量查询,不锁账户也不物化临时额度失效。
|
||||
@@ -197,7 +202,8 @@ public class CustomerMoneyController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过额度调整申请:通过后调整账户额度并生成额度流水
|
||||
* 审批通过额度调整申请:通过后调整账户额度并生成额度流水。
|
||||
* 非超管审批净增额度时 body 须带 approveRoleId(占用该角色额度池)。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('customer:credit:approve')")
|
||||
@Log(title = "客户额度调整审批", businessType = BusinessType.UPDATE)
|
||||
@@ -206,7 +212,18 @@ public class CustomerMoneyController extends BaseController
|
||||
@RequestBody(required = false) CustomerCreditApply body)
|
||||
{
|
||||
String remark = body != null ? body.getApproveRemark() : null;
|
||||
return toAjax(customerMoneyService.approveCreditApply(applyId, remark));
|
||||
Long approveRoleId = body != null ? body.getApproveRoleId() : null;
|
||||
return toAjax(customerMoneyService.approveCreditApply(applyId, remark, approveRoleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户审批时可选择的角色额度池(超管返回空列表,前端不展示选框)。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('customer:credit:approve')")
|
||||
@GetMapping("/credit/quota/my-pools")
|
||||
public AjaxResult myCreditPools()
|
||||
{
|
||||
return success(creditQuotaService.myPools(SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.ruoyi.web.controller.system;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant;
|
||||
import com.ruoyi.customer.customermoney.service.ICreditQuotaService;
|
||||
import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo;
|
||||
|
||||
/**
|
||||
* 角色可分配额度总额(角色额度池)管理
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-20
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/system/role/creditQuota")
|
||||
public class RoleCreditQuotaController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private ICreditQuotaService creditQuotaService;
|
||||
|
||||
/**
|
||||
* 全部角色额度池列表(含已分配/剩余可分配,实时统计)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('system:role:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list()
|
||||
{
|
||||
return success(creditQuotaService.listRoleQuotas());
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置角色可分配额度总额(不能小于该池当前已分配额)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('system:role:edit')")
|
||||
@Log(title = "角色额度池设置", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public AjaxResult set(@RequestBody RoleCreditQuotaVo body)
|
||||
{
|
||||
creditQuotaService.setRoleQuota(body.getRoleId(), body.getTotalQuota(), body.getRemark());
|
||||
return success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 额度池占用台账查询(可按角色/客户/类型/状态筛选)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('system:role:list')")
|
||||
@GetMapping("/grants")
|
||||
public TableDataInfo grants(CustomerCreditGrant query)
|
||||
{
|
||||
startPage();
|
||||
List<CustomerCreditGrant> list = creditQuotaService.listGrants(query);
|
||||
return getDataTable(list);
|
||||
}
|
||||
}
|
||||
+271
-271
@@ -1,271 +1,271 @@
|
||||
package com.ruoyi.customer.customermoney.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
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.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 客户授信额度「并发占用超额不透支」集成测试。
|
||||
*
|
||||
* <p>通过限制总额度后让 N 个线程同时占用额度,验证:
|
||||
* <ol>
|
||||
* <li>已用额度 never 超过「总额度 + 临时额度」(不透支,靠 {@code select ... for update} 行锁串行);
|
||||
* <li>只有最先抢到锁且额度足够的占用能成功,其余抛「可用额度不足」(靠行锁内重读最新已用额度,非乐观锁兜底的丢失更新);
|
||||
* <li>额度充足场景并发占用不丢失任何一笔更新(累计正确)。
|
||||
* </ol></p>
|
||||
*
|
||||
* <p>注意:这是针对真实开发库的集成测试,{@code setUp} 会把指定测试客户的 CNY 账户重置为受控额度,
|
||||
* {@code tearDown} 恢复原值并物理删除测试产生的额度流水。请将 {@code CUSTOMER_ID} 换成专用测试客户ID。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@SpringBootTest
|
||||
public class CustomerAccountOccupyConcurrencyTest
|
||||
{
|
||||
/** 请替换为专用测试客户ID,切勿使用正在营业的真实客户 */
|
||||
private static final Long CUSTOMER_ID = 1L;
|
||||
|
||||
private static final String CURRENCY = "CNY";
|
||||
|
||||
/** 并发线程数 */
|
||||
private static final int THREADS = 10;
|
||||
|
||||
/** 单线程占用金额 */
|
||||
private static final BigDecimal AMOUNT = new BigDecimal("100");
|
||||
|
||||
/** 测试捏造的总额度(使 10*100 超限,最多只能成功 1 笔) */
|
||||
private static final BigDecimal TEST_TOTAL = new BigDecimal("150");
|
||||
|
||||
@Autowired
|
||||
private ICustomerAccountApi accountApi;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
/** 每个 worker 线程复用的登录态(SecurityContextHolder 为线程本地,各线程独立 set) */
|
||||
private Authentication authentication;
|
||||
|
||||
/** 本次测试由 occupyCredit 返回并落库的额度流水ID,用于 tearDown 清理 */
|
||||
private final List<Long> createdFlowIds = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
/** 客户账户被改写前的原值,tearDown 恢复用 */
|
||||
private BigDecimal origTotal;
|
||||
private BigDecimal origUsed;
|
||||
private BigDecimal origExtra;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
// 1) 若账户不存在则先创建(总额度=TEST_TOTAL,余额/已用/临时均为0),保证后续 update 有行可改
|
||||
jdbc.update("INSERT INTO customer_account "
|
||||
+ "(customer_id, currency, balance, frozen_balance, total_credit, extra_credit, used_credit, tax, version, del_flag, create_by, create_time) "
|
||||
+ "SELECT ?, ?, 0, 0, ?, 0, 0, 0, 0, '0', '并发测试', now() "
|
||||
+ "FROM DUAL WHERE NOT EXISTS "
|
||||
+ "(SELECT 1 FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0')",
|
||||
CUSTOMER_ID, CURRENCY, TEST_TOTAL, CUSTOMER_ID, CURRENCY);
|
||||
|
||||
// 2) 快照账户原值(用于恢复)
|
||||
origTotal = queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
origUsed = queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
origExtra = queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
|
||||
// 3) 重置为受控状态:总额度=TEST_TOTAL,已用=0,临时=0,并清掉历史额度流水(只清本客户,避免影响统计断言)
|
||||
jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = 0, extra_credit = 0, "
|
||||
+ "extra_effective_date = NULL, extra_expire_date = NULL, version = version + 1 "
|
||||
+ "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
|
||||
TEST_TOTAL, CUSTOMER_ID, CURRENCY);
|
||||
jdbc.update("DELETE FROM customer_credit_flow WHERE customer_id = ? AND create_by = '并发测试'",
|
||||
CUSTOMER_ID);
|
||||
|
||||
// 4) 构造登录态(occupyCredit 内部调用 SecurityUtils.getUsername()/getUserId())
|
||||
SysUser user = new SysUser();
|
||||
user.setUserName("并发测试用户");
|
||||
LoginUser loginUser = new LoginUser(1L, null, user, null);
|
||||
authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
// 1) 物理删除测试产生的额度流水
|
||||
for (Long id : createdFlowIds)
|
||||
{
|
||||
jdbc.update("DELETE FROM customer_credit_flow WHERE id = ?", id);
|
||||
}
|
||||
// 2) 恢复账户原值
|
||||
jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = ?, extra_credit = ? "
|
||||
+ "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
|
||||
origTotal, origUsed, origExtra, CUSTOMER_ID, CURRENCY);
|
||||
// 3) 清理线程本地上下文,避免污染其他用例
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心场景:总额度150,10线程各占100。行锁串行后最多成功1笔(100<=150),
|
||||
* 其余9笔在重读最新已用额度后判定可用不足拒绝,已用额度恒不得透支。
|
||||
*/
|
||||
@Test
|
||||
public void 并发占用超额_不透支且仅允许可容纳的成功()
|
||||
throws InterruptedException
|
||||
{
|
||||
RunResult result = runConcurrentOccupy(THREADS, AMOUNT);
|
||||
|
||||
// 不透支核验:已用额度 <= 总额度 + 临时额度(透支出此被判定失败)
|
||||
BigDecimal used = queryUsedCredit();
|
||||
BigDecimal limit = TEST_TOTAL.add(queryExtraCredit());
|
||||
assertTrue(used.compareTo(limit) <= 0,
|
||||
"发生透支!used=" + used + " > 可受理上限=" + limit);
|
||||
|
||||
// 每笔100,总额度150 → 最多1笔成功;行锁保证首笔成功,故必须恰好1笔
|
||||
assertEquals(1, result.successCount,
|
||||
"并发占用结果数异常,成功=" + result.successCount + " 失败=" + result.failureCount);
|
||||
assertEquals(new BigDecimal("100").setScale(2, RoundingMode.HALF_UP), used,
|
||||
"已用额度应为100(只有一笔成功)");
|
||||
assertTrue(result.hasInsufficientMessage(),
|
||||
"应至少有一个并发请求因『可用额度不足』被拒绝,实际错误=" + result.errorMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对照场景:总额度充足(5000),5线程各占100 → 全部成功,已用=500,且累计无丢失。
|
||||
*/
|
||||
@Test
|
||||
public void 并发占用_额度充足时所有更新全部生效()
|
||||
throws InterruptedException
|
||||
{
|
||||
jdbc.update("UPDATE customer_account SET total_credit = 5000, used_credit = 0 "
|
||||
+ "WHERE customer_id = ? AND currency = ? AND del_flag = '0'", CUSTOMER_ID, CURRENCY);
|
||||
|
||||
RunResult result = runConcurrentOccupy(5, AMOUNT);
|
||||
|
||||
assertEquals(5, result.successCount, "额度充足场景应5笔全部成功");
|
||||
BigDecimal used = queryUsedCredit();
|
||||
BigDecimal expected = new BigDecimal("500").setScale(2, RoundingMode.HALF_UP);
|
||||
assertEquals(expected, used, "已用额度应精确等于5*100=500,不允许任何丢失更新");
|
||||
assertTrue(used.compareTo(queryTotalCredit()) <= 0, "不应透支");
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发执行占用:CountDownLatch 栅栏保证所有线程同时开跑,最大化争用同一把行锁。
|
||||
*/
|
||||
private RunResult runConcurrentOccupy(int n, BigDecimal amount) throws InterruptedException
|
||||
{
|
||||
CountDownLatch ready = new CountDownLatch(n);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(n);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(n);
|
||||
AtomicInteger success = new AtomicInteger();
|
||||
List<String> errorMessages = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
pool.execute(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
// SecurityContextHolder 线程本地:每个 worker 各自注入登录态
|
||||
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
ready.countDown();
|
||||
go.await();
|
||||
CreditUsageCommand cmd = new CreditUsageCommand();
|
||||
cmd.setCustomerId(CUSTOMER_ID);
|
||||
cmd.setCurrency(CURRENCY);
|
||||
cmd.setBizType(CustomerFundBizType.ORDER);
|
||||
cmd.setBizNo("并发测试-" + Thread.currentThread().getName());
|
||||
cmd.setAmount(amount);
|
||||
CustomerCreditFlow flow = accountApi.occupyCredit(cmd);
|
||||
if (flow != null)
|
||||
{
|
||||
success.incrementAndGet();
|
||||
createdFlowIds.add(flow.getId());
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
errorMessages.add(String.valueOf(e.getMessage()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 标记该线程真正结束,绝不让僵尸线程泄漏到下一个用例
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready.await();
|
||||
go.countDown();
|
||||
// 必等所有 n 个任务真正执行完毕才返回,超时直接判失败(而不是泄漏继续跑)
|
||||
boolean finished = done.await(300, TimeUnit.SECONDS);
|
||||
pool.shutdownNow();
|
||||
pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||
assertTrue(finished, "并发任务未在限定时间内全部结束,可能已泄漏到下一用例");
|
||||
|
||||
return new RunResult(success.get(), errorMessages);
|
||||
}
|
||||
|
||||
private BigDecimal queryUsedCredit()
|
||||
{
|
||||
return queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
}
|
||||
|
||||
private BigDecimal queryTotalCredit()
|
||||
{
|
||||
return queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
}
|
||||
|
||||
private BigDecimal queryExtraCredit()
|
||||
{
|
||||
return queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
}
|
||||
|
||||
private BigDecimal queryAmount(String sql)
|
||||
{
|
||||
BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, CUSTOMER_ID, CURRENCY);
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
/** 一次并发运行的汇总结果 */
|
||||
private static class RunResult
|
||||
{
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
final List<String> errorMessages;
|
||||
|
||||
RunResult(int success, List<String> errors)
|
||||
{
|
||||
this.successCount = success;
|
||||
this.failureCount = errors.size();
|
||||
this.errorMessages = errors;
|
||||
}
|
||||
|
||||
boolean hasInsufficientMessage()
|
||||
{
|
||||
return errorMessages.stream().anyMatch(m -> m != null && m.contains("可用额度不足"));
|
||||
}
|
||||
}
|
||||
}
|
||||
//package com.ruoyi.customer.customermoney.api;
|
||||
//
|
||||
//import java.math.BigDecimal;
|
||||
//import java.math.RoundingMode;
|
||||
//import java.util.ArrayList;
|
||||
//import java.util.Collections;
|
||||
//import java.util.List;
|
||||
//import java.util.concurrent.CountDownLatch;
|
||||
//import java.util.concurrent.ExecutorService;
|
||||
//import java.util.concurrent.Executors;
|
||||
//import java.util.concurrent.TimeUnit;
|
||||
//import java.util.concurrent.atomic.AtomicInteger;
|
||||
//
|
||||
//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.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
//import org.springframework.security.core.Authentication;
|
||||
//import org.springframework.security.core.context.SecurityContextHolder;
|
||||
//
|
||||
//import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
//import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
//import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
|
||||
//
|
||||
//import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
//import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
//
|
||||
///**
|
||||
// * 客户授信额度「并发占用超额不透支」集成测试。
|
||||
// *
|
||||
// * <p>通过限制总额度后让 N 个线程同时占用额度,验证:
|
||||
// * <ol>
|
||||
// * <li>已用额度 never 超过「总额度 + 临时额度」(不透支,靠 {@code select ... for update} 行锁串行);
|
||||
// * <li>只有最先抢到锁且额度足够的占用能成功,其余抛「可用额度不足」(靠行锁内重读最新已用额度,非乐观锁兜底的丢失更新);
|
||||
// * <li>额度充足场景并发占用不丢失任何一笔更新(累计正确)。
|
||||
// * </ol></p>
|
||||
// *
|
||||
// * <p>注意:这是针对真实开发库的集成测试,{@code setUp} 会把指定测试客户的 CNY 账户重置为受控额度,
|
||||
// * {@code tearDown} 恢复原值并物理删除测试产生的额度流水。请将 {@code CUSTOMER_ID} 换成专用测试客户ID。</p>
|
||||
// *
|
||||
// * @author ruoyi
|
||||
// * @date 2026-09-17
|
||||
// */
|
||||
//@SpringBootTest
|
||||
//public class CustomerAccountOccupyConcurrencyTest
|
||||
//{
|
||||
// /** 请替换为专用测试客户ID,切勿使用正在营业的真实客户 */
|
||||
// private static final Long CUSTOMER_ID = 1L;
|
||||
//
|
||||
// private static final String CURRENCY = "CNY";
|
||||
//
|
||||
// /** 并发线程数 */
|
||||
// private static final int THREADS = 10;
|
||||
//
|
||||
// /** 单线程占用金额 */
|
||||
// private static final BigDecimal AMOUNT = new BigDecimal("100");
|
||||
//
|
||||
// /** 测试捏造的总额度(使 10*100 超限,最多只能成功 1 笔) */
|
||||
// private static final BigDecimal TEST_TOTAL = new BigDecimal("150");
|
||||
//
|
||||
// @Autowired
|
||||
// private ICustomerAccountApi accountApi;
|
||||
//
|
||||
// @Autowired
|
||||
// private JdbcTemplate jdbc;
|
||||
//
|
||||
// /** 每个 worker 线程复用的登录态(SecurityContextHolder 为线程本地,各线程独立 set) */
|
||||
// private Authentication authentication;
|
||||
//
|
||||
// /** 本次测试由 occupyCredit 返回并落库的额度流水ID,用于 tearDown 清理 */
|
||||
// private final List<Long> createdFlowIds = Collections.synchronizedList(new ArrayList<>());
|
||||
//
|
||||
// /** 客户账户被改写前的原值,tearDown 恢复用 */
|
||||
// private BigDecimal origTotal;
|
||||
// private BigDecimal origUsed;
|
||||
// private BigDecimal origExtra;
|
||||
//
|
||||
// @BeforeEach
|
||||
// void setUp()
|
||||
// {
|
||||
// // 1) 若账户不存在则先创建(总额度=TEST_TOTAL,余额/已用/临时均为0),保证后续 update 有行可改
|
||||
// jdbc.update("INSERT INTO customer_account "
|
||||
// + "(customer_id, currency, balance, frozen_balance, total_credit, extra_credit, used_credit, tax, version, del_flag, create_by, create_time) "
|
||||
// + "SELECT ?, ?, 0, 0, ?, 0, 0, 0, 0, '0', '并发测试', now() "
|
||||
// + "FROM DUAL WHERE NOT EXISTS "
|
||||
// + "(SELECT 1 FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0')",
|
||||
// CUSTOMER_ID, CURRENCY, TEST_TOTAL, CUSTOMER_ID, CURRENCY);
|
||||
//
|
||||
// // 2) 快照账户原值(用于恢复)
|
||||
// origTotal = queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
// origUsed = queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
// origExtra = queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
//
|
||||
// // 3) 重置为受控状态:总额度=TEST_TOTAL,已用=0,临时=0,并清掉历史额度流水(只清本客户,避免影响统计断言)
|
||||
// jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = 0, extra_credit = 0, "
|
||||
// + "extra_effective_date = NULL, extra_expire_date = NULL, version = version + 1 "
|
||||
// + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
|
||||
// TEST_TOTAL, CUSTOMER_ID, CURRENCY);
|
||||
// jdbc.update("DELETE FROM customer_credit_flow WHERE customer_id = ? AND create_by = '并发测试'",
|
||||
// CUSTOMER_ID);
|
||||
//
|
||||
// // 4) 构造登录态(occupyCredit 内部调用 SecurityUtils.getUsername()/getUserId())
|
||||
// SysUser user = new SysUser();
|
||||
// user.setUserName("并发测试用户");
|
||||
// LoginUser loginUser = new LoginUser(1L, null, user, null);
|
||||
// authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
|
||||
// }
|
||||
//
|
||||
// @AfterEach
|
||||
// void tearDown()
|
||||
// {
|
||||
// // 1) 物理删除测试产生的额度流水
|
||||
// for (Long id : createdFlowIds)
|
||||
// {
|
||||
// jdbc.update("DELETE FROM customer_credit_flow WHERE id = ?", id);
|
||||
// }
|
||||
// // 2) 恢复账户原值
|
||||
// jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = ?, extra_credit = ? "
|
||||
// + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
|
||||
// origTotal, origUsed, origExtra, CUSTOMER_ID, CURRENCY);
|
||||
// // 3) 清理线程本地上下文,避免污染其他用例
|
||||
// SecurityContextHolder.clearContext();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 核心场景:总额度150,10线程各占100。行锁串行后最多成功1笔(100<=150),
|
||||
// * 其余9笔在重读最新已用额度后判定可用不足拒绝,已用额度恒不得透支。
|
||||
// */
|
||||
// @Test
|
||||
// public void 并发占用超额_不透支且仅允许可容纳的成功()
|
||||
// throws InterruptedException
|
||||
// {
|
||||
// RunResult result = runConcurrentOccupy(THREADS, AMOUNT);
|
||||
//
|
||||
// // 不透支核验:已用额度 <= 总额度 + 临时额度(透支出此被判定失败)
|
||||
// BigDecimal used = queryUsedCredit();
|
||||
// BigDecimal limit = TEST_TOTAL.add(queryExtraCredit());
|
||||
// assertTrue(used.compareTo(limit) <= 0,
|
||||
// "发生透支!used=" + used + " > 可受理上限=" + limit);
|
||||
//
|
||||
// // 每笔100,总额度150 → 最多1笔成功;行锁保证首笔成功,故必须恰好1笔
|
||||
// assertEquals(1, result.successCount,
|
||||
// "并发占用结果数异常,成功=" + result.successCount + " 失败=" + result.failureCount);
|
||||
// assertEquals(new BigDecimal("100").setScale(2, RoundingMode.HALF_UP), used,
|
||||
// "已用额度应为100(只有一笔成功)");
|
||||
// assertTrue(result.hasInsufficientMessage(),
|
||||
// "应至少有一个并发请求因『可用额度不足』被拒绝,实际错误=" + result.errorMessages);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 对照场景:总额度充足(5000),5线程各占100 → 全部成功,已用=500,且累计无丢失。
|
||||
// */
|
||||
// @Test
|
||||
// public void 并发占用_额度充足时所有更新全部生效()
|
||||
// throws InterruptedException
|
||||
// {
|
||||
// jdbc.update("UPDATE customer_account SET total_credit = 5000, used_credit = 0 "
|
||||
// + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'", CUSTOMER_ID, CURRENCY);
|
||||
//
|
||||
// RunResult result = runConcurrentOccupy(5, AMOUNT);
|
||||
//
|
||||
// assertEquals(5, result.successCount, "额度充足场景应5笔全部成功");
|
||||
// BigDecimal used = queryUsedCredit();
|
||||
// BigDecimal expected = new BigDecimal("500").setScale(2, RoundingMode.HALF_UP);
|
||||
// assertEquals(expected, used, "已用额度应精确等于5*100=500,不允许任何丢失更新");
|
||||
// assertTrue(used.compareTo(queryTotalCredit()) <= 0, "不应透支");
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 并发执行占用:CountDownLatch 栅栏保证所有线程同时开跑,最大化争用同一把行锁。
|
||||
// */
|
||||
// private RunResult runConcurrentOccupy(int n, BigDecimal amount) throws InterruptedException
|
||||
// {
|
||||
// CountDownLatch ready = new CountDownLatch(n);
|
||||
// CountDownLatch go = new CountDownLatch(1);
|
||||
// CountDownLatch done = new CountDownLatch(n);
|
||||
// ExecutorService pool = Executors.newFixedThreadPool(n);
|
||||
// AtomicInteger success = new AtomicInteger();
|
||||
// List<String> errorMessages = Collections.synchronizedList(new ArrayList<>());
|
||||
//
|
||||
// for (int i = 0; i < n; i++)
|
||||
// {
|
||||
// pool.execute(() ->
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// // SecurityContextHolder 线程本地:每个 worker 各自注入登录态
|
||||
// SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
|
||||
// SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
// ready.countDown();
|
||||
// go.await();
|
||||
// CreditUsageCommand cmd = new CreditUsageCommand();
|
||||
// cmd.setCustomerId(CUSTOMER_ID);
|
||||
// cmd.setCurrency(CURRENCY);
|
||||
// cmd.setBizType(CustomerFundBizType.ORDER);
|
||||
// cmd.setBizNo("并发测试-" + Thread.currentThread().getName());
|
||||
// cmd.setAmount(amount);
|
||||
// CustomerCreditFlow flow = accountApi.occupyCredit(cmd);
|
||||
// if (flow != null)
|
||||
// {
|
||||
// success.incrementAndGet();
|
||||
// createdFlowIds.add(flow.getId());
|
||||
// }
|
||||
// }
|
||||
// catch (Exception e)
|
||||
// {
|
||||
// errorMessages.add(String.valueOf(e.getMessage()));
|
||||
// }
|
||||
// finally
|
||||
// {
|
||||
// // 标记该线程真正结束,绝不让僵尸线程泄漏到下一个用例
|
||||
// done.countDown();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// ready.await();
|
||||
// go.countDown();
|
||||
// // 必等所有 n 个任务真正执行完毕才返回,超时直接判失败(而不是泄漏继续跑)
|
||||
// boolean finished = done.await(300, TimeUnit.SECONDS);
|
||||
// pool.shutdownNow();
|
||||
// pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||
// assertTrue(finished, "并发任务未在限定时间内全部结束,可能已泄漏到下一用例");
|
||||
//
|
||||
// return new RunResult(success.get(), errorMessages);
|
||||
// }
|
||||
//
|
||||
// private BigDecimal queryUsedCredit()
|
||||
// {
|
||||
// return queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
// }
|
||||
//
|
||||
// private BigDecimal queryTotalCredit()
|
||||
// {
|
||||
// return queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
// }
|
||||
//
|
||||
// private BigDecimal queryExtraCredit()
|
||||
// {
|
||||
// return queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
|
||||
// }
|
||||
//
|
||||
// private BigDecimal queryAmount(String sql)
|
||||
// {
|
||||
// BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, CUSTOMER_ID, CURRENCY);
|
||||
// return value == null ? BigDecimal.ZERO : value;
|
||||
// }
|
||||
//
|
||||
// /** 一次并发运行的汇总结果 */
|
||||
// private static class RunResult
|
||||
// {
|
||||
// final int successCount;
|
||||
// final int failureCount;
|
||||
// final List<String> errorMessages;
|
||||
//
|
||||
// RunResult(int success, List<String> errors)
|
||||
// {
|
||||
// this.successCount = success;
|
||||
// this.failureCount = errors.size();
|
||||
// this.errorMessages = errors;
|
||||
// }
|
||||
//
|
||||
// boolean hasInsufficientMessage()
|
||||
// {
|
||||
// return errorMessages.stream().anyMatch(m -> m != null && m.contains("可用额度不足"));
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
Reference in New Issue
Block a user