临时额度定时失效
This commit is contained in:
+9
@@ -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账户已存在时不重复创建。
|
||||
|
||||
+89
-2
@@ -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
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
|
||||
+62
@@ -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>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 > 0
|
||||
and extra_expire_date is not null
|
||||
and extra_expire_date < CURDATE()
|
||||
</select>
|
||||
|
||||
<insert id="insertAccount" parameterType="com.ruoyi.customer.customermoney.domain.CustomerAccount"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
insert into customer_account
|
||||
|
||||
+11
-4
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user