123123123

This commit is contained in:
dzc committed 2026-09-14 22:07:48 +08:00
1 parent 20e36d6d86
commit 9eea0ab3c5
6 files changed
+267 -41

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
-- 2026-09-14 自动退款任务僵尸循环止血脚本
--
-- 背景:商户关闭自动退款开关的任务被置为 status='30'(开关关闭暂停)且 next_retry_time=NULL,
-- 扫描器对 next_retry_time IS NULL 的暂停任务每 30 秒无条件重扫,配合 wallet 配置服务不可达,
-- 形成 select for update + Feign 超时的死循环,拖垮订单接口(当晚 page-join 504)。
-- 代码修复(暂停任务加 10 分钟退避)上线后新数据自动退避,本脚本只处理存量 NULL 行。
--
-- 执行方式:手工执行(无自动迁移),先于代码部署。
-- 注意:不置终态——商户重新打开开关后任务仍可正常退款,仅把复查时间推迟一天降温。
-- 1. 执行前确认存量僵尸任务数量(预期为线上扫描风暴的规模)
SELECT COUNT(*) AS zombie_task_count
FROM hard_auto_refund_task
WHERE del_flag = '0'
AND status = '30'
AND next_retry_time IS NULL;
-- 2. 将存量僵尸任务的复查时间推至一天后,立即脱离 30 秒扫描循环
UPDATE hard_auto_refund_task
SET next_retry_time = DATE_ADD(NOW(), INTERVAL 1 DAY)
WHERE del_flag = '0'
AND status = '30'
AND next_retry_time IS NULL;
-- 3. 执行后确认已无 NULL 复查时间的暂停任务(预期为 0)
SELECT COUNT(*) AS remaining_zombie_task_count
FROM hard_auto_refund_task
WHERE del_flag = '0'
AND status = '30'
AND next_retry_time IS NULL;
@@ -142,6 +142,25 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
*/
Page<HardOrderVo> scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 管理端先按全部筛选条件和数据范围分页读取订单主键,展示表关联与最新售后/发票子查询不参与全量排序。
* 查询列保留 merchant_dept_id 供数据权限拦截器包装部门过滤;总数由分页插件基于数据权限改写后的 SQL 自动统计。
*
* @param page 主键分页参数,排序规则与 {@link #scopePageList} 完全一致
* @param dto 订单、商户、状态、时间、设备类型及售后单号筛选条件
* @param dataScope 后台商户部门数据范围
* @return 当前页订单主键(附商户部门列)及符合条件的总数
*/
Page<HardOrderVo> scopePageIds(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按已分页的订单主键批量读取管理端展示详情,设备、最新售后单、付款人、发票和兑换券关联仅对当前页执行。
*
* @param orderIds 当前页订单主键集合,调用方保证非空且已完成数据范围过滤
* @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排
*/
List<HardOrderVo> listPageDetails(@Param("orderIds") List<Long> orderIds);
/**
* 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。
*
@@ -99,8 +99,16 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 配置服务异常后再次复查的等待时间,单位秒,不消耗自动尝试额度。
* 取 5 分钟而非更短间隔:Wallet 整体不可用时,避免扫描器每轮对上百个暂停任务串行等待 Feign 超时;
* 代价是 Wallet 恢复后任务最多延迟 5 分钟继续执行,对异步补偿任务可接受。
*/
private static final long CONFIG_RECHECK_DELAY_SECONDS = 30L;
private static final long CONFIG_RECHECK_DELAY_SECONDS = 300L;
/**
* 开关关闭暂停后的固定复查间隔,单位分钟。暂停任务按该间隔复查开关状态,
* 不再以扫描频率(30 秒)反复占用任务行锁、配置调用和数据库连接。
*/
private static final long SWITCH_DISABLED_RECHECK_MINUTES = 10L;
/**
* 任务和尝试日志错误字段允许保存的最大字符数。
@@ -350,9 +358,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
try {
try {
// 配置查询移到数据库行锁事务之外:Wallet 抖动或宕机时不再持有任务 FOR UPDATE 行锁等待 Feign 超时,
// 同时把一次尝试的两次配置调用收敛为一次;人工直执路径不消费配置,传 null 即可。
MchAutoRefundConfigVo currentConfig = needsCurrentConfig(triggerType)
? loadCurrentConfig(current.getMchId(), SUPPORTED_ORDER_TYPE) : null;
if (Objects.equals(current.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& current.getAfterSaleId() == null) {
if (!getSelf().canPrepareExpiredReserveTask(taskId, triggerType, operatorUserId)) {
if (!getSelf().canPrepareExpiredReserveTask(taskId, triggerType, operatorUserId, currentConfig)) {
return;
}
getSelf().prepareExpiredReserveTask(taskId);
@@ -361,7 +373,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return;
}
}
getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId);
getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId, currentConfig);
} catch (CurrentConfigLookupException exception) {
if (!tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) {
getSelf().pauseConfigUnavailableInTransaction(taskId, exception.getMessage());
@@ -376,15 +388,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
* 当前配置由调用方在事务外查询后传入,事务内不再发起远程调用。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @param current 事务外预加载的当前配置;人工触发路径不使用
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId, MchAutoRefundConfigVo current) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
@@ -393,7 +407,6 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
return true;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
return false;
@@ -426,15 +439,18 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
/**
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 在单个 Hardware 事务中锁任务、复核调用方预加载的配置、调用 Wallet 并原子完成售后和任务状态。
* 配置查询在事务外完成,事务内不因 Feign 超时长时间持有任务行锁;
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param current 事务外预加载的当前配置;人工直执路径不使用
*/
@Transactional(rollbackFor = Exception.class)
public void executeAttemptInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
public void executeAttemptInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId, MchAutoRefundConfigVo current) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)) {
return;
@@ -450,7 +466,6 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
completeAttempt(task, triggerType, operatorUserId, false);
return;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
@@ -819,7 +834,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
/**
* 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。
* 遇到关闭开关时记录一次跳过审计并进入暂停,不增加自动尝试次数。
* 暂停任务写入固定复查间隔而不是空值:空值会被扫描器当作立即可重扫,
* 在 Wallet 配置服务抖动时任务在开关关闭与配置不可用之间振荡,反复占用行锁并刷跳过日志。
* 暂停态之间的流转(配置不可用转开关关闭)不重复记审计日志。
*
* @param task 已锁定任务
* @param triggerType 触发来源
@@ -827,7 +845,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
if (!Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())) {
boolean alreadyPaused = Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
|| Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus());
if (!alreadyPaused) {
int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
LocalDateTime now = LocalDateTime.now();
HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
@@ -846,7 +866,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
task.setLastAttemptTime(now);
}
task.setStatus(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
task.setNextRetryTime(null);
task.setNextRetryTime(LocalDateTime.now().plusMinutes(SWITCH_DISABLED_RECHECK_MINUTES));
task.setLastError(null);
baseMapper.updateById(task);
}
@@ -898,6 +918,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return true;
}
/**
* 判断触发来源是否需要预加载当前配置;两种后台人工直执路径在执行阶段明确跳过配置与开关复核。
*
* @param triggerType 触发来源
* @return 需要在进入事务前查询配置时返回 {@code true}
*/
private boolean needsCurrentConfig(HardAutoRefundTriggerTypeEnum triggerType) {
return triggerType != HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL
&& triggerType != HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL;
}
/**
* 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。
*
@@ -1390,7 +1421,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
existing.setRestoreInventory(restoreInventory);
existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
existing.setNextRetryTime(enabled ? triggerTime : null);
// 开关关闭同样写入固定复查间隔,空值会被扫描器当作立即可重扫形成僵尸循环。
existing.setNextRetryTime(enabled ? triggerTime
: LocalDateTime.now().plusMinutes(SWITCH_DISABLED_RECHECK_MINUTES));
existing.setLastError(null);
if (baseMapper.updateById(existing) != 1) {
throw new RuntimeException("刷新过期预约退款任务失败");
@@ -1405,7 +1438,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
.status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
.autoAttemptCount(0).totalAttemptCount(0)
.nextRetryTime(enabled ? triggerTime : null)
.nextRetryTime(enabled ? triggerTime
: LocalDateTime.now().plusMinutes(SWITCH_DISABLED_RECHECK_MINUTES))
.build();
if (baseMapper.insert(task) != 1) {
throw new RuntimeException("创建过期预约退款任务失败");
@@ -346,11 +346,42 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
disposeList(list.getRecords(), dto);
return list;
}
Page<HardOrderVo> list = baseMapper.scopePageList(page, dto, dataScope);
// 管理端与 App 相同:先分页主键再回填详情,最新售后/发票相关子查询只对当前页执行,不参与全量排序与计数。
Page<HardOrderVo> list = idPageThenDetails(page, dto, dataScope);
disposeList(list.getRecords(), dto);
return list;
}
/**
* 管理端先在订单主表上分页主键,再按主键回填当前页详情,避免售后、发票等展示关联参与全量排序与计数。
*
* @param page 原始订单分页参数
* @param dto 订单筛选条件
* @param dataScope 后台商户部门数据范围
* @return 记录顺序与主键分页查询完全一致的订单页
*/
private Page<HardOrderVo> idPageThenDetails(Page page, HardOrderDto dto, DataScope dataScope) {
Page<HardOrderVo> idPage = new Page<>(page.getCurrent(), page.getSize(), page.searchCount());
idPage.setMaxLimit(page.maxLimit());
idPage.setOptimizeCountSql(page.optimizeCountSql());
idPage.setOptimizeJoinOfCountSql(page.optimizeJoinOfCountSql());
idPage.setOrders(page.orders());
idPage = baseMapper.scopePageIds(idPage, dto, dataScope);
List<Long> orderIds = idPage.getRecords().stream().map(HardOrderVo::getId).toList();
List<HardOrderVo> orderedRecords = List.of();
if (ObjectUtil.isNotEmpty(orderIds)) {
Map<Long, HardOrderVo> detailMap = CollectionUtils.convertMap(baseMapper.listPageDetails(orderIds), HardOrderVo::getId);
orderedRecords = orderIds.stream().map(detailMap::get).filter(Objects::nonNull).toList();
}
Page<HardOrderVo> result = new Page<>(idPage.getCurrent(), idPage.getSize(), idPage.getTotal(), idPage.searchCount());
result.setRecords(orderedRecords);
result.setOrders(page.orders());
result.setMaxLimit(idPage.maxLimit());
return result;
}
/**
* App 订单先分页主键、再关联当前页详情,避免售后和父购物车关联参与全部历史订单的排序与分页。
*
@@ -390,7 +421,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
applyIdentityQuery(dto);
dto.setOrderPayTypeCodes(List.of(HardPayTypeEnum.HARD_DOOR_FREE_PAY.getType()));
dto.setOrderStatus(HardOrderStatusEnum.CREATE.getType());
Page<HardOrderVo> list = baseMapper.scopePageList(page, dto, dataScope);
Page<HardOrderVo> list = idPageThenDetails(page, dto, dataScope);
disposeList(list.getRecords(), dto);
return list;
}
@@ -360,6 +360,25 @@
</if>
</sql>
<!-- 管理端分页统一排序:ID 预查询与 pageList 必须保持完全一致的顺序,保证主键回填顺序与导出口径一致。 -->
<sql id="pageListOrder">
<choose>
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
<!-- id 作为唯一兜底排序,保证异步导出跨页读取时不会因相同时间和用户出现重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
</otherwise>
</choose>
</sql>
<sql id="pageList">
<include refid="selectA"/>
m.name as hardName,
@@ -416,27 +435,100 @@
and s.no = #{dto.afterSaleNo}
</if>
</where>
<choose>
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
order by a.appt_use_date desc, a.hour_type asc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
<!-- id 作为唯一兜底排序,保证异步导出跨页读取时不会因相同时间和用户出现重复或遗漏。 -->
order by a.create_time desc, a.user_id desc, a.id desc
</otherwise>
</choose>
<include refid="pageListOrder"/>
</sql>
<select id="scopePageList" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
<include refid="pageList"/>
</select>
<!--
管理端先在订单主表上分页主键,最新售后/发票相关子查询只对当前页执行(与 App 端 appVisibilityPageIds 同款优化)。
merchant_dept_id 必须保留在查询列中:数据权限拦截器对部门权限用户包一层
SELECT * FROM (本查询) temp_data_scope WHERE temp_data_scope.merchant_dept_id IN (...),
缺少该列会直接 SQL 报错;总数统计走分页插件自动 count,基于数据权限改写后的 SQL 生成,同样带权限。
-->
<select id="scopePageIds" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
select a.id,
a.merchant_dept_id
from hard_order a
<if test="(dto.hardTypeCode != null and dto.hardTypeCode != '') or (dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0)">
left join hard_manager m on m.id = a.hard_id
</if>
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
</if>
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
and m.type = #{dto.hardTypeCode}
</if>
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
and m.type in
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
and s.no = #{dto.afterSaleNo}
</if>
</where>
<include refid="pageListOrder"/>
</select>
<!-- 管理端当前页详情:按主键集合回填,展示列关联与最新售后/发票子查询只对本页订单执行。 -->
<select id="listPageDetails" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
<include refid="selectA"/>
m.name as hardName,
m.type as hardType,
m.dept_id as hardTypeDeptId,
s.id as afterSaleId,
s.no as afterSaleNo,
p.user_id as payerUserId,
latest_invoice.id as latestInvoiceTaskId,
latest_invoice.status as latestInvoiceStatus,
latest_invoice.print_no as latestInvoicePrintNo,
latest_invoice.hard_id as latestInvoiceSourceHardId,
latest_voucher.id as latestVoucherTaskId,
latest_voucher.status as latestVoucherStatus,
latest_voucher.print_no as latestVoucherPrintNo,
latest_voucher.hard_id as latestVoucherSourceHardId
from hard_order a
left join hard_manager m on m.id = a.hard_id
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
left join hard_invoice latest_invoice on latest_invoice.id = (
select max(invoice_task.id)
from hard_invoice invoice_task
where invoice_task.order_id = a.id
and (invoice_task.print_type = '1' or invoice_task.print_type is null)
and invoice_task.del_flag = '0'
)
left join hard_invoice latest_voucher on latest_voucher.id = (
select max(voucher_task.id)
from hard_invoice voucher_task
where voucher_task.order_id = a.id
and voucher_task.print_type = '2'
and voucher_task.del_flag = '0'
)
where a.id in
<foreach collection="orderIds" item="orderId" open="(" close=")" separator=",">
#{orderId}
</foreach>
and a.del_flag = '0'
</select>
<!-- App 先在订单主表上完成分页,展示字段关联不会再参与全量排序。 -->
<select id="appVisibilityPageIds" resultType="java.lang.Long">
select a.id
@@ -182,12 +182,11 @@ class HardAutoRefundTaskServiceImplTest {
persistedRefund.setPayRefundOrderId(2L);
HardAfterSaleService afterSaleService = org.mockito.Mockito.mock(HardAfterSaleService.class);
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(config));
when(walletRechargeService.getRefundByAfterSaleId(2L)).thenReturn(R.ok(persistedRefund));
when(afterSaleServiceProvider.getObject()).thenReturn(afterSaleService);
when(attemptLogMapper.updateById(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, config);
verify(afterSaleService).completeAutoRefund(2L, 5L);
assertEquals(1, task.getAutoAttemptCount());
@@ -272,6 +271,7 @@ class HardAutoRefundTaskServiceImplTest {
when(taskMapper.selectOne(any())).thenReturn(null);
when(taskMapper.insert(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
ArgumentCaptor<HardAutoRefundTaskEntity> taskCaptor = ArgumentCaptor.forClass(HardAutoRefundTaskEntity.class);
LocalDateTime before = LocalDateTime.now();
int result = service.syncExpiredRescanTask(order, config, triggerTime, YesNoEnum.NO.getCode(), false);
@@ -279,7 +279,9 @@ class HardAutoRefundTaskServiceImplTest {
verify(taskMapper).insert(taskCaptor.capture());
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), taskCaptor.getValue().getStatus());
assertEquals(triggerTime, taskCaptor.getValue().getTriggerTime());
assertNull(taskCaptor.getValue().getNextRetryTime());
// 开关关闭创建的暂停任务必须带固定复查间隔,空值会被扫描器当作立即可重扫形成僵尸循环。
assertTrue(!taskCaptor.getValue().getNextRetryTime().isBefore(before.plusMinutes(10))
&& !taskCaptor.getValue().getNextRetryTime().isAfter(LocalDateTime.now().plusMinutes(10)));
}
/**
@@ -300,11 +302,10 @@ class HardAutoRefundTaskServiceImplTest {
void autoVerifyTaskShouldSkipWhenOrderTerminal() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
.id(3L).orderStatus(HardOrderStatusEnum.CANCEL.getType()).build());
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, verifyConfig(true));
assertEquals(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus(), task.getStatus());
assertNull(task.getNextRetryTime());
@@ -319,7 +320,6 @@ class HardAutoRefundTaskServiceImplTest {
void autoVerifyTaskShouldDeferWhenRefundPending() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
.id(3L)
.orderStatus(HardOrderStatusEnum.PAY.getType())
@@ -327,7 +327,7 @@ class HardAutoRefundTaskServiceImplTest {
.build());
LocalDateTime before = LocalDateTime.now();
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, verifyConfig(true));
assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
assertEquals(0, task.getAutoAttemptCount());
@@ -336,17 +336,37 @@ class HardAutoRefundTaskServiceImplTest {
verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject();
}
/** 自动核销开关关闭时应沿用退款任务的暂停语义,任务保持开关关闭暂停等待重新开启。 */
/** 自动核销开关关闭时应暂停并写入固定复查间隔;事务内不再发起配置远程调用。 */
@Test
void autoVerifyTaskShouldPauseWhenSwitchDisabled() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(false)));
LocalDateTime before = LocalDateTime.now();
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, verifyConfig(false));
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), task.getStatus());
// 暂停任务带 10 分钟复查间隔,避免被扫描器每 30 秒重扫形成僵尸循环。
assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(10))
&& !task.getNextRetryTime().isAfter(LocalDateTime.now().plusMinutes(10)));
verify(orderMapper, org.mockito.Mockito.never()).selectByIdForUpdate(any());
verify(configService, org.mockito.Mockito.never()).getExact(any(), any());
}
/** 配置不可用暂停转开关关闭暂停属于暂停态之间流转,不应重复写跳过日志或增加尝试次数。 */
@Test
void pauseTransitionBetweenPausedStatesShouldNotDuplicateAuditLog() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
LocalDateTime before = LocalDateTime.now();
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null, verifyConfig(false));
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), task.getStatus());
assertEquals(0, task.getTotalAttemptCount());
verify(attemptLogMapper, org.mockito.Mockito.never()).insert(any(HardAutoRefundAttemptLogEntity.class));
assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(10))
&& !task.getNextRetryTime().isAfter(LocalDateTime.now().plusMinutes(10)));
}
/** 触发时间未到或任务已跳过终态时,canExecute 都不允许自动核销任务进入执行。 */
@@ -394,7 +414,7 @@ class HardAutoRefundTaskServiceImplTest {
task.setTriggerTime(LocalDateTime.now().plusDays(2));
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L, null);
assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus());
assertEquals(0, task.getAutoAttemptCount());