新加额度池配置功能

This commit is contained in:
luojiaquan
2026-09-20 10:26:30 +08:00
parent f529067ae3
commit d40adf1318
28 changed files with 1669 additions and 414 deletions
@@ -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()));
}
/**
@@ -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);
}
}
@@ -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("可用额度不足"));
// }
// }
//}
@@ -31,28 +31,29 @@ public interface ICustomerAccountApi
CustomerAccount getAccount(Long customerId, String currency);
/**
* 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理临时额度过期)。
* 调用方拿到后如需最新临时额度,可再调 {@link #expireTempCreditIfNeeded}。
* 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理授信过期)。
* 调用方拿到后如需最新额度,可再调 {@link #expireDueCreditsIfNeeded}。
*/
CustomerAccount lockCnyAccount(Long customerId);
/**
* 临时额度到期自动失效(额度清零 + 回写客户档案 + EXPIRE 额度流水)。
* 入参必须是同一事务内行锁查出的账户;未过期时不做任何操作。
* 授信(信用额度/临时额度)到期自动失效:过期项额度清零 + 释放角色额度池
* + 回写客户档案 + 写 EXPIRE 额度流水。
* 入参必须是同一事务内行锁查出的账户;均未过期时不做任何操作。
*/
void expireTempCreditIfNeeded(CustomerAccount account);
void expireDueCreditsIfNeeded(CustomerAccount account);
/**
* 扫描所有临时额度已到期(extra_credit&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效(行锁 -> 清零 -> 回写档案 -> EXPIRE流水)。
* 扫描所有授信已到期(信用或临时额度&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效(行锁 -> 清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水)。
* 单户失败仅记录日志不影响其他户;定时任务每日调用,应用启动后也会执行一次。
*
* @return 实际发生失效的账户数
*/
int expireAllDueTempCredits();
int expireAllDueCredits();
/**
* 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY,档案有值按档案),
* 新建客户时初始化默认CNY账户(余额0,信用额度默认0,档案有值按档案),
* 写 INIT 额度流水;CNY账户已存在时不重复创建。
*/
int initDefaultCnyAccount(Long customerId);
@@ -49,8 +49,8 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
/** 本位币币种 */
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
/** 新客户默认总额度(CNY) */
private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000");
/** 新客户默认总额度(CNY):默认0,信用额度一律通过「申请-审批」分配 */
private static final BigDecimal DEFAULT_CREDIT = BigDecimal.ZERO;
/** 金额保留位数 */
private static final int MONEY_SCALE = 2;
@@ -60,6 +60,10 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
/** 角色额度池台账:授信到期释放角色池 */
@Autowired
private com.ruoyi.customer.customermoney.service.ICreditQuotaService creditQuotaService;
@Autowired
private PlatformTransactionManager transactionManager;
@@ -113,16 +117,16 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
if (initCredit.compareTo(BigDecimal.ZERO) <= 0)
if (initCredit.compareTo(BigDecimal.ZERO) < 0)
{
initCredit = DEFAULT_CREDIT;
initCredit = BigDecimal.ZERO;
}
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
// 档案额度与实际初始额度保持一致
bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "新客户默认初始化额度1000 CNY");
initCredit, null, null, username, "新客户初始化账户,默认信用额度0(需通过额度申请审批分配)");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
@@ -132,9 +136,9 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
}
@Override
public int expireAllDueTempCredits()
public int expireAllDueCredits()
{
List<Long> customerIds = customerMoneyMapper.selectExpiredTempCreditCustomerIds();
List<Long> customerIds = customerMoneyMapper.selectExpiredCreditCustomerIds();
if (customerIds == null || customerIds.isEmpty())
{
return 0;
@@ -142,17 +146,18 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
int expired = 0;
for (Long customerId : customerIds)
{
// 每户独立事务:行锁 -> 失效清零 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户
// 每户独立事务:行锁 -> 失效清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户
try
{
Boolean changed = requireNewTxTemplate.execute(status -> {
CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (locked == null || !isTempCreditExpired(locked))
if (locked == null
|| (!isTempCreditExpired(locked) && !isCreditExpired(locked)))
{
// 可能被其他节点/入口抢先处理
return Boolean.FALSE;
}
expireTempCreditIfNeeded(locked);
expireDueCreditsIfNeeded(locked);
return Boolean.TRUE;
});
if (Boolean.TRUE.equals(changed))
@@ -162,25 +167,39 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
}
catch (Exception e)
{
log.error("客户{}临时额度到期自动失效失败", customerId, e);
log.error("客户{}授信到期自动失效失败", customerId, e);
}
}
log.info("临时额度到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired);
log.info("授信到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired);
return expired;
}
@Override
public void expireTempCreditIfNeeded(CustomerAccount account)
public void expireDueCreditsIfNeeded(CustomerAccount account)
{
if (account == null || !isTempCreditExpired(account))
if (account == null)
{
return;
}
boolean creditExpired = isCreditExpired(account);
boolean tempExpired = isTempCreditExpired(account);
if (!creditExpired && !tempExpired)
{
return;
}
// 定时任务场景无登录态,操作人回落为 system(人工触发的入口仍记录实际操作人)
String username = currentUsername();
Long userId = currentUserId();
BigDecimal before = nz(account.getExtraCredit());
account.setExtraCredit(BigDecimal.ZERO);
BigDecimal beforeCredit = nz(account.getTotalCredit());
BigDecimal beforeExtra = nz(account.getExtraCredit());
if (creditExpired)
{
account.setTotalCredit(BigDecimal.ZERO);
}
if (tempExpired)
{
account.setExtraCredit(BigDecimal.ZERO);
}
account.setUpdateBy(username);
int rows = customerMoneyMapper.updateAccountCredit(account);
if (rows == 0)
@@ -189,25 +208,49 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
}
account.setVersion(account.getVersion() + 1);
bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(),
nz(account.getTotalCredit()), BigDecimal.ZERO, username);
nz(account.getTotalCredit()), nz(account.getExtraCredit()), username);
// 释放对应角色额度池(无台账背书的历史额度释放金额为0,不影响池余额)
if (creditExpired)
{
creditQuotaService.releaseAll(account.getCustomerId(),
CustomerCreditFlow.CREDIT_TYPE_CREDIT, username);
writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, beforeCredit,
account.getCreditEffectiveDate(), account.getCreditExpireDate(),
username, userId, "信用额度到期自动失效");
}
if (tempExpired)
{
creditQuotaService.releaseAll(account.getCustomerId(),
CustomerCreditFlow.CREDIT_TYPE_TEMP, username);
writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, beforeExtra,
account.getExtraEffectiveDate(), account.getExtraExpireDate(),
username, userId, "临时额度到期自动失效");
}
}
/** 到期失效写 EXPIRE 额度流水 */
private void writeExpireFlow(CustomerAccount account, String creditType, BigDecimal before,
Date effectiveDate, Date expireDate,
String username, Long userId, String reason)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP);
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE);
flow.setDirection(-1);
flow.setBeforeAmount(before);
flow.setChangeAmount(before);
flow.setAfterAmount(BigDecimal.ZERO);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(account.getExtraEffectiveDate());
flow.setExpireDate(account.getExtraExpireDate());
flow.setReason("临时额度到期自动失效");
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason(reason);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零");
flow.setRemark("系统在访问账户或定时任务中检测到额度已过失效日期,自动置零");
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
@@ -473,7 +516,7 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
if (BASE_CURRENCY.equals(currency))
{
CustomerAccount account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
expireDueCreditsIfNeeded(account);
return account;
}
return customerMoneyMapper.selectAccountForUpdate(customerId, currency);
@@ -488,7 +531,7 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
if (BASE_CURRENCY.equals(currency))
{
account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
expireDueCreditsIfNeeded(account);
}
else
{
@@ -676,21 +719,24 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
BigDecimal rawExtra = account != null ? nz(account.getExtraCredit()) : BigDecimal.ZERO;
BigDecimal usedCredit = account != null ? nz(account.getUsedCredit()) : BigDecimal.ZERO;
// 临时额度已过期则视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库)
// 已过期的信用/临时额度视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库)
boolean creditExpiredPending = isCreditExpired(account);
boolean expiredPending = isTempCreditExpired(account);
BigDecimal effectiveTotal = creditExpiredPending ? BigDecimal.ZERO : totalCredit;
BigDecimal effectiveExtra = expiredPending ? BigDecimal.ZERO : rawExtra;
vo.setBalance(account != null ? nz(account.getBalance()) : BigDecimal.ZERO);
vo.setFrozenBalance(account != null ? nz(account.getFrozenBalance()) : BigDecimal.ZERO);
vo.setTotalCredit(totalCredit);
vo.setEffectiveTotalCredit(effectiveTotal);
vo.setExtraCredit(rawExtra);
vo.setEffectiveExtraCredit(effectiveExtra);
vo.setUsedCredit(usedCredit);
BigDecimal available = totalCredit.add(effectiveExtra).subtract(usedCredit);
BigDecimal available = effectiveTotal.add(effectiveExtra).subtract(usedCredit);
vo.setAvailableCredit(available);
boolean overdue = available.compareTo(BigDecimal.ZERO) < 0;
vo.setOverdue(overdue);
vo.setExpiredTempCreditPending(expiredPending);
vo.setExpiredTempCreditPending(expiredPending || creditExpiredPending);
boolean disabled = "1".equals(cust.getIsEnable());
if (disabled && overdue)
@@ -779,6 +825,16 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
.atZone(java.time.ZoneId.systemDefault()).toLocalDate());
}
/** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */
private boolean isCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getCreditExpireDate() != null
&& LocalDate.now().isAfter(account.getCreditExpireDate().toInstant()
.atZone(java.time.ZoneId.systemDefault()).toLocalDate());
}
private String resolveCurrency(String currency)
{
return StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
@@ -34,9 +34,17 @@ public class CustomerAccount extends BaseEntity
/** 冻结余额 */
private BigDecimal frozenBalance;
/** 总额度 */
/** 总额度(信用额度,默认0;配置有效期后到期次日自动清零) */
private BigDecimal totalCredit;
/** 信用额度生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date creditEffectiveDate;
/** 信用额度失效日期(空=长期;到期次日自动清零) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date creditExpireDate;
/** 额外额度(临时额度) */
private BigDecimal extraCredit;
@@ -79,6 +79,9 @@ public class CustomerCreditApply extends BaseEntity
/** 审批人ID */
private Long approveUserId;
/** 审批占用的额度池角色ID(NULL=超管审批不占池;调减/到期释放时不消费池) */
private Long approveRoleId;
/** 审批时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date approveTime;
@@ -99,4 +102,7 @@ public class CustomerCreditApply extends BaseEntity
/** 客户名称 */
private String customerName;
/** 额度池角色名称(关联展示) */
private String approveRoleName;
}
@@ -101,6 +101,9 @@ public class CustomerCreditFlow extends BaseEntity
/** 操作人ID */
private Long operatorId;
/** 额度池角色ID(ADJUST 审批调整时记录本次占用/释放的角色池;NULL=超管/系统) */
private Long roleId;
/** 删除标志(0存在 1删除) */
private String delFlag;
}
@@ -0,0 +1,90 @@
package com.ruoyi.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户授信额度分配台账 customer_credit_grant
* 审批通过的净增额度占一行(占用角色额度池);调减/到期按 FIFO 核销行余额。
* 角色池已用 = SUM(remaining WHERE role_id=? AND status='0')
*
* @author ruoyi
* @date 2026-09-20
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerCreditGrant extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 占用中 */
public static final String STATUS_ACTIVE = "0";
/** 已释放 */
public static final String STATUS_RELEASED = "1";
/** 台账ID */
private Long grantId;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 占用的额度池角色ID(NULL=超管审批/不占池) */
private Long roleId;
/** 审批人ID */
private Long approveUserId;
/** 额度申请单ID */
private Long applyId;
/** 额度流水ID */
private Long flowId;
/** 额度类型:CREDIT信用额度 TEMP临时额度 */
private String creditType;
/** 原始占用池金额 */
private BigDecimal amount;
/** 仍占用池的待释放余额 */
private BigDecimal remaining;
/** 生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
/** 失效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date expireDate;
/** 状态:0占用中 1已释放 */
private String status;
/** 全部释放时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date releaseTime;
/** 删除标志 */
private String delFlag;
/* -------- 展示字段 -------- */
/** 客户编码 */
private String customerCode;
/** 客户名称 */
private String customerName;
/** 角色名称 */
private String roleName;
/** 审批人用户名 */
private String approveBy;
}
@@ -0,0 +1,29 @@
package com.ruoyi.customer.customermoney.domain;
import java.math.BigDecimal;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色可分配授信额度池 sys_role_credit_quota
* 一个角色一行;没有记录视为额度0(该角色用户不能审批任何增量授信)
*
* @author ruoyi
* @date 2026-09-20
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SysRoleCreditQuota extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 角色ID */
private Long roleId;
/** 可分配额度总额(CNY) */
private BigDecimal totalQuota;
/** 乐观锁版本号 */
private Integer version;
}
@@ -0,0 +1,53 @@
package com.ruoyi.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant;
import com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota;
import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo;
import java.math.BigDecimal;
/**
* 角色额度池 / 授信分配台账 Mapper
*
* @author ruoyi
* @date 2026-09-20
*/
public interface CreditQuotaMapper
{
/* ================= 角色额度池 ================= */
/** 全部角色的额度池(左连接,未配置额度的角色总额度按0返回;used 由台账 remaining 实时求和) */
List<RoleCreditQuotaVo> selectRoleQuotaList();
/** 指定用户名下正常角色的额度池(sys_user_role 关联) */
List<RoleCreditQuotaVo> selectRoleQuotaListByUserId(@Param("userId") Long userId);
/** 行锁取额度池配置(不存在返回null) */
SysRoleCreditQuota selectQuotaForUpdate(@Param("roleId") Long roleId);
/** 角色池已分配额(占用中台账 remaining 合计;调用前需先持有该池行锁) */
BigDecimal sumActiveRemaining(@Param("roleId") Long roleId);
/** 新建额度池配置 */
int insertQuota(SysRoleCreditQuota quota);
/** 修改额度池总额度(乐观锁) */
int updateQuota(SysRoleCreditQuota quota);
/* ================= 授信分配台账 ================= */
/** 台账查询(带客户名/角色名) */
List<CustomerCreditGrant> selectGrantList(CustomerCreditGrant query);
/** 行锁取某客户某额度类型的占用中台账(FIFO:按 grant_id 升序) */
List<CustomerCreditGrant> selectActiveGrantsForUpdate(@Param("customerId") Long customerId,
@Param("creditType") String creditType);
/** 新增台账 */
int insertGrant(CustomerCreditGrant grant);
/** 更新台账剩余余额/状态(乐观条件 remaining 不变,防并发核销) */
int updateGrantRemaining(CustomerCreditGrant grant);
}
@@ -29,10 +29,10 @@ public interface CustomerMoneyMapper
List<CustomerAccount> selectAccountListByCustomerId(@Param("customerId") Long customerId);
/**
* 扫描临时额度已到期的CNY账户客户ID(extra_credit>0 且失效日期早于今天;失效日当天仍有效)。
* 扫描授信已到期的CNY账户客户ID(信用或临时额度失效日早于今天;失效日当天仍有效)。
* 供定时任务批量物化失效使用,结果集不加锁,逐户处理时再行锁。
*/
List<Long> selectExpiredTempCreditCustomerIds();
List<Long> selectExpiredCreditCustomerIds();
/** 新增账户 */
int insertAccount(CustomerAccount account);
@@ -0,0 +1,50 @@
package com.ruoyi.customer.customermoney.service;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant;
import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo;
/**
* 角色额度池 Service
*
* @author ruoyi
* @date 2026-09-20
*/
public interface ICreditQuotaService
{
/** 全部角色额度池(管理员配置用) */
List<RoleCreditQuotaVo> listRoleQuotas();
/** 当前用户可使用的角色额度池(审批选池用;超管返回空列表由调用方放行) */
List<RoleCreditQuotaVo> myPools(Long userId);
/** 设置角色可分配额度总额(不能小于该池已分配额) */
void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark);
/** 额度池占用台账查询 */
List<CustomerCreditGrant> listGrants(CustomerCreditGrant query);
/**
* 审批净增额度:占用角色额度池并写台账。
* roleId 为 null(超管审批)时台账角色为空、不占用任何池。
* 必须在客户账户行锁之后调用;内部对角色池加行锁防并发超配。
*/
void consume(CustomerAccount account, String creditType, BigDecimal amount,
Date effectiveDate, Date expireDate, Long roleId,
Long applyId, Long flowId, String username, Long userId);
/**
* 审批调减:按 FIFO 核销该客户该类型的占用台账,释放对应角色池。
* 历史无台账背书的额度(建档直配/超管授予)核销时不释放池。
* 返回实际从池中释放的金额(可能小于 amount)。
*/
BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username);
/**
* 到期自动失效:释放该客户该类型全部占用台账,返回释放总额。
*/
BigDecimal releaseAll(Long customerId, String creditType, String username);
}
@@ -19,7 +19,7 @@ public interface ICustomerMoneyService
List<CustomerAccount> selectAccountList(Long customerId);
/**
* 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY),并写 INIT 额度流水。
* 新建客户时初始化默认CNY账户(余额0,信用额度默认0),并写 INIT 额度流水。
* 已存在CNY账户时不重复创建。
*
* @param customerId 客户ID
@@ -60,8 +60,9 @@ public interface ICustomerMoneyService
/**
* 审批通过额度调整申请:PENDING -> APPROVED。
* 事务内重新锁定CNY账户并按审批时的实际额度计算/校验,通过后调整额度、回写客户档案、写额度流水。
* 净增额度需占用 approveRoleId 对应角色额度池(超管传 null 不占池;净减/到期释放不占池)。
*/
int approveCreditApply(Long applyId, String approveRemark);
int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId);
/** 审批驳回:PENDING -> REJECTED,驳回原因必填 */
int rejectCreditApply(Long applyId, String rejectReason);
@@ -0,0 +1,290 @@
package com.ruoyi.customer.customermoney.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant;
import com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota;
import com.ruoyi.customer.customermoney.mapper.CreditQuotaMapper;
import com.ruoyi.customer.customermoney.service.ICreditQuotaService;
import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo;
import com.ruoyi.system.mapper.SysRoleMapper;
/**
* 角色额度池 Service实现
*
* @author ruoyi
* @date 2026-09-20
*/
@Service
public class CreditQuotaServiceImpl implements ICreditQuotaService
{
private static final int MONEY_SCALE = 2;
@Autowired
private CreditQuotaMapper creditQuotaMapper;
@Autowired
private SysRoleMapper sysRoleMapper;
@Override
public List<RoleCreditQuotaVo> listRoleQuotas()
{
List<RoleCreditQuotaVo> list = creditQuotaMapper.selectRoleQuotaList();
list.forEach(this::fillAvailable);
return list;
}
@Override
public List<RoleCreditQuotaVo> myPools(Long userId)
{
if (userId == null)
{
throw new ServiceException("无法获取当前登录用户");
}
List<RoleCreditQuotaVo> list = creditQuotaMapper.selectRoleQuotaListByUserId(userId);
list.forEach(this::fillAvailable);
return list;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark)
{
if (roleId == null)
{
throw new ServiceException("角色ID不能为空");
}
SysRole role = sysRoleMapper.selectRoleById(roleId);
if (role == null || "2".equals(role.getDelFlag()))
{
throw new ServiceException("角色不存在或已删除");
}
BigDecimal total = nz(totalQuota).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (total.compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("可分配额度总额不能为负数");
}
String username = SecurityUtils.getUsername();
SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId);
if (quota == null)
{
SysRoleCreditQuota newQuota = new SysRoleCreditQuota();
newQuota.setRoleId(roleId);
newQuota.setTotalQuota(total);
newQuota.setVersion(0);
newQuota.setCreateBy(username);
newQuota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
creditQuotaMapper.insertQuota(newQuota);
return;
}
BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId));
if (total.compareTo(used) < 0)
{
throw new ServiceException("可分配额度总额不能小于该角色已分配额度:" + used.toPlainString() + " CNY");
}
quota.setTotalQuota(total);
quota.setUpdateBy(username);
quota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
if (creditQuotaMapper.updateQuota(quota) == 0)
{
throw new ServiceException("额度池已被其他操作变动,请刷新后重试");
}
}
@Override
public List<CustomerCreditGrant> listGrants(CustomerCreditGrant query)
{
return creditQuotaMapper.selectGrantList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void consume(CustomerAccount account, String creditType, BigDecimal amount,
Date effectiveDate, Date expireDate, Long roleId,
Long applyId, Long flowId, String username, Long userId)
{
if (account == null || account.getId() == null)
{
throw new ServiceException("客户账户不存在,无法分配额度");
}
BigDecimal delta = nz(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (delta.compareTo(BigDecimal.ZERO) <= 0)
{
return;
}
CustomerCreditGrant grant = new CustomerCreditGrant();
grant.setCustomerId(account.getCustomerId());
grant.setAccountId(account.getId());
grant.setApproveUserId(userId);
grant.setApplyId(applyId);
grant.setFlowId(flowId);
grant.setCreditType(creditType);
grant.setAmount(delta);
grant.setRemaining(delta);
grant.setEffectiveDate(effectiveDate);
grant.setExpireDate(expireDate);
grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE);
grant.setCreateBy(username);
if (roleId == null)
{
// 超管审批:不占用任何角色池
creditQuotaMapper.insertGrant(grant);
return;
}
// 校验角色归属与状态
SysRole role = sysRoleMapper.selectRoleById(roleId);
if (role == null || "2".equals(role.getDelFlag()) || !"0".equals(role.getStatus()))
{
throw new ServiceException("额度池角色不存在或已停用");
}
List<Long> myRoleIds = sysRoleMapper.selectRoleListByUserId(userId)
.stream().filter(id -> id != null && id.equals(roleId)).collect(Collectors.toList());
if (myRoleIds.isEmpty())
{
throw new ServiceException("不能使用不属于自己的角色额度池");
}
// 池行锁 -> 已分配实时汇总 -> 容量校验,防并发超配
SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId);
if (quota == null)
{
throw new ServiceException("角色【" + role.getRoleName() + "】未配置可分配额度总额,请联系管理员");
}
BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId));
BigDecimal available = nz(quota.getTotalQuota()).subtract(used);
if (available.compareTo(delta) < 0)
{
throw new ServiceException("角色【" + role.getRoleName() + "】额度池不足:总额度 "
+ nz(quota.getTotalQuota()).toPlainString() + ",已分配 " + used.toPlainString()
+ ",剩余 " + available.toPlainString() + ",本次需分配 " + delta.toPlainString());
}
grant.setRoleId(roleId);
creditQuotaMapper.insertGrant(grant);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username)
{
BigDecimal need = nz(amount);
if (need.compareTo(BigDecimal.ZERO) <= 0)
{
return BigDecimal.ZERO;
}
List<CustomerCreditGrant> grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType);
if (grants.isEmpty())
{
return BigDecimal.ZERO;
}
lockQuotaRows(grants);
BigDecimal released = BigDecimal.ZERO;
Date now = new Date();
for (CustomerCreditGrant grant : grants)
{
if (need.compareTo(BigDecimal.ZERO) <= 0)
{
break;
}
BigDecimal remaining = nz(grant.getRemaining());
if (remaining.compareTo(BigDecimal.ZERO) <= 0)
{
continue;
}
BigDecimal take = remaining.min(need);
BigDecimal after = remaining.subtract(take).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
grant.setRemaining(after);
grant.setUpdateBy(username);
if (after.compareTo(BigDecimal.ZERO) == 0)
{
grant.setStatus(CustomerCreditGrant.STATUS_RELEASED);
grant.setReleaseTime(now);
}
else
{
grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE);
grant.setReleaseTime(null);
}
creditQuotaMapper.updateGrantRemaining(grant);
need = need.subtract(take);
released = released.add(take);
}
return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BigDecimal releaseAll(Long customerId, String creditType, String username)
{
List<CustomerCreditGrant> grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType);
if (grants.isEmpty())
{
return BigDecimal.ZERO;
}
lockQuotaRows(grants);
BigDecimal released = BigDecimal.ZERO;
Date now = new Date();
for (CustomerCreditGrant grant : grants)
{
BigDecimal remaining = nz(grant.getRemaining());
if (remaining.compareTo(BigDecimal.ZERO) <= 0)
{
continue;
}
grant.setRemaining(BigDecimal.ZERO);
grant.setStatus(CustomerCreditGrant.STATUS_RELEASED);
grant.setReleaseTime(now);
grant.setUpdateBy(username);
creditQuotaMapper.updateGrantRemaining(grant);
released = released.add(remaining);
}
return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
/**
* 对台账涉及的非空角色池按 role_id 升序加行锁,
* 保证与池占用(consume)互斥且避免多角色交叉死锁。
*/
private void lockQuotaRows(List<CustomerCreditGrant> grants)
{
List<Long> roleIds = grants.stream()
.map(CustomerCreditGrant::getRoleId)
.filter(id -> id != null)
.distinct()
.sorted(Comparator.naturalOrder())
.collect(Collectors.toList());
for (Long roleId : roleIds)
{
// 池配置理论上一定存在(占用时已校验);若被误删则空过,不影响台账核销
creditQuotaMapper.selectQuotaForUpdate(roleId);
}
}
private void fillAvailable(RoleCreditQuotaVo vo)
{
BigDecimal total = nz(vo.getTotalQuota());
BigDecimal used = nz(vo.getUsedQuota());
vo.setTotalQuota(total);
vo.setUsedQuota(used);
vo.setAvailableQuota(total.subtract(used).setScale(MONEY_SCALE, RoundingMode.HALF_UP));
}
private BigDecimal nz(BigDecimal value)
{
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -28,6 +28,7 @@ import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
import com.ruoyi.customer.customermoney.mapper.CustomerCreditApplyMapper;
import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper;
import com.ruoyi.customer.customermoney.service.ICreditQuotaService;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
import com.ruoyi.system.domain.BmsExchangeRate;
import com.ruoyi.system.service.IBmsExchangeRateService;
@@ -64,19 +65,24 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
@Autowired
private ICustomerAccountApi customerAccountApi;
/** 角色额度池:审批增量占用/调减与到期释放 */
@Autowired
private ICreditQuotaService creditQuotaService;
@Override
@Transactional(rollbackFor = Exception.class)
public List<CustomerAccount> selectAccountList(Long customerId)
{
List<CustomerAccount> list = customerMoneyMapper.selectAccountListByCustomerId(customerId);
// 查询时顺带处理临时额度到期自动失效(仅CNY账户),保证卡片展示为最新值
// 查询时顺带处理授信到期自动失效(仅CNY账户),保证卡片展示为最新值
for (int i = 0; i < list.size(); i++)
{
CustomerAccount account = list.get(i);
if (BASE_CURRENCY.equals(account.getCurrency()) && isTempCreditExpired(account))
if (BASE_CURRENCY.equals(account.getCurrency())
&& (isTempCreditExpired(account) || isCreditExpired(account)))
{
CustomerAccount locked = customerAccountApi.lockCnyAccount(customerId);
customerAccountApi.expireTempCreditIfNeeded(locked);
customerAccountApi.expireDueCreditsIfNeeded(locked);
if (locked != null)
{
list.set(i, locked);
@@ -331,10 +337,11 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
/**
* 执行额度调整(额度申请审批通过后调用)。
* 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水。
* 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水 -> 角色额度池占用/释放。
* roleId 为本次审批选择的额度池角色(超管/净减时可为null);applyId 为审批申请单ID。
* 返回已落库的额度流水(含ID)。
*/
private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd)
private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd, Long roleId, Long applyId)
{
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
@@ -365,18 +372,18 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
}
after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 3. 临时额度有效期处理(不允许预约未来生效,失效日必须晚于生效日)
// 3. 有效期处理:信用/临时同口径——不允许预约未来生效;信用额度失效日期必填,临时额度空=长期
Date effectiveDate = null;
Date expireDate = null;
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
effectiveDate = toSqlDate(eff);
if (cmd.getExpireDate() != null)
{
expireDate = toSqlDate(toLocalDate(cmd.getExpireDate()));
}
if (isTemp)
{
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
effectiveDate = toSqlDate(eff);
if (cmd.getExpireDate() != null)
{
expireDate = toSqlDate(toLocalDate(cmd.getExpireDate()));
}
account.setExtraCredit(after);
account.setExtraEffectiveDate(effectiveDate);
account.setExtraExpireDate(expireDate);
@@ -384,6 +391,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
else
{
account.setTotalCredit(after);
account.setCreditEffectiveDate(effectiveDate);
account.setCreditExpireDate(expireDate);
}
account.setUpdateBy(username);
@@ -417,10 +426,29 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
flow.setReason(reason);
flow.setAdjustMode(adjustMode);
flow.setOperatorId(userId);
// 非超管审批且未指定角色池的净增申请不允许通过(在 consume 中强校验)
flow.setRoleId(SecurityUtils.isAdmin(userId) ? null : roleId);
flow.setCreateBy(username);
flow.setRemark(cmd.getRemark());
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
// 7. 角色额度池占用/释放(账户行锁已持有;池锁在台账服务内部按角色加锁)
BigDecimal netDelta = after.subtract(before);
if (netDelta.compareTo(BigDecimal.ZERO) > 0)
{
Long poolRoleId = SecurityUtils.isAdmin(userId) ? null : roleId;
if (poolRoleId == null && !SecurityUtils.isAdmin(userId))
{
throw new ServiceException("请选择本次额度分配占用的角色额度池");
}
creditQuotaService.consume(account, creditType, netDelta,
effectiveDate, expireDate, poolRoleId, applyId, flow.getId(), username, userId);
}
else if (netDelta.compareTo(BigDecimal.ZERO) < 0)
{
creditQuotaService.releaseFifo(customerId, creditType, netDelta.negate(), username);
}
return flow;
}
@@ -477,7 +505,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
@Override
@Transactional(rollbackFor = Exception.class)
public int approveCreditApply(Long applyId, String approveRemark)
public int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId)
{
if (applyId == null)
{
@@ -505,7 +533,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
cmd.setRemark(apply.getRemark());
cmd.setEffectiveDate(apply.getEffectiveDate());
cmd.setExpireDate(apply.getExpireDate());
CustomerCreditFlow flow = executeAdjustCredit(cmd);
CustomerCreditFlow flow = executeAdjustCredit(cmd, approveRoleId, applyId);
CustomerCreditApply update = new CustomerCreditApply();
update.setApplyId(applyId);
@@ -513,6 +541,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
update.setFlowId(flow.getId());
update.setApproveBy(username);
update.setApproveUserId(userId);
// 超管审批不占池;非超管净减时 roleId 为 null 也正常(只释放不占用)
update.setApproveRoleId(SecurityUtils.isAdmin(userId) ? null : approveRoleId);
update.setApproveRemark(approveRemark);
int rows = creditApplyMapper.updateApproved(update);
if (rows == 0)
@@ -628,7 +658,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType);
CustomerAccount account = customerAccountApi.lockCnyAccount(cmd.getCustomerId());
customerAccountApi.expireTempCreditIfNeeded(account);
customerAccountApi.expireDueCreditsIfNeeded(account);
BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit());
BigDecimal otherCredit = isTemp ? nz(account.getTotalCredit()) : nz(account.getExtraCredit());
@@ -660,21 +690,23 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
throw new ServiceException("额度未发生变化,无需调整");
}
if (isTemp)
if (!isTemp && cmd.getExpireDate() == null)
{
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
if (eff.isAfter(today))
// 信用额度必须设置失效日期(到期自动清零);临时额度不填=长期
throw new ServiceException("请选择信用额度失效日期");
}
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
if (eff.isAfter(today))
{
throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再提交");
}
if (cmd.getExpireDate() != null)
{
LocalDate exp = toLocalDate(cmd.getExpireDate());
if (!exp.isAfter(eff))
{
throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再提交");
}
if (cmd.getExpireDate() != null)
{
LocalDate exp = toLocalDate(cmd.getExpireDate());
if (!exp.isAfter(eff))
{
throw new ServiceException("失效日期必须晚于生效日期");
}
throw new ServiceException("失效日期必须晚于生效日期");
}
}
return account;
@@ -701,6 +733,15 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
&& LocalDate.now().isAfter(toLocalDate(account.getExtraExpireDate()));
}
/** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */
private boolean isCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getCreditExpireDate() != null
&& LocalDate.now().isAfter(toLocalDate(account.getCreditExpireDate()));
}
private LocalDate toLocalDate(Date date)
{
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
@@ -10,11 +10,11 @@ import org.springframework.stereotype.Component;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
/**
* 客户临时额度到期自动失效任务。
* 客户授信(信用额度/临时额度)到期自动失效任务。
*
* <p>每天 01:30 扫描临时额度已到期(extra_credit&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效:额度清零 -> 回写客户档案 -> 写 EXPIRE 额度流水。
* 失效日当天临时额度仍有效,故严格按「早于今天」判断。</p>
* <p>每天 01:30 扫描授信已到期(信用或临时额度&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效:额度清零 -> 释放角色额度池 -> 回写客户档案 -> 写 EXPIRE 额度流水。
* 失效日当天额度仍有效,故严格按「早于今天」判断。</p>
*
* <p>除惰性触发(访问资金接口时即查即失效)外,本任务保证展示口径一致:
* 客户列表可用额度是账户表现算,不经过惰性入口,需由本任务把到期状态落库。
@@ -51,12 +51,12 @@ public class TempCreditExpireTask implements ApplicationRunner
{
try
{
int count = customerAccountApi.expireAllDueTempCredits();
log.info("客户临时额度到期失效任务执行完成,失效账户数={}", count);
int count = customerAccountApi.expireAllDueCredits();
log.info("客户授信到期失效任务执行完成,失效账户数={}", count);
}
catch (Exception e)
{
log.error("客户临时额度到期失效任务执行异常", e);
log.error("客户授信到期失效任务执行异常", e);
}
}
}
@@ -44,6 +44,8 @@ public class CustomerAccountStatusVO implements Serializable
private BigDecimal frozenBalance;
/** 授信总额度(CNY) */
private BigDecimal totalCredit;
/** 授信总额度实际有效值(过期未物化时为0,接口计算得出,不物化DB) */
private BigDecimal effectiveTotalCredit;
/** 临时额度原值(CNY,DB 中 extra_credit 字段) */
private BigDecimal extraCredit;
/** 临时额度实际有效值(过期时为0,接口计算得出,不物化DB) */
@@ -78,6 +80,8 @@ public class CustomerAccountStatusVO implements Serializable
public void setFrozenBalance(BigDecimal frozenBalance) { this.frozenBalance = frozenBalance; }
public BigDecimal getTotalCredit() { return totalCredit; }
public void setTotalCredit(BigDecimal totalCredit) { this.totalCredit = totalCredit; }
public BigDecimal getEffectiveTotalCredit() { return effectiveTotalCredit; }
public void setEffectiveTotalCredit(BigDecimal effectiveTotalCredit) { this.effectiveTotalCredit = effectiveTotalCredit; }
public BigDecimal getExtraCredit() { return extraCredit; }
public void setExtraCredit(BigDecimal extraCredit) { this.extraCredit = extraCredit; }
public BigDecimal getEffectiveExtraCredit() { return effectiveExtraCredit; }
@@ -0,0 +1,38 @@
package com.ruoyi.customer.customermoney.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 角色额度池视图(角色管理/审批选池用)
*
* @author ruoyi
* @date 2026-09-20
*/
@Data
public class RoleCreditQuotaVo
{
/** 角色ID */
private Long roleId;
/** 角色名称 */
private String roleName;
/** 权限字符 */
private String roleKey;
/** 角色状态(0正常 1停用) */
private String status;
/** 可分配额度总额;未配置记录时为0 */
private BigDecimal totalQuota;
/** 已分配(占用中台账余额合计) */
private BigDecimal usedQuota;
/** 剩余可分配 */
private BigDecimal availableQuota;
/** 备注 */
private String remark;
}
@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.customer.customermoney.mapper.CreditQuotaMapper">
<!-- ================= 角色额度池 ================= -->
<select id="selectRoleQuotaList" resultType="com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo">
select r.role_id as roleId,
r.role_name as roleName,
r.role_key as roleKey,
r.status as status,
coalesce(q.total_quota, 0) as totalQuota,
(select coalesce(sum(g.remaining), 0)
from customer_credit_grant g
where g.role_id = r.role_id and g.status = '0' and g.del_flag = '0') as usedQuota,
q.remark as remark
from sys_role r
left join sys_role_credit_quota q on q.role_id = r.role_id
where r.del_flag = '0'
order by r.role_sort, r.role_id
</select>
<select id="selectRoleQuotaListByUserId"
resultType="com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo">
select r.role_id as roleId,
r.role_name as roleName,
r.role_key as roleKey,
r.status as status,
coalesce(q.total_quota, 0) as totalQuota,
(select coalesce(sum(g.remaining), 0)
from customer_credit_grant g
where g.role_id = r.role_id and g.status = '0' and g.del_flag = '0') as usedQuota,
q.remark as remark
from sys_user_role ur
inner join sys_role r on r.role_id = ur.role_id and r.del_flag = '0' and r.status = '0'
left join sys_role_credit_quota q on q.role_id = r.role_id
where ur.user_id = #{userId}
order by r.role_sort, r.role_id
</select>
<select id="selectQuotaForUpdate"
resultType="com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota">
select role_id as roleId, total_quota as totalQuota, version, remark
from sys_role_credit_quota
where role_id = #{roleId}
for update
</select>
<select id="sumActiveRemaining" resultType="java.math.BigDecimal">
select coalesce(sum(remaining), 0)
from customer_credit_grant
where role_id = #{roleId} and status = '0' and del_flag = '0'
</select>
<insert id="insertQuota" parameterType="com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota">
insert into sys_role_credit_quota (role_id, total_quota, version, create_by, create_time, remark)
values (#{roleId}, #{totalQuota}, 0, #{createBy}, sysdate(), #{remark})
on duplicate key update total_quota = values(total_quota),
remark = values(remark),
update_by = values(create_by),
update_time = now()
</insert>
<update id="updateQuota" parameterType="com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota">
update sys_role_credit_quota
set total_quota = #{totalQuota},
version = version + 1,
update_by = #{updateBy},
update_time = now(),
remark = #{remark}
where role_id = #{roleId} and version = #{version}
</update>
<!-- ================= 授信分配台账 ================= -->
<resultMap type="com.ruoyi.customer.customermoney.domain.CustomerCreditGrant" id="CustomerCreditGrantResult">
<id property="grantId" column="grant_id" />
<result property="customerId" column="customer_id" />
<result property="accountId" column="account_id" />
<result property="roleId" column="role_id" />
<result property="approveUserId" column="approve_user_id" />
<result property="applyId" column="apply_id" />
<result property="flowId" column="flow_id" />
<result property="creditType" column="credit_type" />
<result property="amount" column="amount" />
<result property="remaining" column="remaining" />
<result property="effectiveDate" column="effective_date" />
<result property="expireDate" column="expire_date" />
<result property="status" column="status" />
<result property="releaseTime" column="release_time" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="delFlag" column="del_flag" />
<result property="customerCode" column="customer_code" />
<result property="customerName" column="customer_name" />
<result property="roleName" column="role_name" />
<result property="approveBy" column="approve_by" />
</resultMap>
<sql id="grantVo">
select g.grant_id, g.customer_id, g.account_id, g.role_id, g.approve_user_id, g.apply_id, g.flow_id,
g.credit_type, g.amount, g.remaining, g.effective_date, g.expire_date, g.status, g.release_time,
g.create_by, g.create_time, g.update_by, g.update_time, g.remark, g.del_flag,
c.customer_code, c.customer_name,
r.role_name,
u.user_name as approve_by
from customer_credit_grant g
left join bms_customer c on c.customer_id = g.customer_id
left join sys_role r on r.role_id = g.role_id
left join sys_user u on u.user_id = g.approve_user_id
</sql>
<select id="selectGrantList"
parameterType="com.ruoyi.customer.customermoney.domain.CustomerCreditGrant"
resultMap="CustomerCreditGrantResult">
<include refid="grantVo"/>
<where>
g.del_flag = '0'
<if test="customerId != null"> and g.customer_id = #{customerId}</if>
<if test="roleId != null"> and g.role_id = #{roleId}</if>
<if test="creditType != null and creditType != ''"> and g.credit_type = #{creditType}</if>
<if test="status != null and status != ''"> and g.status = #{status}</if>
</where>
order by g.grant_id desc
</select>
<select id="selectActiveGrantsForUpdate"
resultMap="CustomerCreditGrantResult">
select grant_id, customer_id, account_id, role_id, approve_user_id, apply_id, flow_id,
credit_type, amount, remaining, effective_date, expire_date, status,
create_by, create_time, update_by, update_time, del_flag
from customer_credit_grant
where customer_id = #{customerId}
and credit_type = #{creditType}
and status = '0'
and del_flag = '0'
order by grant_id
for update
</select>
<insert id="insertGrant" parameterType="com.ruoyi.customer.customermoney.domain.CustomerCreditGrant"
useGeneratedKeys="true" keyProperty="grantId">
insert into customer_credit_grant (
customer_id, account_id, role_id, approve_user_id, apply_id, flow_id,
credit_type, amount, remaining, effective_date, expire_date, status,
create_by, create_time, remark, del_flag
) values (
#{customerId}, #{accountId}, #{roleId}, #{approveUserId}, #{applyId}, #{flowId},
#{creditType}, #{amount}, #{remaining}, #{effectiveDate}, #{expireDate}, #{status},
#{createBy}, sysdate(), #{remark}, '0'
)
</insert>
<update id="updateGrantRemaining"
parameterType="com.ruoyi.customer.customermoney.domain.CustomerCreditGrant">
update customer_credit_grant
set remaining = #{remaining},
status = #{status},
release_time = #{releaseTime},
update_by = #{updateBy},
update_time = now()
where grant_id = #{grantId}
</update>
</mapper>
@@ -21,6 +21,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="applyTime" column="apply_time" />
<result property="approveBy" column="approve_by" />
<result property="approveUserId" column="approve_user_id"/>
<result property="approveRoleId" column="approve_role_id"/>
<result property="approveRoleName" column="approve_role_name"/>
<result property="approveTime" column="approve_time" />
<result property="approveRemark" column="approve_remark" />
<result property="flowId" column="flow_id" />
@@ -38,11 +40,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select a.apply_id, a.apply_no, a.customer_id, a.account_id, a.credit_type, a.adjust_mode,
a.change_amount, a.effective_date, a.expire_date, a.reason, a.status,
a.apply_by, a.apply_user_id, a.apply_time,
a.approve_by, a.approve_user_id, a.approve_time, a.approve_remark, a.flow_id,
a.approve_by, a.approve_user_id, a.approve_role_id, a.approve_time, a.approve_remark, a.flow_id,
a.create_by, a.create_time, a.update_by, a.update_time, a.remark, a.del_flag,
c.customer_code, c.customer_name
c.customer_code, c.customer_name,
r.role_name as approve_role_name
from customer_credit_apply a
left join bms_customer c on c.customer_id = a.customer_id
left join sys_role r on r.role_id = a.approve_role_id
</sql>
<select id="selectApplyList"
@@ -98,6 +102,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
flow_id = #{flowId},
approve_by = #{approveBy},
approve_user_id = #{approveUserId},
approve_role_id = #{approveRoleId},
approve_time = sysdate(),
approve_remark = #{approveRemark},
update_by = #{approveBy},
@@ -12,6 +12,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="balance" column="balance" />
<result property="frozenBalance" column="frozen_balance" />
<result property="totalCredit" column="total_credit" />
<result property="creditEffectiveDate" column="credit_effective_date" />
<result property="creditExpireDate" column="credit_expire_date" />
<result property="extraCredit" column="extra_credit" />
<result property="extraEffectiveDate" column="extra_effective_date" />
<result property="extraExpireDate" column="extra_expire_date" />
@@ -27,7 +29,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectCustomerAccountVo">
select id, customer_id, currency, balance, frozen_balance, total_credit, extra_credit,
select id, customer_id, currency, balance, frozen_balance, total_credit,
credit_effective_date, credit_expire_date, extra_credit,
extra_effective_date, extra_expire_date, used_credit,
tax, version, create_by, create_time, update_by, update_time, remark, del_flag
from customer_account
@@ -49,15 +52,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where customer_id = #{customerId} and del_flag = '0'
</select>
<!-- 扫描已到期临时额度的CNY账户(失效日当天仍有效,故严格小于今天) -->
<select id="selectExpiredTempCreditCustomerIds" resultType="java.lang.Long">
select customer_id
<!-- 扫描已到期授信的CNY账户(信用或临时额度失效日早于今天;失效日当天仍有效,故严格小于今天) -->
<select id="selectExpiredCreditCustomerIds" resultType="java.lang.Long">
select distinct customer_id
from customer_account
where del_flag = '0'
and currency = 'CNY'
and extra_credit &gt; 0
and extra_expire_date is not null
and extra_expire_date &lt; CURDATE()
and (
(total_credit &gt; 0 and credit_expire_date is not null and credit_expire_date &lt; CURDATE())
or
(extra_credit &gt; 0 and extra_expire_date is not null and extra_expire_date &lt; CURDATE())
)
</select>
<insert id="insertAccount" parameterType="com.ruoyi.customer.customermoney.domain.CustomerAccount"
@@ -104,10 +109,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where id = #{id} and version = #{version} and del_flag = '0'
</update>
<!-- 乐观锁更新额度(信用/临时额度及临时额度有效期) -->
<!-- 乐观锁更新额度(信用/临时额度及各自有效期) -->
<update id="updateAccountCredit">
update customer_account
set total_credit = #{totalCredit},
credit_effective_date = #{creditEffectiveDate},
credit_expire_date = #{creditExpireDate},
extra_credit = #{extraCredit},
extra_effective_date = #{extraEffectiveDate},
extra_expire_date = #{extraExpireDate},
@@ -138,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="reason" column="reason" />
<result property="adjustMode" column="adjust_mode" />
<result property="operatorId" column="operator_id" />
<result property="roleId" column="role_id" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -149,7 +157,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectCustomerCreditFlowVo">
select id, flow_no, customer_id, account_id, credit_type, change_type, biz_type, biz_id, biz_no, direction,
before_amount, change_amount, after_amount, used_credit,
effective_date, expire_date, reason, adjust_mode, operator_id,
effective_date, expire_date, reason, adjust_mode, operator_id, role_id,
create_by, create_time, update_by, update_time, remark, del_flag
from customer_credit_flow
</sql>
@@ -192,6 +200,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="reason != null">reason,</if>
<if test="adjustMode != null">adjust_mode,</if>
<if test="operatorId != null">operator_id,</if>
<if test="roleId != null">role_id,</if>
<if test="createBy != null">create_by,</if>
<if test="remark != null">remark,</if>
<if test="delFlag != null">del_flag,</if>
@@ -215,6 +224,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="reason != null">#{reason},</if>
<if test="adjustMode != null">#{adjustMode},</if>
<if test="operatorId != null">#{operatorId},</if>
<if test="roleId != null">#{roleId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="remark != null">#{remark},</if>
<if test="delFlag != null">#{delFlag},</if>
@@ -0,0 +1,76 @@
-- =============================================================
-- 角色额度池 + 客户信用额度有效期 改造
-- 1. customer_account 新增信用额度生效/失效日期(与临时额度口径一致,到期次日自动清零)
-- 2. customer_credit_apply 新增 approve_role_id,记录审批占用的额度池角色
-- 3. customer_credit_flow 新增 role_id,流水留痕额度池归属角色
-- 4. 新表 sys_role_credit_quota:管理员为角色设置的可分配额度总额(额度池)
-- 5. 新表 customer_credit_grant:审批增量额度的占用台账(池余额=该表remaining求和)
-- 2026-09-20
-- 说明:ALTER 语句非自动幂等,若字段/表已存在,对应语句会报 Duplicate/already exists,忽略即可
-- =============================================================
SET NAMES utf8mb4;
-- -------------------------------------------------------------
-- 1. customer_account:信用额度有效期
-- -------------------------------------------------------------
ALTER TABLE `customer_account`
ADD COLUMN `credit_effective_date` DATE DEFAULT NULL COMMENT '信用额度生效日期' AFTER `total_credit`,
ADD COLUMN `credit_expire_date` DATE DEFAULT NULL COMMENT '信用额度失效日期(空=长期;到期次日清零)' AFTER `credit_effective_date`;
-- -------------------------------------------------------------
-- 2. customer_credit_apply / customer_credit_flow:额度池归属
-- -------------------------------------------------------------
ALTER TABLE `customer_credit_apply`
ADD COLUMN `approve_role_id` BIGINT(20) DEFAULT NULL COMMENT '审批占用的额度池角色ID(NULL=超管审批不占池)' AFTER `approve_user_id`;
ALTER TABLE `customer_credit_flow`
ADD COLUMN `role_id` BIGINT(20) DEFAULT NULL COMMENT '额度池角色ID(审批调整流水留痕;NULL=超管/系统)' AFTER `operator_id`;
-- -------------------------------------------------------------
-- 3. 角色额度池
-- 一个角色一行;没有记录视为额度 0(该角色用户不能审批任何增量授信)
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `sys_role_credit_quota` (
`role_id` BIGINT(20) NOT NULL COMMENT '角色ID(sys_role.role_id)',
`total_quota` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可分配额度总额(CNY)',
`version` INT(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色可分配授信额度池';
-- -------------------------------------------------------------
-- 4. 额度分配台账
-- 审批通过的净增额度在此占一行(占用角色池);调减/到期按 FIFO 核销行余额。
-- 角色池已用 = SUM(remaining WHERE role_id=? AND status='0')
-- role_id 为 NULL 表示超管审批(不占池)或历史/建档直配额度。
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `customer_credit_grant` (
`grant_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '台账ID',
`customer_id` BIGINT(20) NOT NULL COMMENT '客户ID',
`account_id` BIGINT(20) NOT NULL COMMENT '客户账户ID',
`role_id` BIGINT(20) DEFAULT NULL COMMENT '占用的额度池角色ID(NULL=超管/不占池)',
`approve_user_id` BIGINT(20) DEFAULT NULL COMMENT '审批人ID',
`apply_id` BIGINT(20) DEFAULT NULL COMMENT '额度申请单ID',
`flow_id` BIGINT(20) DEFAULT NULL COMMENT '额度流水ID',
`credit_type` VARCHAR(16) NOT NULL COMMENT '额度类型:CREDIT信用额度 TEMP临时额度',
`amount` DECIMAL(18,2) NOT NULL COMMENT '原始占用池金额',
`remaining` DECIMAL(18,2) NOT NULL COMMENT '仍占用池的待释放余额',
`effective_date` DATE DEFAULT NULL COMMENT '生效日期',
`expire_date` DATE DEFAULT NULL COMMENT '失效日期',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '状态:0占用中 1已释放',
`release_time` DATETIME DEFAULT NULL COMMENT '全部释放时间',
`create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
PRIMARY KEY (`grant_id`),
KEY `idx_role_status` (`role_id`, `status`),
KEY `idx_customer_type_status` (`customer_id`, `credit_type`, `status`),
KEY `idx_apply` (`apply_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='客户授信额度分配台账(角色池占用明细)';