新加额度池配置功能

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
@@ -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>0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效(行锁 -> 清零 -> 回写档案 -> EXPIRE流水)。
* 扫描所有授信已到期(信用或临时额度>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;
}