Przeglądaj źródła

feat(coupon): 新增过期优惠券物理清理定时任务

每日 03:30 分批(500/批)将 family_coupon.status=AVAILABLE 且 coupon.valid_until 已过期的券实例置为 EXPIRED;SQL 内二次限定 AVAILABLE,绝不触碰 RESERVED/USED/REVOKED;每批打印条数并汇总总处理量。不改 schema/表结构。
zxyj-web 2 tygodni temu
rodzic
commit
2854d0629e

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyCouponMapper.java

@@ -3,7 +3,43 @@ package com.etotem.cfc.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.FamilyCoupon;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.Date;
+import java.util.List;
 
 @Mapper
 public interface FamilyCouponMapper extends BaseMapper<FamilyCoupon> {
+
+    /**
+     * 查询「券实例仍为 AVAILABLE 且所属券模板已过期」的券实例 ID(按 id 升序,最多 limit 条)。
+     * 仅用于过期券清理任务分批取数:RESERVED(下单占用中)/USED/EXPIRED 等状态不会被查出。
+     * 券模板 valid_until 为 NULL(永久有效)时不视为过期。
+     *
+     * @param now   过期判定基准时间
+     * @param limit 单批最大条数
+     * @return 待处理券实例 ID 列表
+     */
+    @Select("SELECT fc.id FROM family_coupon fc " +
+            "JOIN coupon c ON c.id = fc.coupon_id " +
+            "WHERE fc.status = 'AVAILABLE' " +
+            "AND c.valid_until IS NOT NULL AND c.valid_until < #{now} " +
+            "ORDER BY fc.id LIMIT #{limit}")
+    List<Long> selectExpiredAvailableIds(@Param("now") Date now, @Param("limit") int limit);
+
+    /**
+     * 按 ID 批量将券实例置为 EXPIRED。
+     * SQL 内再次限定 status = 'AVAILABLE',确保并发下刚被下单占用(RESERVED)的券不会被误改。
+     *
+     * @param ids 券实例 ID 列表(非空,由调用方保证)
+     * @return 实际更新条数
+     */
+    @Update("<script>" +
+            "UPDATE family_coupon SET status = 'EXPIRED' " +
+            "WHERE status = 'AVAILABLE' AND id IN " +
+            "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" +
+            "</script>")
+    int expireAvailableByIds(@Param("ids") List<Long> ids);
 }

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -601,6 +601,26 @@ public class CouponService {
         return result;
     }
 
+    /**
+     * 过期券清理(单批):把「券实例 status=AVAILABLE 且券模板 valid_until 已过期」的记录置为 EXPIRED。
+     * 每批最多处理 batchSize 条,由调用方循环调用以控制单事务影响行数。
+     * 只处理 AVAILABLE;RESERVED(下单占用中)/USED/REVOKED 等状态一律不涉及。
+     *
+     * @param now       过期判定基准时间
+     * @param batchSize 单批最大条数
+     * @return 本批实际更新条数,0 表示已无待清理记录
+     */
+    @Transactional
+    public int expireOverdueAvailableCoupons(Date now, int batchSize) {
+        List<Long> ids = familyCouponMapper.selectExpiredAvailableIds(now, batchSize);
+        if (ids == null || ids.isEmpty()) {
+            return 0;
+        }
+        int updated = familyCouponMapper.expireAvailableByIds(ids);
+        logger.info("过期券清理单批: 命中={}, 实际更新={}", ids.size(), updated);
+        return updated;
+    }
+
     /**
      * 查询指定会员等级的活跃家庭 ID 列表(供 CouponGrantTask 周期补发)
      */

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/CouponExpireCleanupTask.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.service.CouponService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 过期优惠券物理清理任务
+ *
+ * <p>清理口径:只清理「券实例 {@code family_coupon.status = 'AVAILABLE'} 且其券模板
+ * {@code coupon.valid_until} 早于当前时间」的记录,把这些券实例的 status 更新为 {@code EXPIRED}。
+ * 券模板 valid_until 为 NULL(永久有效)的券不视为过期,不会被清理。</p>
+ *
+ * <p>不会触碰 {@code status = 'RESERVED'}(下单占用中)的券实例——RESERVED 券的过期处理不在本任务范围内,
+ * 由下单/超时取消链路({@code OrderTimeoutCancelTask} 归还 AVAILABLE、支付后置 USED)自行负责。</p>
+ *
+ * <p>分批执行:每批最多 {@link #BATCH_SIZE} 条,逐批独立事务({@link CouponService#expireOverdueAvailableCoupons}),
+ * 避免长事务/大事务锁表;每批打印处理条数,任务结束时汇总输出总处理条数。</p>
+ *
+ * <p>调度:每天 03:30 低峰执行一次({@code @EnableScheduling} 已在 {@code CfcApplication} 开启)。</p>
+ */
+@Slf4j
+@Component
+public class CouponExpireCleanupTask {
+
+    /** 每批处理条数:控制单条 UPDATE 的影响行数与事务时长 */
+    private static final int BATCH_SIZE = 500;
+
+    /** 单次任务最多执行批数(500 * 200 = 10 万条),防止历史积压过大时任务长时间占用数据库 */
+    private static final int MAX_BATCHES = 200;
+
+    @Resource
+    private CouponService couponService;
+
+    @Scheduled(cron = "0 30 3 * * ?")
+    public void cleanupExpiredCoupons() {
+        log.info("开始执行过期优惠券物理清理任务: batchSize={}, maxBatches={}", BATCH_SIZE, MAX_BATCHES);
+        long start = System.currentTimeMillis();
+        int total = 0;
+        int batches = 0;
+        try {
+            Date now = new Date();
+            while (batches < MAX_BATCHES) {
+                int updated = couponService.expireOverdueAvailableCoupons(now, BATCH_SIZE);
+                if (updated <= 0) {
+                    break;
+                }
+                batches++;
+                total += updated;
+                log.info("过期优惠券清理: 第{}批处理{}条,累计{}条", batches, updated, total);
+                if (updated < BATCH_SIZE) {
+                    break;
+                }
+            }
+            if (batches >= MAX_BATCHES) {
+                log.warn("过期优惠券清理达到单次最大批数{}(本次已处理{}条),剩余记录留待下次任务执行",
+                        MAX_BATCHES, total);
+            }
+            log.info("过期优惠券物理清理任务执行完成: 总处理{}条, 批次={}, 耗时={}ms",
+                    total, batches, System.currentTimeMillis() - start);
+        } catch (Exception e) {
+            log.error("过期优惠券物理清理任务执行失败: 已处理{}条, 批次={}", total, batches, e);
+        }
+    }
+}