7 Achegas 8c0c7f0f4c ... f3af7d51bf

Autor SHA1 Mensaxe Data
  xw f3af7d51bf 评论redis优化和pad防重过期时间 hai 1 semana
  xw c89401d131 完课定时任务增加一次检查,减少完课误差 hai 1 semana
  xw 300d4dc79b 优化看课redis,索引Set+ SCAN兜底 hai 1 semana
  xw 48683f98f5 视频上传走本地ffmpeg hai 1 semana
  xw 7553a98586 直播缓存用索引Set hai 1 semana
  xw 78a642d004 重粉新加判断,项目1 和项目 28视为同一项目 hai 1 semana
  xw 58f770f39f 重粉新加判断,项目1 和项目 28视为同一项目 hai 1 semana
Modificáronse 24 ficheiros con 843 adicións e 191 borrados
  1. 1 16
      fs-admin/src/main/java/com/fs/course/task/VideoTask.java
  2. 15 0
      fs-common/src/main/java/com/fs/common/constant/CourseWatchKeysConstant.java
  3. 26 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  4. 13 0
      fs-common/src/main/java/com/fs/common/constant/VideoCommentKeysConstant.java
  5. 49 5
      fs-common/src/main/java/com/fs/common/utils/redis/LiveDelayedTaskRedisUtil.java
  6. 178 0
      fs-common/src/main/java/com/fs/common/utils/redis/RedisActiveKeyIndexRepairService.java
  7. 1 1
      fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  8. 48 22
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  9. 12 6
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  10. 16 4
      fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  11. 3 4
      fs-service/src/main/java/com/fs/course/dto/RedisKeyInfo.java
  12. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  13. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsUserVideoCommentService.java
  14. 38 27
      fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java
  15. 120 69
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  16. 29 7
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  17. 46 8
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoCommentServiceImpl.java
  18. 34 0
      fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java
  19. 98 0
      fs-service/src/main/java/com/fs/course/utils/H5WxUserWatchRedisUtil.java
  20. 73 0
      fs-service/src/main/java/com/fs/course/utils/VideoCommentCountRedisUtil.java
  21. 9 2
      fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  22. 2 1
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  23. 16 2
      fs-service/src/main/java/com/fs/utils/VideoUtil.java
  24. 1 17
      fs-user-app/src/main/java/com/fs/app/controller/VideoTestController.java

+ 1 - 16
fs-admin/src/main/java/com/fs/course/task/VideoTask.java

@@ -18,7 +18,6 @@ import com.qcloud.cos.region.Region;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -36,7 +35,6 @@ public class VideoTask {
     private static final String FAVORITE_KEY_PREFIX = "favorite:video:";
     private static final String NO_FAVORITE_KEY_PREFIX = "nofavorite:video:";
     private static final String COMMENT_KEY_PREFIX = "comment:video:";
-    private static final String VIDEO_COMMENT_COUNT_KEY_PATTERN = "comment:count:video:*";
     private static final String COMMENT_REPLY_COUNT_KEY_PATTERN = "reply:count:comment:*";
     @Autowired
     private FsUserVideoMapper videoMapper;
@@ -51,8 +49,6 @@ public class VideoTask {
     @Autowired
     private RedisCache redisCache;
     @Autowired
-    private RedisTemplate<String, Object> redisTemplate;
-    @Autowired
     private FsUserCourseVideoMapper courseVideoMapper;
     @Autowired
     private FsCourseRedPacketLogMapper redPacketLogMapper;
@@ -156,18 +152,7 @@ public class VideoTask {
 
     //同步评论数量
     public void syncCommentCountToDatabase() {
-        Set<String> keys = redisTemplate.keys(VIDEO_COMMENT_COUNT_KEY_PATTERN);
-        if (keys != null) {
-            for (String key : keys) {
-                String videoIdStr = key.split(":")[3];
-                Long videoId = Long.parseLong(videoIdStr);
-                Integer commentCount = (Integer) redisTemplate.opsForValue().get(key);
-                if (commentCount != null) {
-                    videoMapper.updateCommentCount(videoId, commentCount);
-                    redisTemplate.delete(key);
-                }
-            }
-        }
+        videoCommentService.syncCommentCountToDatabase();
     }
 
     //同步评论

+ 15 - 0
fs-common/src/main/java/com/fs/common/constant/CourseWatchKeysConstant.java

@@ -0,0 +1,15 @@
+package com.fs.common.constant;
+
+/**
+ * H5 微信看课 Redis key(与 {@link com.fs.course.utils.H5WxUserWatchRedisUtil} 配合使用)
+ */
+public final class CourseWatchKeysConstant {
+
+    private CourseWatchKeysConstant() {
+    }
+
+    public static final String H5_WX_HEARTBEAT_PREFIX = "h5wxuser:watch:heartbeat:";
+    public static final String H5_WX_HEARTBEAT_INDEX = "h5wxuser:watch:heartbeat:index";
+    public static final String H5_WX_DURATION_PREFIX = "h5wxuser:watch:duration:";
+    public static final String H5_WX_DURATION_INDEX = "h5wxuser:watch:duration:index";
+}

+ 26 - 0
fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java

@@ -16,6 +16,20 @@ public class LiveKeysConstant {
     public static final Integer LIVE_HOME_PAGE_LIST_EXPIRE = 300; //首页缓存过期时间
     public static final String LIVE_WATCH_USERS = "live:watch:users:%s"; //在线人数
     public static final String LIVE_COUPON_NUM = "live:coupon:num:%s"; //直播间优惠券数量
+    /** 优惠券余量缓存 key 索引(勿用 KEYS live:coupon:num:*) */
+    public static final String LIVE_COUPON_NUM_INDEX = "live:coupon:num:index";
+    public static final String LIVE_COUPON_NUM_PREFIX = "live:coupon:num:";
+
+    public static String liveCouponNumKey(Long couponIssueId) {
+        return String.format(LIVE_COUPON_NUM, couponIssueId);
+    }
+
+    public static Long parseCouponIssueIdFromKey(String key) {
+        if (key == null || !key.startsWith(LIVE_COUPON_NUM_PREFIX)) {
+            return null;
+        }
+        return Long.parseLong(key.substring(LIVE_COUPON_NUM_PREFIX.length()));
+    }
 
     public static final String LIVE_HOME_PAGE_DETAIL = "live:detail:%s"; //直播间详情
     public static final Integer LIVE_HOME_PAGE_DETAIL_EXPIRE = 300; //直播间详情过期时间
@@ -37,8 +51,20 @@ public class LiveKeysConstant {
     public static final Integer PRODUCT_DETAIL_CACHE_EXPIRE = 300; //商品详情缓存过期时间(秒)
 
     public static final String LIVE_TAG_MARK_CACHE = "live:tag:mark:%s"; //直播间打标签缓存,存储直播间ID、开始时间和视频时长
+    /** 打标签缓存 key 索引(勿用 KEYS live:tag:mark:*) */
+    public static final String LIVE_TAG_MARK_INDEX = "live:tag:mark:index";
+
+    public static String liveTagMarkKey(Long liveId) {
+        return String.format(LIVE_TAG_MARK_CACHE, liveId);
+    }
     //记录用户观看直播间信息 直播间id、用户id、外部联系人id、qwUserId
     public static final String LIVE_USER_WATCH_LOG_CACHE = "live:user:watch:log:%s:%s:%s:%s";
+    /** 用户观看日志活跃时间缓存索引(勿用 KEYS live:user:watch:log:*) */
+    public static final String LIVE_USER_WATCH_LOG_INDEX = "live:user:watch:log:index";
+
+    public static String liveUserWatchLogKey(Long liveId, Long userId, Long externalContactId, Long qwUserId) {
+        return String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId, externalContactId, qwUserId);
+    }
 
     /** 直播评论飘屏/置顶全局配置缓存(单条) */
     public static final String LIVE_COMMENT_FEATURE_CONFIG_ROW = "live:comment:feature:config:row";

+ 13 - 0
fs-common/src/main/java/com/fs/common/constant/VideoCommentKeysConstant.java

@@ -0,0 +1,13 @@
+package com.fs.common.constant;
+
+/**
+ * 短视频评论数 Redis key(与 {@link com.fs.course.utils.VideoCommentCountRedisUtil} 配合使用)
+ */
+public final class VideoCommentKeysConstant {
+
+    private VideoCommentKeysConstant() {
+    }
+
+    public static final String VIDEO_COMMENT_COUNT_PREFIX = "comment:count:video:";
+    public static final String VIDEO_COMMENT_COUNT_INDEX = "comment:count:video:index";
+}

+ 49 - 5
fs-common/src/main/java/com/fs/common/utils/redis/LiveDelayedTaskRedisUtil.java

@@ -10,7 +10,7 @@ import java.util.HashSet;
 import java.util.Set;
 
 /**
- * 直播延时任务 ZSet 索引:用 Set 维护活跃 ZSet key,避免生产环境 KEYS 全库扫描。
+ * 直播 Redis 活跃 key 索引:用 Set 维护待扫描的 ZSet / 缓存 key,避免生产环境 KEYS 全库扫描。
  */
 @Component
 public class LiveDelayedTaskRedisUtil {
@@ -67,15 +67,59 @@ public class LiveDelayedTaskRedisUtil {
     }
 
     public Set<String> listAutoTaskZSetKeys() {
-        return listZSetKeys(LiveKeysConstant.LIVE_AUTO_TASK_INDEX);
+        return listIndexedKeys(LiveKeysConstant.LIVE_AUTO_TASK_INDEX);
     }
 
     public Set<String> listLotteryTaskZSetKeys() {
-        return listZSetKeys(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX);
+        return listIndexedKeys(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX);
     }
 
     public Set<String> listRedTaskZSetKeys() {
-        return listZSetKeys(LiveKeysConstant.LIVE_RED_TASK_INDEX);
+        return listIndexedKeys(LiveKeysConstant.LIVE_RED_TASK_INDEX);
+    }
+
+    public String tagMarkKey(Long liveId) {
+        return LiveKeysConstant.liveTagMarkKey(liveId);
+    }
+
+    public void trackTagMark(Long liveId) {
+        track(LiveKeysConstant.LIVE_TAG_MARK_INDEX, tagMarkKey(liveId));
+    }
+
+    public void untrackTagMark(Long liveId) {
+        untrack(LiveKeysConstant.LIVE_TAG_MARK_INDEX, tagMarkKey(liveId));
+    }
+
+    public Set<String> listTagMarkKeys() {
+        return listIndexedKeys(LiveKeysConstant.LIVE_TAG_MARK_INDEX);
+    }
+
+    public String couponNumKey(Long couponIssueId) {
+        return LiveKeysConstant.liveCouponNumKey(couponIssueId);
+    }
+
+    public void trackCouponNum(Long couponIssueId) {
+        track(LiveKeysConstant.LIVE_COUPON_NUM_INDEX, couponNumKey(couponIssueId));
+    }
+
+    public void untrackCouponNum(Long couponIssueId) {
+        untrack(LiveKeysConstant.LIVE_COUPON_NUM_INDEX, couponNumKey(couponIssueId));
+    }
+
+    public Set<String> listCouponNumKeys() {
+        return listIndexedKeys(LiveKeysConstant.LIVE_COUPON_NUM_INDEX);
+    }
+
+    public void trackUserWatchLog(String cacheKey) {
+        track(LiveKeysConstant.LIVE_USER_WATCH_LOG_INDEX, cacheKey);
+    }
+
+    public void untrackUserWatchLog(String cacheKey) {
+        untrack(LiveKeysConstant.LIVE_USER_WATCH_LOG_INDEX, cacheKey);
+    }
+
+    public Set<String> listUserWatchLogKeys() {
+        return listIndexedKeys(LiveKeysConstant.LIVE_USER_WATCH_LOG_INDEX);
     }
 
     private void track(String indexKey, String zsetKey) {
@@ -100,7 +144,7 @@ public class LiveDelayedTaskRedisUtil {
         }
     }
 
-    private Set<String> listZSetKeys(String indexKey) {
+    private Set<String> listIndexedKeys(String indexKey) {
         Set<Object> members = redisCache.redisTemplate.opsForSet().members(indexKey);
         if (members == null || members.isEmpty()) {
             return Collections.emptySet();

+ 178 - 0
fs-common/src/main/java/com/fs/common/utils/redis/RedisActiveKeyIndexRepairService.java

@@ -0,0 +1,178 @@
+package com.fs.common.utils.redis;
+
+import com.fs.common.constant.CourseWatchKeysConstant;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.constant.VideoCommentKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.RedisCallback;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 使用 SCAN 将 Redis 中真实存在的活跃 key 与 Set 索引对账(兜底,避免漏 track)。
+ */
+@Service
+public class RedisActiveKeyIndexRepairService {
+
+    private static final Logger log = LoggerFactory.getLogger(RedisActiveKeyIndexRepairService.class);
+
+    private static final String REPAIR_LOCK_KEY = "redis:active_key_index:repair:lock";
+    private static final int SCAN_COUNT = 500;
+    private static final int LOCK_MINUTES = 45;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 修补全部已注册的索引(多节点仅一个实例执行)。
+     */
+    public void repairAllIndexes() {
+        if (!tryAcquireRepairLock()) {
+            log.info("Redis 活跃 key 索引修补跳过:其他节点正在执行");
+            return;
+        }
+        try {
+            log.info("Redis 活跃 key 索引修补开始");
+            List<RepairResult> results = new ArrayList<>();
+            for (IndexRepairTarget target : repairTargets()) {
+                results.add(repairIndex(target));
+            }
+            log.info("Redis 活跃 key 索引修补完成: {}", results);
+        } catch (Exception e) {
+            log.error("Redis 活跃 key 索引修补失败", e);
+        } finally {
+            redisCache.deleteObject(REPAIR_LOCK_KEY);
+        }
+    }
+
+    public RepairResult repairIndex(IndexRepairTarget target) {
+        Set<String> scannedKeys = scanKeys(target.scanPattern);
+        int added = 0;
+        for (String cacheKey : scannedKeys) {
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                Long addedCount = redisCache.redisTemplate.opsForSet().add(target.indexKey, cacheKey);
+                if (addedCount != null && addedCount > 0) {
+                    added++;
+                }
+            }
+        }
+
+        int removed = 0;
+        Set<Object> members = redisCache.redisTemplate.opsForSet().members(target.indexKey);
+        if (members != null) {
+            for (Object member : members) {
+                String cacheKey = member.toString();
+                if (!Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                    redisCache.redisTemplate.opsForSet().remove(target.indexKey, cacheKey);
+                    removed++;
+                }
+            }
+        }
+        return new RepairResult(target.name, target.scanPattern, target.indexKey, scannedKeys.size(), added, removed);
+    }
+
+    private List<IndexRepairTarget> repairTargets() {
+        List<IndexRepairTarget> targets = new ArrayList<>();
+        targets.add(new IndexRepairTarget("liveAutoTask",
+                LiveKeysConstant.LIVE_AUTO_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_AUTO_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveLotteryTask",
+                LiveKeysConstant.LIVE_LOTTERY_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveRedTask",
+                LiveKeysConstant.LIVE_RED_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_RED_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveTagMark",
+                LiveKeysConstant.LIVE_TAG_MARK_CACHE.replace("%s", "*"),
+                LiveKeysConstant.LIVE_TAG_MARK_INDEX));
+        targets.add(new IndexRepairTarget("liveCouponNum",
+                LiveKeysConstant.LIVE_COUPON_NUM_PREFIX + "*",
+                LiveKeysConstant.LIVE_COUPON_NUM_INDEX));
+        targets.add(new IndexRepairTarget("liveUserWatchLog",
+                "live:user:watch:log:*",
+                LiveKeysConstant.LIVE_USER_WATCH_LOG_INDEX));
+        targets.add(new IndexRepairTarget("h5wxHeartbeat",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_PREFIX + "*",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_INDEX));
+        targets.add(new IndexRepairTarget("h5wxDuration",
+                CourseWatchKeysConstant.H5_WX_DURATION_PREFIX + "*",
+                CourseWatchKeysConstant.H5_WX_DURATION_INDEX));
+        targets.add(new IndexRepairTarget("videoCommentCount",
+                VideoCommentKeysConstant.VIDEO_COMMENT_COUNT_PREFIX + "*",
+                VideoCommentKeysConstant.VIDEO_COMMENT_COUNT_INDEX));
+        return targets;
+    }
+
+    private Set<String> scanKeys(String pattern) {
+        Set<String> keys = new HashSet<>();
+        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(SCAN_COUNT).build();
+        redisCache.redisTemplate.execute((RedisCallback<Void>) connection -> {
+            RedisSerializer<String> keySerializer = redisCache.redisTemplate.getStringSerializer();
+            try (Cursor<byte[]> cursor = connection.scan(options)) {
+                while (cursor.hasNext()) {
+                    String key = keySerializer.deserialize(cursor.next());
+                    if (key != null) {
+                        keys.add(key);
+                    }
+                }
+            } catch (IOException e) {
+                throw new RuntimeException("Redis SCAN failed, pattern=" + pattern, e);
+            }
+            return null;
+        });
+        return keys;
+    }
+
+    private boolean tryAcquireRepairLock() {
+        return redisCache.setIfAbsent(REPAIR_LOCK_KEY, "1", LOCK_MINUTES, TimeUnit.MINUTES);
+    }
+
+    public static final class IndexRepairTarget {
+        private final String name;
+        private final String scanPattern;
+        private final String indexKey;
+
+        public IndexRepairTarget(String name, String scanPattern, String indexKey) {
+            this.name = name;
+            this.scanPattern = scanPattern;
+            this.indexKey = indexKey;
+        }
+    }
+
+    public static final class RepairResult {
+        private final String name;
+        private final String scanPattern;
+        private final String indexKey;
+        private final int scannedCount;
+        private final int addedToIndex;
+        private final int removedFromIndex;
+
+        public RepairResult(String name, String scanPattern, String indexKey,
+                            int scannedCount, int addedToIndex, int removedFromIndex) {
+            this.name = name;
+            this.scanPattern = scanPattern;
+            this.indexKey = indexKey;
+            this.scannedCount = scannedCount;
+            this.addedToIndex = addedToIndex;
+            this.removedFromIndex = removedFromIndex;
+        }
+
+        @Override
+        public String toString() {
+            return name + "{scanned=" + scannedCount + ", added=" + addedToIndex + ", removed=" + removedFromIndex + "}";
+        }
+    }
+}

+ 1 - 1
fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -223,7 +223,7 @@ public class SendMsg {
                 log.error("{}已有发送:{}, :{}", qwUser.getQwUserName(), qwSopLogs.getId(), time);
                 continue;
             }
-            redisCache.setCacheObject(key, System.currentTimeMillis(), 24, TimeUnit.HOURS);
+            redisCache.setCacheObject(key, System.currentTimeMillis(), 1, TimeUnit.HOURS);
             List<QwPushCount> pushCountList = qwPushCountMapper.selectQwPushCountLists();
             Map<Integer, List<QwPushCount>> pushMap = pushCountList.stream().collect(Collectors.groupingBy(QwPushCount::getType));
             // 循环发送消息里面的每一条消息

+ 48 - 22
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -9,6 +9,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.redis.LiveDelayedTaskRedisUtil;
+import com.fs.common.utils.redis.RedisActiveKeyIndexRepairService;
 import com.fs.framework.aspectj.lock.DistributeLock;
 import com.fs.erp.service.FsJstAftersalePushService;
 import com.fs.his.service.IFsUserService;
@@ -40,7 +41,6 @@ import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import static com.fs.common.constant.LiveKeysConstant.*;
-import static com.fs.common.constant.LiveKeysConstant.LIVE_COUPON_NUM;
 import static com.fs.live.websocket.service.WebSocketServer.USER_ENTRY_TIME_KEY;
 
 @Component
@@ -85,6 +85,8 @@ public class Task {
     public FsJstAftersalePushService fsJstAftersalePushService;
     @Autowired
     private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
+    private RedisActiveKeyIndexRepairService redisActiveKeyIndexRepairService;
 
     @Scheduled(cron = "0 0/1 * * * ?")
     @DistributeLock(key = "updateLiveStatusByTime", scene = "task")
@@ -198,8 +200,9 @@ public class Task {
                         tagMarkInfo.put("startTime", live.getStartTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
                         tagMarkInfo.put("videoDuration", videoDuration);
 
-                        String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                        String tagMarkKey = liveDelayedTaskRedisUtil.tagMarkKey(live.getLiveId());
                         redisCache.setCacheObject(tagMarkKey, JSON.toJSONString(tagMarkInfo), 24, TimeUnit.HOURS);
+                        liveDelayedTaskRedisUtil.trackTagMark(live.getLiveId());
                         log.info("直播间开启,已加入打标签缓存: liveId={}, startTime={}, videoDuration={}",
                                 live.getLiveId(), live.getStartTime(), videoDuration);
                     }
@@ -228,8 +231,9 @@ public class Task {
 
                 // 删除打标签缓存
                 try {
-                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, live.getLiveId());
+                    String tagMarkKey = liveDelayedTaskRedisUtil.tagMarkKey(live.getLiveId());
                     redisCache.deleteObject(tagMarkKey);
+                    liveDelayedTaskRedisUtil.untrackTagMark(live.getLiveId());
                     log.info("直播间结束,已删除打标签缓存: liveId={}", live.getLiveId());
                 } catch (Exception e) {
                     log.error("删除直播间打标签缓存失败: liveId={}, error={}", live.getLiveId(), e.getMessage(), e);
@@ -608,17 +612,23 @@ public class Task {
             }
             /*// 更新数据库
             liveDataService.updateLiveData(liveData);*/
-        Set<String> keys = redisCache.redisTemplate.keys(String.format(LIVE_COUPON_NUM, "*"));
-        if (keys != null && !keys.isEmpty()) {
-            for (String key : keys) {
-                Object o = redisCache.redisTemplate.opsForValue().get(String.format(LIVE_COUPON_NUM, key));
-                if (o != null) {
-                    LiveCouponIssue updateEntity = new LiveCouponIssue();
-                    updateEntity.setId(Long.valueOf(key));
-                    updateEntity.setRemainCount(Long.parseLong(o.toString()));
-                    liveCouponIssueService.updateLiveCouponIssue(updateEntity);
+        for (String key : liveDelayedTaskRedisUtil.listCouponNumKeys()) {
+            Object o = redisCache.getCacheObject(key);
+            if (o == null) {
+                Long issueId = LiveKeysConstant.parseCouponIssueIdFromKey(key);
+                if (issueId != null) {
+                    liveDelayedTaskRedisUtil.untrackCouponNum(issueId);
                 }
+                continue;
             }
+            Long issueId = LiveKeysConstant.parseCouponIssueIdFromKey(key);
+            if (issueId == null) {
+                continue;
+            }
+            LiveCouponIssue updateEntity = new LiveCouponIssue();
+            updateEntity.setId(issueId);
+            updateEntity.setRemainCount(Long.parseLong(o.toString()));
+            liveCouponIssueService.updateLiveCouponIssue(updateEntity);
         }
     }
 
@@ -640,11 +650,8 @@ public class Task {
     public void scanLiveTagMark() {
         try {
 
-            // 获取所有打标签缓存的key
-            String pattern = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, "*");
-            Set<String> keys = redisCache.redisTemplate.keys(pattern);
-
-            if (keys == null || keys.isEmpty()) {
+            Set<String> keys = liveDelayedTaskRedisUtil.listTagMarkKeys();
+            if (keys.isEmpty()) {
                 return;
             }
 
@@ -657,6 +664,10 @@ public class Task {
                     // 从Redis获取直播间信息
                     Object cacheValue = redisCache.getCacheObject(key);
                     if (cacheValue == null) {
+                        Long staleLiveId = LiveKeysConstant.parseLiveIdFromTaskZSetKey(key);
+                        if (staleLiveId != null) {
+                            liveDelayedTaskRedisUtil.untrackTagMark(staleLiveId);
+                        }
                         continue;
                     }
 
@@ -792,8 +803,9 @@ public class Task {
             // 删除已处理的直播间缓存
             for (Long liveId : processedLiveIds) {
                 try {
-                    String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, liveId);
+                    String tagMarkKey = liveDelayedTaskRedisUtil.tagMarkKey(liveId);
                     redisCache.deleteObject(tagMarkKey);
+                    liveDelayedTaskRedisUtil.untrackTagMark(liveId);
                 } catch (Exception e) {
                     log.error("删除直播间打标签缓存失败: liveId={}, error={}", liveId, e.getMessage(), e);
                 }
@@ -883,9 +895,10 @@ public class Task {
                                 continue;
                             }
                             //更新最新用户活跃时间
-                            String liveUserWatchLogKey = String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId,externalContactId,qwUserId);
+                            String liveUserWatchLogKey = LiveKeysConstant.liveUserWatchLogKey(liveId, userId, externalContactId, qwUserId);
                             LocalDateTime now = LocalDateTime.now();
-                            redisCache.setCacheObject(liveUserWatchLogKey,formatter.format(now),5,TimeUnit.MINUTES);
+                            redisCache.setCacheObject(liveUserWatchLogKey, formatter.format(now), 5, TimeUnit.MINUTES);
+                            liveDelayedTaskRedisUtil.trackUserWatchLog(liveUserWatchLogKey);
                             // 使用 updateLiveWatchLogTypeByDuration 的逻辑更新观看记录状态
                             updateLiveWatchLogTypeByDuration(liveId, userId, qwUserId, externalContactId,
                                     onlineSeconds, totalVideoDuration, updateLog);
@@ -986,14 +999,18 @@ public class Task {
     @DistributeLock(key = "updateLiveWatchUserStatus", scene = "task")
     public void updateLiveWatchUserStatus() {
         try {
-            Set<String> keys = redisCache.redisTemplate.keys("live:user:watch:log:*");
+            Set<String> keys = liveDelayedTaskRedisUtil.listUserWatchLogKeys();
             LocalDateTime now = LocalDateTime.now();
             DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
             List<LiveWatchLog> updateLog = new ArrayList<>();
-            if (keys != null && !keys.isEmpty()) {
+            if (!keys.isEmpty()) {
                 for (String key : keys) {
                     String[] split = key.split(":");
                     String cacheTime = redisCache.getCacheObject(key);
+                    if (StringUtils.isBlank(cacheTime)) {
+                        liveDelayedTaskRedisUtil.untrackUserWatchLog(key);
+                        continue;
+                    }
                     //判断缓存的值是否已经距离现在超过一分钟
                     if (StringUtils.isNotBlank(cacheTime)) {
                         try {
@@ -1112,4 +1129,13 @@ public class Task {
 //            log.error("批量同步观看时长任务异常", e);
 //        }
 //    }
+
+    /**
+     * 每天凌晨 1 点:SCAN 修补活跃 key 索引(多节点由 Redis 锁保证只执行一次)
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    @DistributeLock(key = "repairRedisActiveKeyIndexes", scene = "task")
+    public void repairRedisActiveKeyIndexes() {
+        redisActiveKeyIndexRepairService.repairAllIndexes();
+    }
 }

+ 12 - 6
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -735,16 +735,20 @@ public class WebSocketServer {
         msg.setStatus(status);
         Long couponIssueId = jsonObject.getLong("couponIssueId");
         // ①  检查  缓存是否存在  ② 如果是发布 放入缓存 ③ 删除缓存
+        String couponNumKey = LiveKeysConstant.liveCouponNumKey(couponIssueId);
+        LiveDelayedTaskRedisUtil delayedTaskRedisUtil = SpringUtils.getBean(LiveDelayedTaskRedisUtil.class);
         if (status == 1) {
-            Object cacheObject = redisCache.getCacheObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM , couponIssueId));
+            Object cacheObject = redisCache.getCacheObject(couponNumKey);
             if (cacheObject == null) {
                 LiveCouponIssue liveCoupon = liveCouponIssueService.selectLiveCouponIssueById(couponIssueId);
                 if (liveCoupon != null) {
-                    redisCache.setCacheObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM , couponIssueId), liveCoupon.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                    redisCache.setCacheObject(couponNumKey, liveCoupon.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                    delayedTaskRedisUtil.trackCouponNum(couponIssueId);
                 }
             }
         } else {
-            redisCache.deleteObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM , couponIssueId));
+            redisCache.deleteObject(couponNumKey);
+            delayedTaskRedisUtil.untrackCouponNum(couponIssueId);
         }
         // 管理员消息插队
         enqueueMessage(liveId, JSONObject.toJSONString(R.ok().put("data", msg)), true);
@@ -1282,7 +1286,8 @@ public class WebSocketServer {
                 LiveCouponIssue liveCouponIssue = liveCouponIssueService.selectLiveCouponIssueByCouponId(liveCoupon.getCouponId());
                 LiveCouponIssueRelation relation = liveCouponMapper.selectCouponRelation(task.getLiveId(), liveCouponIssue.getId());
                 if (liveCoupon != null) {
-                    redisCache.setCacheObject(String.format(LiveKeysConstant.LIVE_COUPON_NUM , liveCouponIssue.getId()), liveCouponIssue.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                    redisCache.setCacheObject(LiveKeysConstant.liveCouponNumKey(liveCouponIssue.getId()), liveCouponIssue.getRemainCount().intValue(), 30, TimeUnit.MINUTES);
+                    SpringUtils.getBean(LiveDelayedTaskRedisUtil.class).trackCouponNum(liveCouponIssue.getId());
                 }
                 HashMap<String, Object> data = new HashMap<>();
                 data.put("liveId", task.getLiveId());
@@ -1422,9 +1427,10 @@ public class WebSocketServer {
                     if (log.getLogType() == null || log.getLogType() != 2) {
                         log.setLogType(1);
                         liveWatchLogService.updateLiveWatchLog(log);
-                        String liveUserWatchLogKey = String.format(LIVE_USER_WATCH_LOG_CACHE, liveId, userId,externalContactId,qwUserId);
+                        String liveUserWatchLogKey = LiveKeysConstant.liveUserWatchLogKey(liveId, userId, externalContactId, qwUserId);
                         LocalDateTime now = LocalDateTime.now();
-                        redisCache.setCacheObject(liveUserWatchLogKey,formatter.format(now),5,TimeUnit.MINUTES);
+                        redisCache.setCacheObject(liveUserWatchLogKey, formatter.format(now), 5, TimeUnit.MINUTES);
+                        SpringUtils.getBean(LiveDelayedTaskRedisUtil.class).trackUserWatchLog(liveUserWatchLogKey);
                     }
                 }
             }

+ 16 - 4
fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java

@@ -5,6 +5,7 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsCourseLinkService;
+import com.fs.common.utils.redis.RedisActiveKeyIndexRepairService;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.system.service.ISysConfigService;
@@ -37,6 +38,9 @@ public class CourseWatchLogScheduler {
     @Autowired
     RedisCache redisCache;
 
+    @Autowired
+    private RedisActiveKeyIndexRepairService redisActiveKeyIndexRepairService;
+
     @Autowired
     private FsUserCourseVideoMapper courseVideoMapper;
 
@@ -171,7 +175,7 @@ public class CourseWatchLogScheduler {
 
     }
 
-    @Scheduled(fixedRate = 30000) // 每分钟执行一次
+    @Scheduled(fixedRate = 30000) // 每30s执行一次
     public void checkFsUserWatchStatus() {
         // 尝试设置标志为 true,表示任务开始执行
         if (!isRunning4.compareAndSet(false, true)) {
@@ -192,8 +196,16 @@ public class CourseWatchLogScheduler {
 
     }
 
-
-
-
+    /**
+     * 每天凌晨 1 点:SCAN 修补活跃 key 索引(与业务 track 配合,兜底漏登记/发版空窗)
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    public void repairRedisActiveKeyIndexes() {
+        try {
+            redisActiveKeyIndexRepairService.repairAllIndexes();
+        } catch (Exception e) {
+            log.error("Redis 活跃 key 索引修补定时任务失败", e);
+        }
+    }
 
 }

+ 3 - 4
fs-service/src/main/java/com/fs/course/dto/RedisKeyInfo.java

@@ -1,6 +1,7 @@
 package com.fs.course.dto;
 
 import com.fs.course.enums.RedisKeyType;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import lombok.Data;
 
 @Data
@@ -41,16 +42,14 @@ public class RedisKeyInfo {
      * 构建时长key
      */
     public String buildDurationKey() {
-        return String.format("h5wxuser:watch:duration:%d:%d:%d",
-                userId, videoId, companyUserId);
+        return H5WxUserWatchRedisUtil.durationKey(userId, videoId, companyUserId);
     }
 
     /**
      * 构建心跳key
      */
     public String buildHeartbeatKey() {
-        return String.format("h5wxuser:watch:heartbeat:%d:%d:%d",
-                userId, videoId, companyUserId);
+        return H5WxUserWatchRedisUtil.heartbeatKey(userId, videoId, companyUserId);
     }
 
     @Override

+ 10 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java

@@ -129,6 +129,16 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
 
     void scheduleUpdateDurationToDatabase();
 
+    /**
+     * H5 微信看课:上报时长时若已达完课条件则立即写库(逻辑与定时任务一致,仅提前完课落库)
+     */
+    void syncH5WxUserWatchProgressOnFinish(Long userId, Long videoId, Long companyUserId, Long duration);
+
+    /**
+     * 企微看课:上报时长时若已达完课条件则立即写库(逻辑与 scheduleBatchUpdateToDatabase 单条一致)
+     */
+    void syncQwUserWatchProgressOnFinish(Long qwUserId, Long qwExternalContactId, Long videoId, Long duration);
+
     void checkFsUserWatchStatus();
 
     /**

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserVideoCommentService.java

@@ -80,4 +80,9 @@ public interface IFsUserVideoCommentService
 
     void syncRepliesToDatabase();
 
+    /**
+     * 将 Redis 中待同步的视频评论数写入数据库(使用索引,避免 KEYS)
+     */
+    void syncCommentCountToDatabase();
+
 }

+ 38 - 27
fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java

@@ -9,6 +9,7 @@ import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.param.FsUserCourseVideoAddKfUParam;
 import com.fs.course.service.ICourseRepeatByProjectService;
+import com.fs.course.support.CourseProjectEquivalence;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.his.domain.FsUser;
@@ -57,12 +58,13 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
             log.debug("看课重粉判断跳过:课程未关联项目,userId={}", param.getUserId());
             return;
         }
-        boolean projectRepeat = isRepeatWatchByProject(param, projectId);
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        boolean projectRepeat = isRepeatWatchByProject(param, equivalentProjectIds);
         boolean userAlreadyRepeat = fsUser.getIsRepeat() != null && fsUser.getIsRepeat() == 1;
-        log.info("看课重粉判断(按项目):userId={}, projectId={}, projectRepeat={}, userAlreadyRepeat={}",
-                param.getUserId(), projectId, projectRepeat, userAlreadyRepeat);
+        log.info("看课重粉判断(按项目):userId={}, projectId={}, equivalentProjectIds={}, projectRepeat={}, userAlreadyRepeat={}",
+                param.getUserId(), projectId, equivalentProjectIds, projectRepeat, userAlreadyRepeat);
         if (projectRepeat || userAlreadyRepeat) {
-            markRepeatFansByProject(fsUser, param, projectId);
+            markRepeatFansByProject(fsUser, param, equivalentProjectIds);
             return;
         }
         if (param.getQwExternalId() != null) {
@@ -89,32 +91,39 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         return 0L;
     }
 
-    private boolean isRepeatWatchByProject(FsUserCourseVideoAddKfUParam param, Long projectId) {
+    private boolean isRepeatWatchByProject(FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
+        if (CollectionUtils.isEmpty(equivalentProjectIds)) {
+            return false;
+        }
         Long userId = param.getUserId();
         Long currentCompanyUserId = param.getCompanyUserId();
         Long currentQwUserId = parseQwUserId(param.getQwUserId());
 
-        FsUserCompanyQw existBind = fsUserCompanyQwMapper.selectByUserAndProject(userId, projectId);
-        if (existBind != null) {
-            if (currentCompanyUserId != null && existBind.getCompanyUserId() != null
-                    && !existBind.getCompanyUserId().equals(currentCompanyUserId)) {
-                return true;
-            }
-            if (currentQwUserId != null && existBind.getQwUserId() != null
-                    && !existBind.getQwUserId().equals(currentQwUserId)) {
-                return true;
+        for (Long projectId : equivalentProjectIds) {
+            FsUserCompanyQw existBind = fsUserCompanyQwMapper.selectByUserAndProject(userId, projectId);
+            if (existBind != null) {
+                if (currentCompanyUserId != null && existBind.getCompanyUserId() != null
+                        && !existBind.getCompanyUserId().equals(currentCompanyUserId)) {
+                    return true;
+                }
+                if (currentQwUserId != null && existBind.getQwUserId() != null
+                        && !existBind.getQwUserId().equals(currentQwUserId)) {
+                    return true;
+                }
             }
         }
 
-        FsUserCompanyUser userCompanyUser = userCompanyUserService.selectByUserIdAndProjectId(userId, projectId);
-        if (userCompanyUser != null && currentCompanyUserId != null
-                && userCompanyUser.getCompanyUserId() != null
-                && !userCompanyUser.getCompanyUserId().equals(currentCompanyUserId)) {
-            return true;
+        for (Long projectId : equivalentProjectIds) {
+            FsUserCompanyUser userCompanyUser = userCompanyUserService.selectByUserIdAndProjectId(userId, projectId);
+            if (userCompanyUser != null && currentCompanyUserId != null
+                    && userCompanyUser.getCompanyUserId() != null
+                    && !userCompanyUser.getCompanyUserId().equals(currentCompanyUserId)) {
+                return true;
+            }
         }
 
         List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
-                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", userId).eq("project_id", projectId));
+                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", userId).in("project_id", equivalentProjectIds));
         if (CollectionUtils.isNotEmpty(binds)) {
             if (currentQwUserId != null && binds.stream().map(FsUserCompanyBind::getQwUserId).filter(Objects::nonNull)
                     .anyMatch(qwId -> !qwId.equals(currentQwUserId))) {
@@ -127,7 +136,7 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         }
 
         QueryWrapper<FsCourseWatchLog> watchWrapper = new QueryWrapper<FsCourseWatchLog>()
-                .eq("project", projectId)
+                .in("project", equivalentProjectIds)
                 .isNotNull("company_user_id");
         watchWrapper.and(w -> {
             w.eq("user_id", userId);
@@ -155,7 +164,7 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         }
     }
 
-    private void markRepeatFansByProject(FsUser fsUser, FsUserCourseVideoAddKfUParam param, Long projectId) {
+    private void markRepeatFansByProject(FsUser fsUser, FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
         List<QwExternalContact> toUpdate = new ArrayList<>();
         if (param.getQwExternalId() != null) {
             QwExternalContact current = qwExternalContactMapper.selectById(param.getQwExternalId());
@@ -165,7 +174,7 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
             }
         }
         List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
-                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", param.getUserId()).eq("project_id", projectId));
+                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", param.getUserId()).in("project_id", equivalentProjectIds));
         if (CollectionUtils.isNotEmpty(binds)) {
             Set<Long> contactIds = binds.stream().map(FsUserCompanyBind::getQwExternalContactId)
                     .filter(Objects::nonNull).collect(Collectors.toSet());
@@ -193,10 +202,12 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
             fsUser.setIsRepeat(1);
             fsUserMapper.updateFsUser(fsUser);
         }
-        FsUserCompanyUser userCompanyUser = userCompanyUserService.selectByUserIdAndProjectId(param.getUserId(), projectId);
-        if (userCompanyUser != null && (userCompanyUser.getIsRepeatFans() == null || userCompanyUser.getIsRepeatFans() != 1)) {
-            userCompanyUser.setIsRepeatFans(1);
-            userCompanyUserService.updateById(userCompanyUser);
+        for (Long projectId : equivalentProjectIds) {
+            FsUserCompanyUser userCompanyUser = userCompanyUserService.selectByUserIdAndProjectId(param.getUserId(), projectId);
+            if (userCompanyUser != null && (userCompanyUser.getIsRepeatFans() == null || userCompanyUser.getIsRepeatFans() != 1)) {
+                userCompanyUser.setIsRepeatFans(1);
+                userCompanyUserService.updateById(userCompanyUser);
+            }
         }
     }
 

+ 120 - 69
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -22,6 +22,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RedisKeyScanner;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
@@ -107,6 +108,8 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private H5WxUserWatchRedisUtil h5WxUserWatchRedisUtil;
+    @Autowired
     private IQwExternalContactCacheService qwExternalContactCacheService;
     @Autowired
     private QwWatchLogMapper qwWatchLogMapper;
@@ -381,69 +384,141 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public void scheduleUpdateDurationToDatabase() {
         log.info("WXH5-开始更新会员看课时长,检查完课>>>>>>");
-        //读取所有的key
-        Collection<String> keys = redisCache.keys("h5wxuser:watch:duration:*");
-
-        //读取看课配置
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-
+        Set<String> keys = h5WxUserWatchRedisUtil.listDurationKeys();
+        CourseConfig config = loadCourseConfig();
         List<FsCourseWatchLog> logs = new ArrayList<>();
         for (String key : keys) {
-            //取key中数据
             String[] parts = key.split(":");
-            Long userId=null;
-            Long videoId=null;
-            Long companyUserId=null;
+            Long userId;
+            Long videoId;
+            Long companyUserId;
             try {
                 userId = Long.parseLong(parts[3]);
                 videoId = Long.parseLong(parts[4]);
                 companyUserId = Long.parseLong(parts[5]);
-
-            }catch (Exception e){
+            } catch (Exception e) {
                 log.error("key中id为null:{}", key);
                 continue;
             }
             String durationStr = redisCache.getCacheObject(key);
             if (durationStr == null) {
                 log.error("key中数据为null:{}", key);
-                continue;  // 如果 Redis 中没有记录,跳过
+                h5WxUserWatchRedisUtil.untrackDuration(key);
+                continue;
             }
             Long duration = Long.valueOf(durationStr);
-
-            FsCourseWatchLog watchLog = new FsCourseWatchLog();
-            watchLog.setVideoId(videoId);
-            watchLog.setUserId(userId);
-            watchLog.setCompanyUserId(companyUserId);
-            watchLog.setDuration(duration);
-
-            //取对应视频的时长
-            Long videoDuration = 0L;
-            try {
-                videoDuration = getFsUserVideoDuration(videoId);
-            } catch (Exception e) {
-                log.error("视频时长识别错误:{}", key);
+            FsCourseWatchLog watchLog = buildH5WxDurationWatchLogUpdate(userId, videoId, companyUserId, duration, config, key);
+            if (watchLog == null) {
                 continue;
             }
-            if (videoDuration != null && videoDuration != 0) {
-                //判断是否完课
-                long percentage = (duration * 100 / videoDuration);
-                if (percentage >= config.getAnswerRate()) {
-                    watchLog.setLogType(2); // 设置状态为“已完成”checkFsUserWatchStatus
-                    watchLog.setFinishTime(new Date());
-                    String heartbeatKey = "h5wxuser:watch:heartbeat:" + userId + ":" + videoId + ":" + companyUserId;
-                    // 完课删除心跳记录
-                    redisCache.deleteObject(heartbeatKey);
-                    // 完课删除看课时长记录
-                    redisCache.deleteObject(key);
-                }
-            }
-            //集合中增加
             logs.add(watchLog);
         }
         batchUpdateFsUserCourseWatchLog(logs, 100);
     }
 
+    @Override
+    public void syncH5WxUserWatchProgressOnFinish(Long userId, Long videoId, Long companyUserId, Long duration) {
+        if (userId == null || videoId == null || companyUserId == null || duration == null) {
+            return;
+        }
+        CourseConfig config = loadCourseConfig();
+        String durationKey = H5WxUserWatchRedisUtil.durationKey(userId, videoId, companyUserId);
+        FsCourseWatchLog watchLog = buildH5WxDurationWatchLogUpdate(userId, videoId, companyUserId, duration, config, durationKey);
+        if (watchLog == null || watchLog.getLogType() == null || watchLog.getLogType() != 2) {
+            return;
+        }
+        batchUpdateFsUserCourseWatchLog(Collections.singletonList(watchLog), 100);
+        log.info("H5微信看课已达完课阈值,已同步写库: userId={}, videoId={}, companyUserId={}, duration={}",
+                userId, videoId, companyUserId, duration);
+    }
+
+    @Override
+    public void syncQwUserWatchProgressOnFinish(Long qwUserId, Long qwExternalContactId, Long videoId, Long duration) {
+        if (qwUserId == null || qwExternalContactId == null || videoId == null || duration == null) {
+            return;
+        }
+        CourseConfig config = loadCourseConfig();
+        String durationKey = "h5user:watch:duration:" + qwUserId + ":" + qwExternalContactId + ":" + videoId;
+        FsCourseWatchLog watchLog = buildQwDurationWatchLogUpdate(
+                qwUserId, qwExternalContactId, videoId, duration, config, durationKey);
+        if (watchLog.getLogType() == null || watchLog.getLogType() != 2) {
+            return;
+        }
+        batchUpdateFsCourseWatchLog(Collections.singletonList(watchLog), 100);
+        log.info("企微看课已达完课阈值,已同步写库: qwUserId={}, qwExternalContactId={}, videoId={}, duration={}",
+                qwUserId, qwExternalContactId, videoId, duration);
+    }
+
+    /**
+     * 与 scheduleBatchUpdateToDatabase / processKeyBatch(type=1) 单条处理逻辑一致
+     */
+    private FsCourseWatchLog buildQwDurationWatchLogUpdate(Long qwUserId, Long externalId, Long videoId,
+                                                           Long duration, CourseConfig config, String durationRedisKey) {
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setVideoId(videoId);
+        watchLog.setQwUserId(qwUserId);
+        watchLog.setQwExternalContactId(externalId);
+        watchLog.setDuration(duration);
+
+        Long videoDuration;
+        try {
+            videoDuration = getVideoDuration(videoId);
+        } catch (Exception e) {
+            log.error("视频时长识别错误:{}", durationRedisKey);
+            return watchLog;
+        }
+        if (videoDuration != null && videoDuration != 0) {
+            long percentage = (duration * 100 / videoDuration);
+            if (percentage >= config.getAnswerRate()) {
+                watchLog.setLogType(2);
+                watchLog.setFinishTime(new Date());
+                String heartbeatKey = "h5user:watch:heartbeat:" + qwUserId + ":" + externalId + ":" + videoId;
+                redisCache.deleteObject(heartbeatKey);
+                redisCache.deleteObject(durationRedisKey);
+            }
+        }
+        return watchLog;
+    }
+
+    private CourseConfig loadCourseConfig() {
+        String json = configService.selectConfigByKey("course.config");
+        return JSONUtil.toBean(json, CourseConfig.class);
+    }
+
+    /**
+     * 与 scheduleUpdateDurationToDatabase 单条处理逻辑一致(百分比达标即完课并清理 Redis)
+     */
+    private FsCourseWatchLog buildH5WxDurationWatchLogUpdate(Long userId, Long videoId, Long companyUserId,
+                                                             Long duration, CourseConfig config, String durationRedisKey) {
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setVideoId(videoId);
+        watchLog.setUserId(userId);
+        watchLog.setCompanyUserId(companyUserId);
+        watchLog.setDuration(duration);
+
+        Long videoDuration;
+        try {
+            videoDuration = getFsUserVideoDuration(videoId);
+        } catch (Exception e) {
+            log.error("视频时长识别错误:{}", durationRedisKey);
+            return watchLog;
+        }
+        if (videoDuration == null || videoDuration == 0 || config == null || config.getAnswerRate() == null) {
+            return watchLog;
+        }
+        long percentage = (duration * 100 / videoDuration);
+        if (percentage >= config.getAnswerRate()) {
+            watchLog.setLogType(2);
+            watchLog.setFinishTime(new Date());
+            String heartbeatKey = H5WxUserWatchRedisUtil.heartbeatKey(userId, videoId, companyUserId);
+            redisCache.deleteObject(heartbeatKey);
+            h5WxUserWatchRedisUtil.untrackHeartbeat(heartbeatKey);
+            redisCache.deleteObject(durationRedisKey);
+            h5WxUserWatchRedisUtil.untrackDuration(durationRedisKey);
+        }
+        return watchLog;
+    }
+
     public Long getFsUserVideoDuration(Long videoId) {
         //将视频时长也存到redis
         String videoRedisKey = "h5wxuser:video:duration:" + videoId;
@@ -468,8 +543,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public void checkFsUserWatchStatus() {
         log.info("WXH5-开始更新会员看课中断记录>>>>>");
-        // 从 Redis 中获取所有正在看课的用户记录
-        Collection<String> keys = redisCache.keys("h5wxuser:watch:heartbeat:*");
+        Set<String> keys = h5WxUserWatchRedisUtil.listHeartbeatKeys();
         LocalDateTime now = LocalDateTime.now();
         List<FsCourseWatchLog> logs = new ArrayList<>();
         for (String key : keys) {
@@ -481,6 +555,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             // 获取最后心跳时间
             String lastHeartbeatStr = redisCache.getCacheObject(key);
             if (lastHeartbeatStr == null) {
+                h5WxUserWatchRedisUtil.untrackHeartbeat(key);
                 continue; // 如果 Redis 中没有记录,跳过
             }
             LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr);
@@ -494,6 +569,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 watchLog.setLogType(4);
                 // 从 Redis 中删除该记录
                 redisCache.deleteObject(key);
+                h5WxUserWatchRedisUtil.untrackHeartbeat(key);
             } else {
                 watchLog.setLogType(1);
             }
@@ -1100,32 +1176,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                         continue;  // 如果 Redis 中没有记录,跳过
                     }
                     Long duration = Long.valueOf(durationStr);
-
-                    watchLog.setDuration(duration);
-
-                    // 取对应视频的时长
-                    Long videoDuration;
-                    try {
-                        videoDuration = getVideoDuration(videoId);
-                    } catch (Exception e) {
-                        log.error("视频时长识别错误:{}", key);
-                        continue;
-                    }
-
-
-                    if (videoDuration != null && videoDuration != 0) {
-                        // 判断是否完课
-                        long percentage = (duration * 100 / videoDuration);
-                        if (percentage >= config.getAnswerRate()) {
-                            watchLog.setLogType(2); // 设置状态为"已完成"
-                            watchLog.setFinishTime(new Date());
-                            String heartbeatKey = "h5user:watch:heartbeat:" + qwUserId + ":" + externalId + ":" + videoId;
-                            // 完课删除心跳记录
-                            redisCache.deleteObject(heartbeatKey);
-                            // 完课删除看课时长记录
-                            redisCache.deleteObject(key);
-                        }
-                    }
+                    watchLog = buildQwDurationWatchLogUpdate(qwUserId, externalId, videoId, duration, config, key);
                 }else{
                     //检查看课中断
                     // 获取最后心跳时间

+ 29 - 7
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -47,6 +47,7 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.param.newfs.*;
 import com.fs.course.service.*;
 import com.fs.course.vo.*;
@@ -177,6 +178,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
+    private IFsCourseWatchLogService courseWatchLogService;
+    @Autowired
     private ISopUserLogsInfoService iSopUserLogsInfoService;
     @Autowired
     private FsCourseLinkMapper fsCourseLinkMapper;
@@ -210,6 +213,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     RedisCache redisCache;
     @Autowired
+    private H5WxUserWatchRedisUtil h5WxUserWatchRedisUtil;
+    @Autowired
     private ISysConfigService sysConfigService;
 
     @Autowired
@@ -521,6 +526,16 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                 redisCache.setCacheObject(redisKey, param.getDuration().toString(), 2, TimeUnit.HOURS);
             }
             updateHeartbeat(param);
+            if (param.getDuration() != null && param.getQwUserId() != null
+                    && param.getQwExternalId() != null && param.getVideoId() != null) {
+                try {
+                    Long qwUserId = Long.parseLong(param.getQwUserId());
+                    courseWatchLogService.syncQwUserWatchProgressOnFinish(
+                            qwUserId, param.getQwExternalId(), param.getVideoId(), param.getDuration());
+                } catch (NumberFormatException e) {
+                    logger.warn("企微看课完课同步跳过,qwUserId: {}", param.getQwUserId());
+                }
+            }
             return R.ok();
         } catch (Exception e) {
             e.printStackTrace();
@@ -2793,10 +2808,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                 fsCourseWatchLog.setLogType(1);
                 fsCourseWatchLog.setAppId(param.getAppId());
                 courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
-                String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + 0;
+                String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), 0L);
                 redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-                // 设置 Redis 记录的过期时间(例如 5 分钟)
                 redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+                h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
             }
             return ResponseResult.ok(fsUser);
         }
@@ -2935,10 +2950,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             fsCourseWatchLog.setAppId(param.getAppId());
             courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
 
-            String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+            String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), param.getCompanyUserId());
             redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-            // 设置 Redis 记录的过期时间(例如 5 分钟)
             redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+            h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
         }
 
         // 添加会员销售关系表数据
@@ -3304,7 +3319,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             return R.error();
         }
         // 从Redis中获取观看时长
-        String redisKey = "h5wxuser:watch:duration:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+        String redisKey = H5WxUserWatchRedisUtil.durationKey(param.getUserId(), param.getVideoId(), param.getCompanyUserId());
 //        log.info("看课redis缓存key:{}", redisKey);
         try {
             String durationStr = redisCache.getCacheObject(redisKey);
@@ -3315,10 +3330,16 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             if (param.getDuration() != null && param.getDuration() > duration) {
                 //24小时过期
                 redisCache.setCacheObject(redisKey, param.getDuration().toString(), 2, TimeUnit.HOURS);
+                h5WxUserWatchRedisUtil.trackDuration(redisKey);
             }
 
             //更新缓存中的心跳时间
             updateHeartbeatWx(param);
+            // 已达完课阈值时立即写库,避免仅依赖定时任务产生十几秒延迟
+            if (param.getDuration() != null) {
+                courseWatchLogService.syncH5WxUserWatchProgressOnFinish(
+                        param.getUserId(), param.getVideoId(), param.getCompanyUserId(), param.getDuration());
+            }
             return R.ok();
         } catch (Exception e) {
             logger.error("更新看课时长失败:{}", redisKey, e.getMessage());
@@ -3624,10 +3645,11 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     //会员-更新心跳时间
     public void updateHeartbeatWx(FsUserCourseVideoUParam param) {
-        String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+        Long companyUserId = param.getCompanyUserId() == null ? 0L : param.getCompanyUserId();
+        String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), companyUserId);
         redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-        // 设置 Redis 记录的过期时间(例如 5 分钟)
         redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+        h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
     }
 
 

+ 46 - 8
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoCommentServiceImpl.java

@@ -20,8 +20,10 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import com.fs.course.mapper.FsUserVideoCommentMapper;
+import com.fs.course.mapper.FsUserVideoMapper;
 import com.fs.course.domain.FsUserVideoComment;
 import com.fs.course.service.IFsUserVideoCommentService;
+import com.fs.course.utils.VideoCommentCountRedisUtil;
 import org.springframework.transaction.annotation.Transactional;
 
 /**
@@ -48,6 +50,12 @@ public class FsUserVideoCommentServiceImpl implements IFsUserVideoCommentService
     @Autowired
     private FsUserMapper fsUserMapper;
 
+    @Autowired
+    private FsUserVideoMapper fsUserVideoMapper;
+
+    @Autowired
+    private VideoCommentCountRedisUtil videoCommentCountRedisUtil;
+
     private static final String COMMENT_LIST_KEY_PREFIX = "comment:list:video:";
     private static final String COMMENT_HASH_KEY_PREFIX = "comment:hash:video:";
     private static final String REPLY_LIST_KEY_PREFIX = "reply:list:comment:";
@@ -58,8 +66,6 @@ public class FsUserVideoCommentServiceImpl implements IFsUserVideoCommentService
     private static final String COMMENT_LIKE_KEY_PREFIX = "like:comment:";
     private static final String COMMENT_LIKE_COUNT_KEY_PREFIX = "likecount:comment:";
     private static final String COMMENT_REPLY_COUNT_KEY_PREFIX = "reply:count:comment:";
-    private static final String VIDEO_COMMENT_COUNT_KEY_PREFIX = "comment:count:video:";
-
     /**
      * 查询课堂视频评论
      *
@@ -170,12 +176,8 @@ public class FsUserVideoCommentServiceImpl implements IFsUserVideoCommentService
 //        redisTemplate.expire(listKey, 1, TimeUnit.DAYS);
 //        redisTemplate.expire(hashKey, 1, TimeUnit.DAYS);
 //
-//        // 增加视频的评论数
-//        String videoCommentCountKey = VIDEO_COMMENT_COUNT_KEY_PREFIX + param.getVideoId();
-//        if (redisTemplate.opsForValue().get(videoCommentCountKey) == null) {
-//            redisTemplate.opsForValue().set(videoCommentCountKey, 0);
-//        }
-//        redisTemplate.opsForValue().increment(videoCommentCountKey, 1);
+//        // 增加视频的评论数(须走 VideoCommentCountRedisUtil 以登记索引)
+//        videoCommentCountRedisUtil.incrementCommentCount(param.getVideoId());
 //        return R.ok().put("data", comment);
 //    }
 
@@ -584,4 +586,40 @@ public class FsUserVideoCommentServiceImpl implements IFsUserVideoCommentService
             }
         }
     }
+
+    @Override
+    public void syncCommentCountToDatabase() {
+        Set<String> keys = videoCommentCountRedisUtil.listKeys();
+        if (keys == null || keys.isEmpty()) {
+            return;
+        }
+        for (String key : keys) {
+            try {
+                String videoIdStr = key.split(":")[3];
+                Long videoId = Long.parseLong(videoIdStr);
+                Object value = redisTemplate.opsForValue().get(key);
+                Integer commentCount = toIntegerCount(value);
+                if (commentCount != null) {
+                    fsUserVideoMapper.updateCommentCount(videoId, commentCount);
+                    redisTemplate.delete(key);
+                    videoCommentCountRedisUtil.untrack(key);
+                }
+            } catch (Exception e) {
+                // 单条失败不影响其余 key
+            }
+        }
+    }
+
+    private static Integer toIntegerCount(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Integer) {
+            return (Integer) value;
+        }
+        if (value instanceof Long) {
+            return ((Long) value).intValue();
+        }
+        return null;
+    }
 }

+ 34 - 0
fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java

@@ -0,0 +1,34 @@
+package com.fs.course.support;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 看课项目等价关系:重粉判断时视为同一项目。
+ */
+public final class CourseProjectEquivalence {
+
+    /** 项目 1 与 28 为同一业务项目 */
+    private static final List<Long> PROJECT_GROUP_1_AND_28 = Collections.unmodifiableList(Arrays.asList(1L, 28L));
+
+    private CourseProjectEquivalence() {
+    }
+
+    /**
+     * 返回与给定项目 ID 等价的全部项目 ID(用于查询、判重)。
+     */
+    public static List<Long> equivalentProjectIds(Long projectId) {
+        if (projectId == null || projectId == 0L) {
+            return Collections.emptyList();
+        }
+        if (projectId.equals(1L) || projectId.equals(28L)) {
+            return PROJECT_GROUP_1_AND_28;
+        }
+        return Collections.singletonList(projectId);
+    }
+
+    public static boolean isInProjectGroup1And28(Long projectId) {
+        return projectId != null && (projectId.equals(1L) || projectId.equals(28L));
+    }
+}

+ 98 - 0
fs-service/src/main/java/com/fs/course/utils/H5WxUserWatchRedisUtil.java

@@ -0,0 +1,98 @@
+package com.fs.course.utils;
+
+import com.fs.common.constant.CourseWatchKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * H5 微信看课心跳/时长 Redis 索引,避免 KEYS h5wxuser:watch:* 全库扫描。
+ */
+@Component
+public class H5WxUserWatchRedisUtil {
+
+    public static final String HEARTBEAT_PREFIX = CourseWatchKeysConstant.H5_WX_HEARTBEAT_PREFIX;
+    public static final String HEARTBEAT_INDEX = CourseWatchKeysConstant.H5_WX_HEARTBEAT_INDEX;
+    public static final String DURATION_PREFIX = CourseWatchKeysConstant.H5_WX_DURATION_PREFIX;
+    public static final String DURATION_INDEX = CourseWatchKeysConstant.H5_WX_DURATION_INDEX;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public static String heartbeatKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format("%s%d:%d:%d", HEARTBEAT_PREFIX, userId, videoId, companyUserId);
+    }
+
+    public static String durationKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format("%s%d:%d:%d", DURATION_PREFIX, userId, videoId, companyUserId);
+    }
+
+    public void trackHeartbeat(String cacheKey) {
+        track(HEARTBEAT_INDEX, cacheKey);
+    }
+
+    public void trackHeartbeat(Long userId, Long videoId, Long companyUserId) {
+        trackHeartbeat(heartbeatKey(userId, videoId, companyUserId));
+    }
+
+    public void untrackHeartbeat(String cacheKey) {
+        untrack(HEARTBEAT_INDEX, cacheKey);
+    }
+
+    public void untrackHeartbeat(Long userId, Long videoId, Long companyUserId) {
+        untrackHeartbeat(heartbeatKey(userId, videoId, companyUserId));
+    }
+
+    public Set<String> listHeartbeatKeys() {
+        return listIndexedKeys(HEARTBEAT_INDEX);
+    }
+
+    public void trackDuration(String cacheKey) {
+        track(DURATION_INDEX, cacheKey);
+    }
+
+    public void trackDuration(Long userId, Long videoId, Long companyUserId) {
+        trackDuration(durationKey(userId, videoId, companyUserId));
+    }
+
+    public void untrackDuration(String cacheKey) {
+        untrack(DURATION_INDEX, cacheKey);
+    }
+
+    public void untrackDuration(Long userId, Long videoId, Long companyUserId) {
+        untrackDuration(durationKey(userId, videoId, companyUserId));
+    }
+
+    public Set<String> listDurationKeys() {
+        return listIndexedKeys(DURATION_INDEX);
+    }
+
+    private void track(String indexKey, String cacheKey) {
+        redisCache.redisTemplate.opsForSet().add(indexKey, cacheKey);
+    }
+
+    private void untrack(String indexKey, String cacheKey) {
+        redisCache.redisTemplate.opsForSet().remove(indexKey, cacheKey);
+    }
+
+    private Set<String> listIndexedKeys(String indexKey) {
+        Set<Object> members = redisCache.redisTemplate.opsForSet().members(indexKey);
+        if (members == null || members.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> result = new HashSet<>(members.size());
+        for (Object member : members) {
+            String cacheKey = member.toString();
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                result.add(cacheKey);
+            } else {
+                untrack(indexKey, cacheKey);
+            }
+        }
+        return result;
+    }
+}

+ 73 - 0
fs-service/src/main/java/com/fs/course/utils/VideoCommentCountRedisUtil.java

@@ -0,0 +1,73 @@
+package com.fs.course.utils;
+
+import com.fs.common.constant.VideoCommentKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 视频评论数 Redis 索引,避免 KEYS comment:count:video:* 全库扫描。
+ */
+@Component
+public class VideoCommentCountRedisUtil {
+
+    public static final String COUNT_PREFIX = VideoCommentKeysConstant.VIDEO_COMMENT_COUNT_PREFIX;
+    public static final String COUNT_INDEX = VideoCommentKeysConstant.VIDEO_COMMENT_COUNT_INDEX;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    public static String commentCountKey(Long videoId) {
+        return COUNT_PREFIX + videoId;
+    }
+
+    public void track(String cacheKey) {
+        redisCache.redisTemplate.opsForSet().add(COUNT_INDEX, cacheKey);
+    }
+
+    public void track(Long videoId) {
+        track(commentCountKey(videoId));
+    }
+
+    public void untrack(String cacheKey) {
+        redisCache.redisTemplate.opsForSet().remove(COUNT_INDEX, cacheKey);
+    }
+
+    public void untrack(Long videoId) {
+        untrack(commentCountKey(videoId));
+    }
+
+    /**
+     * 评论数 +1 并登记索引(写入 Redis 时调用,供定时同步扫索引)
+     */
+    public void incrementCommentCount(Long videoId) {
+        String cacheKey = commentCountKey(videoId);
+        redisTemplate.opsForValue().increment(cacheKey, 1);
+        track(cacheKey);
+    }
+
+    public Set<String> listKeys() {
+        Set<Object> members = redisCache.redisTemplate.opsForSet().members(COUNT_INDEX);
+        if (members == null || members.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> result = new HashSet<>(members.size());
+        for (Object member : members) {
+            String cacheKey = member.toString();
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                result.add(cacheKey);
+            } else {
+                untrack(cacheKey);
+            }
+        }
+        return result;
+    }
+}

+ 9 - 2
fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java

@@ -10,6 +10,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.redis.LiveDelayedTaskRedisUtil;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.LiveCouponIssueMapper;
 import com.fs.live.mapper.LiveCouponIssueUserMapper;
@@ -42,6 +43,8 @@ public class LiveCouponServiceImpl implements ILiveCouponService
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
     private ILiveCouponIssueUserService liveCouponIssueUserService;
     @Autowired
     private ILiveCouponUserService liveCouponUserService;
@@ -316,12 +319,16 @@ public class LiveCouponServiceImpl implements ILiveCouponService
             }
         }
 
-        Long decrement = redisCache.decrement(String.format(LiveKeysConstant.LIVE_COUPON_NUM , coupon.getCouponIssueId()));
+        Long couponIssueId = coupon.getCouponIssueId();
+        String couponNumKey = LiveKeysConstant.liveCouponNumKey(couponIssueId);
+        Long decrement = redisCache.decrement(couponNumKey);
+        liveDelayedTaskRedisUtil.trackCouponNum(couponIssueId);
 
         if (decrement < 0L) {
             issue.setStatus(-1);
             issue.setRemainCount(0L);
-            redisCache.deleteObject(String.valueOf(issue.getId()));
+            redisCache.deleteObject(couponNumKey);
+            liveDelayedTaskRedisUtil.untrackCouponNum(couponIssueId);
             liveCouponIssueService.updateLiveCouponIssue(issue);
             return R.error("此优惠券已领完");
         }

+ 2 - 1
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -1034,8 +1034,9 @@ public class LiveServiceImpl implements ILiveService
                 tagMarkInfo.put("startTime", exist.getStartTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
                 tagMarkInfo.put("videoDuration", videoDuration);
 
-                String tagMarkKey = String.format(LiveKeysConstant.LIVE_TAG_MARK_CACHE, exist.getLiveId());
+                String tagMarkKey = LiveKeysConstant.liveTagMarkKey(exist.getLiveId());
                 redisCache.setCacheObject(tagMarkKey, JSON.toJSONString(tagMarkInfo), 24, TimeUnit.HOURS);
+                liveDelayedTaskRedisUtil.trackTagMark(exist.getLiveId());
                 log.info("手动开直播间开启,已加入打标签缓存: liveId={}, startTime={}, videoDuration={}",
                         exist.getLiveId(), exist.getStartTime(), videoDuration);
             }

+ 16 - 2
fs-service/src/main/java/com/fs/utils/VideoUtil.java

@@ -13,6 +13,20 @@ import java.util.regex.Pattern;
 
 @Slf4j
 public class VideoUtil {
+
+    /** 与项目 AudioUtils 等模块一致:优先 C:\ffmpeg.exe,其次 PATH 中的 ffmpeg */
+    private static String resolveFfmpegCommand() {
+        String winPath = "C:\\ffmpeg.exe";
+        if (new File(winPath).exists()) {
+            return winPath;
+        }
+        String envPath = System.getenv("FFMPEG_PATH");
+        if (envPath != null && !envPath.isEmpty() && new File(envPath).exists()) {
+            return envPath;
+        }
+        return "ffmpeg";
+    }
+
     /**
      * 获取视频元信息(宽高、大小、时长等)
      */
@@ -20,7 +34,7 @@ public class VideoUtil {
         Map<String, Object> videoInfo = new HashMap<>();
         String videoPath = videoFile.getAbsolutePath();
         String[] command = {
-                "ffmpeg",
+                resolveFfmpegCommand(),
                 "-i", videoPath
         };
 
@@ -76,7 +90,7 @@ public class VideoUtil {
      */
     public static void extractFirstFrame(String videoPath, String outputImagePath) throws IOException, InterruptedException {
         String[] command = {
-                "ffmpeg",
+                resolveFfmpegCommand(),
                 "-ss", "00:00:01.000",  // 精准定位到第1秒
                 "-i", videoPath,        // 输入视频路径
                 "-vframes", "1",        // 只提取1帧

+ 1 - 17
fs-user-app/src/main/java/com/fs/app/controller/VideoTestController.java

@@ -19,7 +19,6 @@ import com.fs.sop.domain.QwSopLogs;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.web.bind.annotation.*;
 
 import java.time.LocalDateTime;
@@ -37,7 +36,6 @@ public class VideoTestController{
     private static final String FAVORITE_KEY_PREFIX = "favorite:video:";
     private static final String NO_FAVORITE_KEY_PREFIX = "nofavorite:video:";
     private static final String COMMENT_KEY_PREFIX = "comment:video:";
-    private static final String VIDEO_COMMENT_COUNT_KEY_PATTERN = "comment:count:video:*";
     private static final String COMMENT_REPLY_COUNT_KEY_PATTERN = "reply:count:comment:*";
     @Autowired
     private FsUserVideoMapper videoMapper;
@@ -51,9 +49,6 @@ public class VideoTestController{
     private FsUserVideoCommentMapper videoCommentMapper;
     @Autowired
     private RedisCache redisCache;
-    @Autowired
-    private RedisTemplate<String, Object> redisTemplate;
-
     //同步点赞到数据库
     @ApiOperation("同步点赞")
     @GetMapping("/syncLikes")
@@ -144,18 +139,7 @@ public class VideoTestController{
     //同步评论数量
     @GetMapping("/syncCommentCount")
     public void syncCommentCountToDatabase() {
-        Set<String> keys = redisTemplate.keys(VIDEO_COMMENT_COUNT_KEY_PATTERN);
-        if (keys != null) {
-            for (String key : keys) {
-                String videoIdStr = key.split(":")[3];
-                Long videoId = Long.parseLong(videoIdStr);
-                Integer commentCount = (Integer) redisTemplate.opsForValue().get(key);
-                if (commentCount != null) {
-                    videoMapper.updateCommentCount(videoId, commentCount);
-                    redisTemplate.delete(key);
-                }
-            }
-        }
+        videoCommentService.syncCommentCountToDatabase();
     }
 
     @Autowired