临时额度定时失效

This commit is contained in:
luojiaquan
2026-09-17 14:12:05 +08:00
parent 51c8013cd9
commit eece680a07
7 changed files with 197 additions and 7 deletions
@@ -41,6 +41,15 @@ public interface ICustomerAccountApi
*/
void expireTempCreditIfNeeded(CustomerAccount account);
/**
* 扫描所有临时额度已到期(extra_credit>0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效(行锁 -> 清零 -> 回写档案 -> EXPIRE流水)。
* 单户失败仅记录日志不影响其他户;定时任务每日调用,应用启动后也会执行一次。
*
* @return 实际发生失效的账户数
*/
int expireAllDueTempCredits();
/**
* 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY,档案有值按档案),
* 写 INIT 额度流水;CNY账户已存在时不重复创建。
@@ -8,9 +8,15 @@ import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
@@ -36,6 +42,8 @@ import com.ruoyi.system.domain.BmsExchangeRate;
@Service
public class CustomerAccountApiImpl implements ICustomerAccountApi
{
private static final Logger log = LoggerFactory.getLogger(CustomerAccountApiImpl.class);
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
/** 本位币币种 */
@@ -51,6 +59,19 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Autowired
private PlatformTransactionManager transactionManager;
/** 逐户独立事务模板(REQUIRES_NEW):批量失效时单户失败回滚不影响其他户 */
private TransactionTemplate requireNewTxTemplate;
@PostConstruct
public void initTxTemplate()
{
requireNewTxTemplate = new TransactionTemplate(transactionManager);
requireNewTxTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Override
public CustomerAccount getAccount(Long customerId, String currency)
{
@@ -109,6 +130,44 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
return 1;
}
@Override
public int expireAllDueTempCredits()
{
List<Long> customerIds = customerMoneyMapper.selectExpiredTempCreditCustomerIds();
if (customerIds == null || customerIds.isEmpty())
{
return 0;
}
int expired = 0;
for (Long customerId : customerIds)
{
// 每户独立事务:行锁 -> 失效清零 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户
try
{
Boolean changed = requireNewTxTemplate.execute(status -> {
CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (locked == null || !isTempCreditExpired(locked))
{
// 可能被其他节点/入口抢先处理
return Boolean.FALSE;
}
expireTempCreditIfNeeded(locked);
return Boolean.TRUE;
});
if (Boolean.TRUE.equals(changed))
{
expired++;
}
}
catch (Exception e)
{
log.error("客户{}临时额度到期自动失效失败", customerId, e);
}
}
log.info("临时额度到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired);
return expired;
}
@Override
public void expireTempCreditIfNeeded(CustomerAccount account)
{
@@ -116,7 +175,9 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
{
return;
}
String username = SecurityUtils.getUsername();
// 定时任务场景无登录态,操作人回落为 system(人工触发的入口仍记录实际操作人)
String username = currentUsername();
Long userId = currentUserId();
BigDecimal before = nz(account.getExtraCredit());
account.setExtraCredit(BigDecimal.ZERO);
account.setUpdateBy(username);
@@ -143,13 +204,39 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
flow.setEffectiveDate(account.getExtraEffectiveDate());
flow.setExpireDate(account.getExtraExpireDate());
flow.setReason("临时额度到期自动失效");
flow.setOperatorId(SecurityUtils.getUserId());
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零");
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
/** 当前登录用户名;无登录态(定时任务)时返回 system */
private String currentUsername()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "system";
}
}
/** 当前登录用户ID;无登录态(定时任务)时返回null */
private Long currentUserId()
{
try
{
return SecurityUtils.getUserId();
}
catch (Exception e)
{
return null;
}
}
/* ================= 授信额度占用 / 释放 ================= */
@Override
@@ -28,6 +28,12 @@ public interface CustomerMoneyMapper
/** 查询客户全部币种账户 */
List<CustomerAccount> selectAccountListByCustomerId(@Param("customerId") Long customerId);
/**
* 扫描临时额度已到期的CNY账户客户ID(extra_credit>0 且失效日期早于今天;失效日当天仍有效)。
* 供定时任务批量物化失效使用,结果集不加锁,逐户处理时再行锁。
*/
List<Long> selectExpiredTempCreditCustomerIds();
/** 新增账户 */
int insertAccount(CustomerAccount account);
@@ -0,0 +1,62 @@
package com.ruoyi.customer.customermoney.task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
/**
* 客户临时额度到期自动失效任务。
*
* <p>每天 01:30 扫描临时额度已到期(extra_credit&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效:额度清零 -> 回写客户档案 -> 写 EXPIRE 额度流水。
* 失效日当天临时额度仍有效,故严格按「早于今天」判断。</p>
*
* <p>除惰性触发(访问资金接口时即查即失效)外,本任务保证展示口径一致:
* 客户列表可用额度是账户表现算,不经过惰性入口,需由本任务把到期状态落库。
* 应用启动后也会执行一次,避免停机期间错过调度。</p>
*
* <p>到期不影响已用额度:已确认账单形成的授信占用仍然有效,客户回款核销后
* used_credit 自然下降;可用额度可能短暂为负,新计费开单会被额度校验拦截。</p>
*
* @author ruoyi
* @date 2026-09-17
*/
@Component
public class TempCreditExpireTask implements ApplicationRunner
{
private static final Logger log = LoggerFactory.getLogger(TempCreditExpireTask.class);
@Autowired
private ICustomerAccountApi customerAccountApi;
/** 每天 01:30 执行(cron: 秒 分 时 日 月 周) */
@Scheduled(cron = "0 30 1 * * ?")
public void scheduled()
{
runExpire();
}
@Override
public void run(ApplicationArguments args)
{
runExpire();
}
private void runExpire()
{
try
{
int count = customerAccountApi.expireAllDueTempCredits();
log.info("客户临时额度到期失效任务执行完成,失效账户数={}", count);
}
catch (Exception e)
{
log.error("客户临时额度到期失效任务执行异常", e);
}
}
}
@@ -49,6 +49,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
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()
</select>
<insert id="insertAccount" parameterType="com.ruoyi.customer.customermoney.domain.CustomerAccount"
useGeneratedKeys="true" keyProperty="id">
insert into customer_account
@@ -70,7 +70,10 @@
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="hover" class="balance-card">
<div class="balance-label">可用额度</div>
<div class="balance-value success">{{ availableCredit }}</div>
<div class="balance-value" :class="availableOverLimit ? 'danger' : 'success'">{{ availableCredit }}</div>
<div class="balance-sub" v-if="availableOverLimit" style="color: var(--el-color-danger);">
已超额,回款核销后恢复
</div>
</el-card>
</el-col>
</el-row>
@@ -1006,14 +1009,18 @@ const currentAccount = computed(() => {
return accountList.value.find(a => a.currency === 'CNY') || {}
})
/** 可用额度 = 总额度 + 额外额度 - 已用额度 */
const availableCredit = computed(() => {
/** 可用额度数值 = 总额度 + 临时额度 - 已用额度(可能为负,表示临时额度到期/占用超授信总额) */
const availableCreditValue = computed(() => {
const a = currentAccount.value
const total = Number(a.totalCredit) || 0
const extra = Number(a.extraCredit) || 0
const used = Number(a.usedCredit) || 0
return formatMoney(total + extra - used)
return total + extra - used
})
/** 可用额度展示文本 */
const availableCredit = computed(() => formatMoney(availableCreditValue.value))
/** 是否处于超额状态(已用额度超过授信总额,新计费开单将被拦截,需先回款核销) */
const availableOverLimit = computed(() => availableCreditValue.value < 0)
/** 临时额度有效期文案 */
const tempCreditPeriodText = computed(() => {
@@ -157,7 +157,15 @@
</el-table-column>
<el-table-column label="重量阈值" align="center" prop="weightThreshold" />
<el-table-column label="抛比" align="center" prop="throwRatio" />
<el-table-column label="信用额度" align="center" prop="creditEd" />
<el-table-column label="信用额度" align="center" prop="creditEd">
<template #default="scope">
<span v-if="Number(scope.row.creditEd) < 0" style="color: var(--el-color-danger); font-weight: 600;"
title="已用额度超过授信总额(如临时额度已到期),客户回款核销后恢复">
{{ scope.row.creditEd }}
</span>
<span v-else>{{ scope.row.creditEd }}</span>
</template>
</el-table-column>
<el-table-column label="绑定产品" align="center" prop="productIds" min-width="180">
<template #default="scope">
<span v-if="productNames(scope.row.productIds)" :title="productNames(scope.row.productIds)">