ソースを参照

Merge remote-tracking branch 'origin/bjcz_his_scrm' into 北京存在

吴树波 6 日 前
コミット
ca04477e16
71 ファイル変更2777 行追加413 行削除
  1. 1 16
      fs-admin/src/main/java/com/fs/course/task/VideoTask.java
  2. 98 7
      fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java
  3. 1 1
      fs-admin/src/main/resources/application.yml
  4. 15 0
      fs-common/src/main/java/com/fs/common/constant/CourseWatchKeysConstant.java
  5. 60 0
      fs-common/src/main/java/com/fs/common/constant/LiveKeysConstant.java
  6. 13 0
      fs-common/src/main/java/com/fs/common/constant/VideoCommentKeysConstant.java
  7. 163 0
      fs-common/src/main/java/com/fs/common/utils/redis/LiveDelayedTaskRedisUtil.java
  8. 195 0
      fs-common/src/main/java/com/fs/common/utils/redis/RedisActiveKeyIndexRepairService.java
  9. 23 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopController.java
  10. 1 1
      fs-company/src/main/resources/application.yml
  11. 1 1
      fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  12. 87 64
      fs-live-app/src/main/java/com/fs/live/task/Task.java
  13. 16 8
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  14. 53 0
      fs-qw-mq/src/main/java/com/fs/app/mq/RocketMQConsumerCourseRepeatByProjectService.java
  15. 16 4
      fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  16. 3 0
      fs-service/src/main/java/com/fs/company/domain/Company.java
  17. 9 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  18. 3 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVO.java
  19. 17 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  20. 3 4
      fs-service/src/main/java/com/fs/course/dto/RedisKeyInfo.java
  21. 10 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserTalentFollowMapper.java
  22. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoFavoriteMapper.java
  23. 6 6
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoMapper.java
  24. 19 0
      fs-service/src/main/java/com/fs/course/mq/CourseRepeatByProjectMqConstants.java
  25. 33 0
      fs-service/src/main/java/com/fs/course/service/ICourseProjectSalesBindService.java
  26. 15 0
      fs-service/src/main/java/com/fs/course/service/ICourseRepeatByProjectService.java
  27. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  28. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsUserVideoCommentService.java
  29. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsUserVideoService.java
  30. 249 0
      fs-service/src/main/java/com/fs/course/service/impl/CourseProjectSalesBindServiceImpl.java
  31. 74 0
      fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectMqProducer.java
  32. 250 0
      fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java
  33. 166 92
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  34. 60 9
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java
  35. 45 80
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  36. 0 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserTalentServiceImpl.java
  37. 46 8
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoCommentServiceImpl.java
  38. 7 7
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoFavoriteServiceImpl.java
  39. 6 7
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoLikeServiceImpl.java
  40. 39 35
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java
  41. 34 0
      fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java
  42. 30 0
      fs-service/src/main/java/com/fs/course/support/CourseProjectSalesBindConstants.java
  43. 117 0
      fs-service/src/main/java/com/fs/course/utils/H5WxUserWatchRedisUtil.java
  44. 73 0
      fs-service/src/main/java/com/fs/course/utils/VideoCommentCountRedisUtil.java
  45. 17 0
      fs-service/src/main/java/com/fs/course/vo/CourseRepeatByProjectMqMessage.java
  46. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserVideoListUVO.java
  47. 1 0
      fs-service/src/main/java/com/fs/enums/ExceptionCodeEnum.java
  48. 2 1
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  49. 3 0
      fs-service/src/main/java/com/fs/his/mapper/FsIntegralGoodsMapper.java
  50. 3 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsIntegralGoodsScrmMapper.java
  51. 2 2
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreCartScrmMapper.java
  52. 21 8
      fs-service/src/main/java/com/fs/live/service/impl/LiveAutoTaskServiceImpl.java
  53. 9 2
      fs-service/src/main/java/com/fs/live/service/impl/LiveCouponServiceImpl.java
  54. 12 8
      fs-service/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java
  55. 12 8
      fs-service/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java
  56. 11 3
      fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java
  57. 2 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  58. 4 0
      fs-service/src/main/java/com/fs/sop/domain/QwSop.java
  59. 5 0
      fs-service/src/main/java/com/fs/sop/mapper/QwSopMapper.java
  60. 16 2
      fs-service/src/main/java/com/fs/utils/VideoUtil.java
  61. 104 0
      fs-service/src/main/resources/application-config-druid-hzjs.yml
  62. 104 0
      fs-service/src/main/resources/application-config-druid-jshz.yml
  63. 4 0
      fs-service/src/main/resources/application-druid-bjczwh.yml
  64. 175 0
      fs-service/src/main/resources/application-druid-hzjs.yml
  65. 172 0
      fs-service/src/main/resources/application-druid-jshz.yml
  66. 6 1
      fs-service/src/main/resources/mapper/company/CompanyMapper.xml
  67. 3 0
      fs-user-app/src/main/java/com/fs/app/controller/TalentController.java
  68. 5 3
      fs-user-app/src/main/java/com/fs/app/controller/VideoController.java
  69. 1 17
      fs-user-app/src/main/java/com/fs/app/controller/VideoTestController.java
  70. 4 4
      fs-user-app/src/main/java/com/fs/app/controller/store/ProductScrmController.java
  71. 1 1
      fs-user-app/src/main/java/com/fs/app/controller/store/VideoScrmController.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();
     }
 
     //同步评论

+ 98 - 7
fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java

@@ -1,21 +1,25 @@
 package com.fs.qw.controller;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
-import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.fs.common.core.domain.R;
-import com.fs.common.exception.ServiceException;
-import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.qw.domain.QwContactWay;
 import com.fs.qw.param.QwExternalContactParam;
 import com.fs.qw.param.QwTagSearchParam;
 import com.fs.qw.param.ResignedTransferParam;
 import com.fs.qw.param.TransferParam;
+import com.fs.qw.service.IQwContactWayService;
 import com.fs.qw.service.IQwExternalContactInfoService;
-import com.fs.qw.service.IQwExternalContactInfoService;
+import com.fs.qw.service.IQwGroupChatUserService;
 import com.fs.qw.service.IQwTagService;
+import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.qw.param.UserWatchLogParam;
+import com.fs.course.vo.UserWatchLogListVo;
 import com.fs.qw.vo.QwExternalContactUnionIdExportVO;
 import com.fs.qw.vo.QwExternalContactVO;
 import com.google.gson.Gson;
@@ -31,6 +35,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -57,6 +62,15 @@ public class QwExternalContactController extends BaseController
     @Autowired
     private IQwExternalContactInfoService qwExternalContactInfoService;
 
+    @Autowired
+    private IQwContactWayService qwContactWayService;
+
+    @Autowired
+    private IQwGroupChatUserService qwGroupChatUserService;
+
+    @Autowired
+    private IFsUserCompanyBindService fsUserCompanyBindService;
+
     QwExternalContactController(IQwExternalContactService qwExternalContactService,IQwTagService iQwTagService){
         this.qwExternalContactService=qwExternalContactService;
         this.iQwTagService=iQwTagService;
@@ -70,11 +84,32 @@ public class QwExternalContactController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(QwExternalContactParam qwExternalContact)
     {
+        if (qwExternalContact.getFsUserId() == null
+                && qwExternalContact.getExtId() == null
+                && qwExternalContact.getId() == null
+                && StringUtils.isEmpty(qwExternalContact.getExternalUserId())) {
+            return getDataTable(new ArrayList<>());
+        }
+
+        QwContactWay qwContactWay = new QwContactWay();
+        if (qwExternalContact.getCompanyId() != null) {
+            qwContactWay.setCompanyId(qwExternalContact.getCompanyId());
+        }
+        List<QwContactWay> wayList = qwContactWayService.selectQwContactWayList(qwContactWay);
+
         startPage();
-        List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVONewSys(qwExternalContact);
-        list.forEach(item->{
+        if (StringUtils.isNotEmpty(qwExternalContact.getStatuses())) {
+            String[] split = qwExternalContact.getStatuses().split(",");
+            qwExternalContact.setStatusCondition(split);
+        }
+        List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
+        list.forEach(item -> {
+            if (StringUtils.isNotEmpty(item.getExternalUserId())) {
+                List<String> chatNameList = qwGroupChatUserService.selectChatNameByUserId(item.getExternalUserId());
+                item.setChatNames(chatNameList);
+            }
 
-            if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds()!=null) {
+            if (!Objects.equals(item.getTagIds(), "[]") && item.getTagIds() != null) {
                 QwTagSearchParam param = new QwTagSearchParam();
                 Gson gson = new Gson();
                 List<String> tagIds = gson.fromJson(
@@ -87,11 +122,67 @@ public class QwExternalContactController extends BaseController
 
                 item.setTagIdsName(iQwTagService.selectQwTagListByTagIds(param));
             }
+
+            if (StringUtils.isNotEmpty(item.getState()) && !wayList.isEmpty()) {
+                item.setState(item.getState() + "-" + getContactWayNameStream(item.getState(), wayList));
+            }
         });
+        if (StringUtils.isNotEmpty(qwExternalContact.getChatName())) {
+            list = list.stream()
+                    .filter(item -> item.getChatNames() != null && !item.getChatNames().isEmpty())
+                    .collect(Collectors.toList());
+        }
 
         return getDataTable(list);
     }
 
+    public String getContactWayNameStream(String configStr, List<QwContactWay> wayList) {
+        if (configStr == null || wayList == null || wayList.isEmpty()) {
+            return null;
+        }
+
+        return wayList.stream()
+                .filter(way -> way.getId() != null &&
+                        way.getId().toString().equals(extractLastValue(configStr)))
+                .map(QwContactWay::getName)
+                .findFirst()
+                .orElse(null);
+    }
+
+    private String extractLastValue(String input) {
+        if (input == null || input.isEmpty()) {
+            return null;
+        }
+
+        int lastColonIndex = input.lastIndexOf(":");
+        if (lastColonIndex == -1) {
+            return input;
+        }
+
+        return input.substring(lastColonIndex + 1);
+    }
+
+    /**
+     * 重粉看课记录查询
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:list')")
+    @GetMapping("/getWatchLogList")
+    public TableDataInfo getWatchLogList(UserWatchLogParam param) {
+        startPage();
+        List<UserWatchLogListVo> list = fsUserCompanyBindService.getWatchLogList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 重粉看课历史 - 综合信息查询(关联销售 + 课程进度)
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:list')")
+    @GetMapping("/getRepeatCourseHistory")
+    public AjaxResult getRepeatCourseHistory(@RequestParam Long fsUserId,
+                                             @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(fsUserCompanyBindService.getRepeatCourseHistory(fsUserId, companyId));
+    }
+
     /**
      * 导出企业微信客户列表
      */

+ 1 - 1
fs-admin/src/main/resources/application.yml

@@ -7,7 +7,7 @@ spring:
 #    active: dev
 #    active: druid-hdt
 #    active: druid-yzt
-    active: druid-bjczwh-test
+    active: druid-jshz
 #    active: druid-sft
 #    active: druid-fby
 #    active: dev

+ 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";
+}

+ 60 - 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";
@@ -59,4 +85,38 @@ public class LiveKeysConstant {
     /** 真实订单去重(防止同一订单多次推送),%s=orderId */
     public static final String LIVE_ORDER_TIP_REAL_DEDUP = "live:order:tip:real:dedup:%s";
 
+    // ========== 直播延时任务 ZSet(定时扫描,勿用 KEYS) ==========
+    public static final String LIVE_AUTO_TASK_PREFIX = "live:auto_task:";
+    public static final String LIVE_LOTTERY_TASK_PREFIX = "live:lottery_task:";
+    public static final String LIVE_RED_TASK_PREFIX = "live:red_task:";
+
+    /** 活跃 ZSet key 索引,成员为完整 ZSet key,如 live:auto_task:123 */
+    public static final String LIVE_AUTO_TASK_INDEX = "live:auto_task:index";
+    public static final String LIVE_LOTTERY_TASK_INDEX = "live:lottery_task:index";
+    public static final String LIVE_RED_TASK_INDEX = "live:red_task:index";
+
+    public static String liveAutoTaskKey(Long liveId) {
+        return LIVE_AUTO_TASK_PREFIX + liveId;
+    }
+
+    public static String liveLotteryTaskKey(Long liveId) {
+        return LIVE_LOTTERY_TASK_PREFIX + liveId;
+    }
+
+    public static String liveRedTaskKey(Long liveId) {
+        return LIVE_RED_TASK_PREFIX + liveId;
+    }
+
+    /** 从延时任务 ZSet key 解析直播间 ID */
+    public static Long parseLiveIdFromTaskZSetKey(String zsetKey) {
+        if (zsetKey == null || zsetKey.isEmpty()) {
+            return null;
+        }
+        int idx = zsetKey.lastIndexOf(':');
+        if (idx < 0 || idx >= zsetKey.length() - 1) {
+            return null;
+        }
+        return Long.parseLong(zsetKey.substring(idx + 1));
+    }
+
 }

+ 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";
+}

+ 163 - 0
fs-common/src/main/java/com/fs/common/utils/redis/LiveDelayedTaskRedisUtil.java

@@ -0,0 +1,163 @@
+package com.fs.common.utils.redis;
+
+import com.fs.common.constant.LiveKeysConstant;
+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;
+
+/**
+ * 直播 Redis 活跃 key 索引:用 Set 维护待扫描的 ZSet / 缓存 key,避免生产环境 KEYS 全库扫描。
+ */
+@Component
+public class LiveDelayedTaskRedisUtil {
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public String autoTaskKey(Long liveId) {
+        return LiveKeysConstant.liveAutoTaskKey(liveId);
+    }
+
+    public String lotteryTaskKey(Long liveId) {
+        return LiveKeysConstant.liveLotteryTaskKey(liveId);
+    }
+
+    public String redTaskKey(Long liveId) {
+        return LiveKeysConstant.liveRedTaskKey(liveId);
+    }
+
+    public void trackAutoTask(Long liveId) {
+        track(LiveKeysConstant.LIVE_AUTO_TASK_INDEX, autoTaskKey(liveId));
+    }
+
+    public void trackLotteryTask(Long liveId) {
+        track(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX, lotteryTaskKey(liveId));
+    }
+
+    public void trackRedTask(Long liveId) {
+        track(LiveKeysConstant.LIVE_RED_TASK_INDEX, redTaskKey(liveId));
+    }
+
+    public void refreshAutoTaskIndex(Long liveId) {
+        refreshIndex(LiveKeysConstant.LIVE_AUTO_TASK_INDEX, autoTaskKey(liveId));
+    }
+
+    public void refreshLotteryTaskIndex(Long liveId) {
+        refreshIndex(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX, lotteryTaskKey(liveId));
+    }
+
+    public void refreshRedTaskIndex(Long liveId) {
+        refreshIndex(LiveKeysConstant.LIVE_RED_TASK_INDEX, redTaskKey(liveId));
+    }
+
+    public void untrackAutoTask(Long liveId) {
+        untrack(LiveKeysConstant.LIVE_AUTO_TASK_INDEX, autoTaskKey(liveId));
+    }
+
+    public void untrackLotteryTask(Long liveId) {
+        untrack(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX, lotteryTaskKey(liveId));
+    }
+
+    public void untrackRedTask(Long liveId) {
+        untrack(LiveKeysConstant.LIVE_RED_TASK_INDEX, redTaskKey(liveId));
+    }
+
+    public Set<String> listAutoTaskZSetKeys() {
+        return listIndexedKeys(LiveKeysConstant.LIVE_AUTO_TASK_INDEX);
+    }
+
+    public Set<String> listLotteryTaskZSetKeys() {
+        return listIndexedKeys(LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX);
+    }
+
+    public Set<String> listRedTaskZSetKeys() {
+        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) {
+        redisCache.redisTemplate.opsForSet().add(indexKey, zsetKey);
+    }
+
+    private void untrack(String indexKey, String zsetKey) {
+        redisCache.redisTemplate.opsForSet().remove(indexKey, zsetKey);
+    }
+
+    /**
+     * ZSet 变更后调用:无成员或 key 已过期则从索引移除。
+     */
+    public void refreshIndex(String indexKey, String zsetKey) {
+        if (!Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(zsetKey))) {
+            untrack(indexKey, zsetKey);
+            return;
+        }
+        Long size = redisCache.redisTemplate.opsForZSet().zCard(zsetKey);
+        if (size == null || size == 0) {
+            untrack(indexKey, zsetKey);
+        }
+    }
+
+    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 zsetKey = member.toString();
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(zsetKey))) {
+                result.add(zsetKey);
+            } else {
+                untrack(indexKey, zsetKey);
+            }
+        }
+        return result;
+    }
+}

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

@@ -0,0 +1,195 @@
+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;
+        if (target.isRemoveExpiredFromIndex()) {
+            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++;
+                    }
+                }
+            }
+        } else {
+            log.info("索引 {} 跳过过期成员清理,交由 checkFsUserWatchStatus 写库后 untrack", target.name);
+        }
+        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));
+        // 心跳索引只补不删:过期 key 由 checkFsUserWatchStatus 写 log_type=4 后再 untrack
+        targets.add(new IndexRepairTarget("h5wxHeartbeat",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_PREFIX + "*",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_INDEX,
+                false));
+        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;
+        /** false 时仅 SCAN 补登记,不清理索引中已 TTL 过期的成员 */
+        private final boolean removeExpiredFromIndex;
+
+        public IndexRepairTarget(String name, String scanPattern, String indexKey) {
+            this(name, scanPattern, indexKey, true);
+        }
+
+        public IndexRepairTarget(String name, String scanPattern, String indexKey, boolean removeExpiredFromIndex) {
+            this.name = name;
+            this.scanPattern = scanPattern;
+            this.indexKey = indexKey;
+            this.removeExpiredFromIndex = removeExpiredFromIndex;
+        }
+
+        public boolean isRemoveExpiredFromIndex() {
+            return removeExpiredFromIndex;
+        }
+    }
+
+    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 + "}";
+        }
+    }
+}

+ 23 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwSopController.java

@@ -12,6 +12,8 @@ import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.Company;
+import com.fs.company.service.ICompanyService;
 import com.fs.company.service.impl.CompanyDeptServiceImpl;
 import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
@@ -75,6 +77,9 @@ public class QwSopController extends BaseController
     @Autowired
     private CompanyDeptServiceImpl companyDeptService;
 
+    @Autowired
+    private ICompanyService companyService;
+
     @Autowired
     private IQwUserService iQwUserService;
 
@@ -254,11 +259,13 @@ public class QwSopController extends BaseController
         qwSop.setCompanyId(companyId);
         qwSop.setCreateBy(loginUser.getUser().getNickName());
         qwSop.setCreateTime(sdf.format(new Date()));
+        fillCompanyLevel(qwSop, companyId);
         int count = qwSopService.insertQwSop(qwSop);
         if(count > 0){
             if(qwSop.getQwUserIds() != null){
                 updateTempVoiceInfo(qwSop);
             }
+            return AjaxResult.success(qwSop);
         }
         return toAjax(count);
 
@@ -278,10 +285,26 @@ public class QwSopController extends BaseController
             if(qwSop != null && qwSop.getQwUserIds() != null){
                 updateTempVoiceInfo(qwSop);
             }
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            Long companyId = loginUser.getCompany().getCompanyId();
+            fillCompanyLevel(qwSop, companyId);
+            sop.put("level", qwSop.getLevel());
         }
         return sop;
     }
 
+    private void fillCompanyLevel(QwSop qwSop, Long companyId) {
+        if (qwSop == null || companyId == null) {
+            return;
+        }
+        Company company = companyService.selectCompanyById(companyId);
+        Integer level = company != null && company.getLevel() != null ? company.getLevel() : 0;
+        qwSop.setLevel(level);
+        if (level == 1) {
+            qwSop.setIsRating(1);
+        }
+    }
+
 
     /**
      * 暂停企微sop

+ 1 - 1
fs-company/src/main/resources/application.yml

@@ -13,4 +13,4 @@ spring:
 #    active: druid-myhk
 #    active: druid-sft
 #    active: dev-jnlzjk
-#    active: dev-yjb
+#    active: druid-jshz

+ 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));
             // 循环发送消息里面的每一条消息

+ 87 - 64
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -8,6 +8,8 @@ import com.fs.common.constant.LiveKeysConstant;
 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;
@@ -39,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
@@ -82,6 +83,10 @@ public class Task {
 
     @Autowired
     public FsJstAftersalePushService fsJstAftersalePushService;
+    @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
+    private RedisActiveKeyIndexRepairService redisActiveKeyIndexRepairService;
 
     @Scheduled(cron = "0 0/1 * * * ?")
     @DistributeLock(key = "updateLiveStatusByTime", scene = "task")
@@ -155,7 +160,6 @@ public class Task {
                 liveService.updateLiveEntity(live);
             }
         }
-        String key = "live:auto_task:";
         if (!startLiveList.isEmpty()) {
             for (Live live : startLiveList) {
                 SendMsgVo sendMsgVo = new SendMsgVo();
@@ -164,12 +168,14 @@ public class Task {
                 webSocketServer.broadcastMessage(live.getLiveId(), JSONObject.toJSONString(R.ok().put("data",sendMsgVo)));
                 List<LiveAutoTask> collect = liveAutoTasks.stream().filter(liveAutoTask -> liveAutoTask.getLiveId().equals(live.getLiveId())).collect(Collectors.toList());
                 if (!collect.isEmpty()) {
+                    String autoTaskKey = liveDelayedTaskRedisUtil.autoTaskKey(live.getLiveId());
                     collect.forEach(liveAutoTask -> {
                         liveAutoTask.setCreateTime(null);
                         liveAutoTask.setUpdateTime(null);
-                        redisCache.zSetAdd(key + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
-                        redisCache.expire(key+live.getLiveId(), 1, TimeUnit.DAYS);
+                        redisCache.zSetAdd(autoTaskKey, JSON.toJSONString(liveAutoTask), liveAutoTask.getAbsValue().getTime());
+                        redisCache.expire(autoTaskKey, 1, TimeUnit.DAYS);
                     });
+                    liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
                 }
                 // 清理小程序缓存 和 直播标签缓存
                 String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
@@ -194,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);
                     }
@@ -214,12 +221,9 @@ public class Task {
                 webSocketServer.broadcastMessage(live.getLiveId(), JSONObject.toJSONString(R.ok().put("data",sendMsgVo)));
                 List<LiveAutoTask> collect = liveAutoTasks.stream().filter(liveAutoTask -> liveAutoTask.getLiveId().equals(live.getLiveId())).collect(Collectors.toList());
                 if (!collect.isEmpty()) {
-                    redisCache.deleteObject(key + live.getLiveId());
-                    collect.forEach(liveAutoTask -> {
-                        liveAutoTask.setCreateTime(null);
-                        liveAutoTask.setUpdateTime(null);
-                        redisCache.redisTemplate.opsForZSet().remove(key + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
-                    });
+                    String autoTaskKey = liveDelayedTaskRedisUtil.autoTaskKey(live.getLiveId());
+                    redisCache.deleteObject(autoTaskKey);
+                    liveDelayedTaskRedisUtil.untrackAutoTask(live.getLiveId());
                 }
                 String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
                 redisCache.deleteObject(cacheKey);
@@ -227,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);
@@ -243,42 +248,41 @@ public class Task {
     @DistributeLock(key = "liveLotteryTask", scene = "task")
     public void liveLotteryTask() {
         long currentTime = Instant.now().toEpochMilli(); // 当前时间戳(毫秒)
-        String lotteryKey = "live:lottery_task:*";
-        Set<String> allLiveKeys = redisCache.redisTemplate.keys(lotteryKey);
-        if (allLiveKeys != null && !allLiveKeys.isEmpty()) {
-            for (String liveKey : allLiveKeys) {
-                Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
-                if (range == null || range.isEmpty()) {
-                    continue;
-                }
-                processLotteryTask(range);
-                redisCache.redisTemplate.opsForZSet()
-                        .removeRangeByScore(liveKey, 0, currentTime);
+        Set<String> lotteryZSetKeys = liveDelayedTaskRedisUtil.listLotteryTaskZSetKeys();
+        for (String liveKey : lotteryZSetKeys) {
+            Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
+            if (range == null || range.isEmpty()) {
+                continue;
+            }
+            processLotteryTask(range);
+            redisCache.redisTemplate.opsForZSet().removeRangeByScore(liveKey, 0, currentTime);
+            Long liveId = LiveKeysConstant.parseLiveIdFromTaskZSetKey(liveKey);
+            if (liveId != null) {
+                liveDelayedTaskRedisUtil.refreshLotteryTaskIndex(liveId);
             }
         }
 
-        String redKey = "live:red_task:*";
-        allLiveKeys = redisCache.redisTemplate.keys(redKey);
-        if (allLiveKeys == null || allLiveKeys.isEmpty()) {
-            return;
-        }
-        for (String liveKey : allLiveKeys) {
+        Set<String> redZSetKeys = liveDelayedTaskRedisUtil.listRedTaskZSetKeys();
+        for (String liveKey : redZSetKeys) {
             Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
             if (range == null || range.isEmpty()) {
                 continue;
             }
 
             updateRedStatus(range);
-            redisCache.redisTemplate.opsForZSet()
-                    .removeRangeByScore(liveKey, 0, currentTime);
+            redisCache.redisTemplate.opsForZSet().removeRangeByScore(liveKey, 0, currentTime);
+            Long liveId = LiveKeysConstant.parseLiveIdFromTaskZSetKey(liveKey);
+            if (liveId != null) {
+                liveDelayedTaskRedisUtil.refreshRedTaskIndex(liveId);
+            }
             try {
                 // 广播红包关闭消息
                 SendMsgVo sendMsgVo = new SendMsgVo();
-                sendMsgVo.setLiveId(Long.valueOf(liveKey));
+                sendMsgVo.setLiveId(liveId);
                 sendMsgVo.setCmd("red");
                 sendMsgVo.setStatus(-1);
-                liveService.asyncToCacheLiveConfig(Long.parseLong(liveKey));
-                webSocketServer.broadcastMessage(Long.valueOf(liveKey), JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
+                liveService.asyncToCacheLiveConfig(liveId);
+                webSocketServer.broadcastMessage(liveId, JSONObject.toJSONString(R.ok().put("data", sendMsgVo)));
             } catch (Exception e) {
                 log.error("更新红包状态异常", e);
             }
@@ -374,21 +378,18 @@ public class Task {
     public void liveAutoTask() {
         long currentTime = Instant.now().toEpochMilli(); // 当前时间戳(毫秒)
 
-        Set<String> allLiveKeys = redisCache.redisTemplate.keys("live:auto_task:*");
-        if (allLiveKeys == null || allLiveKeys.isEmpty()) {
-            return; // 没有数据,直接返回
-        }
-        // 2. 遍历每个直播间的ZSet键
-        for (String liveKey : allLiveKeys) {
-            // 3. 获取当前直播间ZSet中所有元素(按score排序)
-            // range方法:0表示第一个元素,-1表示最后一个元素,即获取全部
+        Set<String> autoZSetKeys = liveDelayedTaskRedisUtil.listAutoTaskZSetKeys();
+        for (String liveKey : autoZSetKeys) {
             Set<String> range = redisCache.redisTemplate.opsForZSet().rangeByScore(liveKey, 0, currentTime);
             if (range == null || range.isEmpty()) {
-                continue; // 没有数据,直接返回
+                continue;
             }
-            redisCache.redisTemplate.opsForZSet()
-                    .removeRangeByScore(liveKey, 0, currentTime);
+            redisCache.redisTemplate.opsForZSet().removeRangeByScore(liveKey, 0, currentTime);
             processAutoTask(range);
+            Long liveId = LiveKeysConstant.parseLiveIdFromTaskZSetKey(liveKey);
+            if (liveId != null) {
+                liveDelayedTaskRedisUtil.refreshAutoTaskIndex(liveId);
+            }
         }
     }
 
@@ -611,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);
         }
     }
 
@@ -643,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;
             }
 
@@ -660,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;
                     }
 
@@ -795,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);
                 }
@@ -886,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);
@@ -989,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 {
@@ -1115,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();
+    }
 }

+ 16 - 8
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -28,6 +28,7 @@ import com.fs.live.websocket.bean.SendMsgVo;
 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.spring.SpringUtils;
 import com.fs.live.domain.*;
 import com.fs.live.param.RedPO;
@@ -734,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);
@@ -1281,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());
@@ -1332,8 +1338,9 @@ public class WebSocketServer {
 
     }
     private void delAutoTask(long liveId, Long data) {
-        String key = "live:auto_task:";
-        redisCache.redisTemplate.opsForZSet().removeRangeByScore(key + liveId, data, data);
+        String cacheKey = LiveKeysConstant.liveAutoTaskKey(liveId);
+        redisCache.redisTemplate.opsForZSet().removeRangeByScore(cacheKey, data, data);
+        SpringUtils.getBean(LiveDelayedTaskRedisUtil.class).refreshAutoTaskIndex(liveId);
     }
 
     /**
@@ -1420,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);
                     }
                 }
             }

+ 53 - 0
fs-qw-mq/src/main/java/com/fs/app/mq/RocketMQConsumerCourseRepeatByProjectService.java

@@ -0,0 +1,53 @@
+package com.fs.app.mq;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.mq.CourseRepeatByProjectMqConstants;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import com.fs.course.service.ICourseRepeatByProjectService;
+import com.fs.course.vo.CourseRepeatByProjectMqMessage;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@AllArgsConstructor
+@RocketMQMessageListener(
+        topic = CourseRepeatByProjectMqConstants.TOPIC,
+        consumerGroup = CourseRepeatByProjectMqConstants.CONSUMER_GROUP)
+public class RocketMQConsumerCourseRepeatByProjectService implements RocketMQListener<String> {
+
+    private final ICourseRepeatByProjectService courseRepeatByProjectService;
+    private final FsUserMapper fsUserMapper;
+
+    @Override
+    public void onMessage(String message) {
+        if (StringUtils.isEmpty(message)) {
+            return;
+        }
+        try {
+            CourseRepeatByProjectMqMessage mqMessage = JSON.parseObject(message, CourseRepeatByProjectMqMessage.class);
+            if (mqMessage == null || mqMessage.getParam() == null) {
+                return;
+            }
+            FsUserCourseVideoAddKfUParam param = mqMessage.getParam();
+            if (param.getUserId() == null) {
+                return;
+            }
+            FsUser fsUser = fsUserMapper.selectFsUserByUserId(param.getUserId());
+            if (fsUser == null) {
+                log.warn("看课重粉 MQ 消费:用户不存在 userId={}", param.getUserId());
+                return;
+            }
+            courseRepeatByProjectService.checkAndMarkRepeatByProject(param, fsUser);
+        } catch (Exception e) {
+            log.error("看课重粉 MQ 消费失败 message={}", message, e);
+            throw e;
+        }
+    }
+}

+ 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 - 0
fs-service/src/main/java/com/fs/company/domain/Company.java

@@ -141,6 +141,9 @@ public class Company extends BaseEntity
     // 控制休息提示是否打开要暂停  0-关闭 1-打开 null-默认打开
     private Integer isOpenRestReminder;
 
+    /** 是否强制评级 0否 1是,同步控制该公司下所有SOP */
+    private Integer level;
+
     @TableField(exist = false)
     private List<Long> ids;
 

+ 9 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -39,6 +39,7 @@ import com.fs.hisStore.mapper.FsStoreOrderScrmMapper;
 import com.fs.live.domain.LiveOrder;
 import com.fs.live.mapper.LiveOrderMapper;
 import com.fs.live.service.ILiveService;
+import com.fs.sop.mapper.QwSopMapper;
 import com.fs.store.config.CompanyMenuConfig;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
@@ -141,6 +142,9 @@ public class CompanyServiceImpl implements ICompanyService
     @Autowired
     private ICompanyConfigService companyConfigService;
 
+    @Autowired
+    private QwSopMapper qwSopMapper;
+
 
     @Override
     public List<CompanyVO> liveShowList(CompanyParam param) {
@@ -598,7 +602,11 @@ public class CompanyServiceImpl implements ICompanyService
         if(company.isUpdateMiniApp()){
             bindMiniApp(company);
         }
-        return companyMapper.updateCompany(company);
+        int rows = companyMapper.updateCompany(company);
+        if (rows > 0 && company.getLevel() != null && company.getCompanyId() != null) {
+            qwSopMapper.updateIsRatingByCompanyId(company.getCompanyId(), company.getLevel());
+        }
+        return rows;
     }
     // 绑定小程序
     public void bindMiniApp(Company company){

+ 3 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVO.java

@@ -108,6 +108,9 @@ public class CompanyVO implements Serializable
 
     // 控制休息提示是否打开要暂停  0-关闭 1-打开 null-默认打开
     private Integer isOpenRestReminder;
+
+    /** 是否强制评级 0否 1是 */
+    private Integer level;
     //主程序
    @TableField(exist = false)
    private String masterAppName;

+ 17 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -6,6 +6,7 @@ import lombok.Data;
 import java.io.Serializable;
 import java.math.BigDecimal;
 import java.time.LocalTime;
+import java.util.Date;
 import java.util.List;
 
 @Data
@@ -102,6 +103,22 @@ public class CourseConfig implements Serializable {
     // 控制休息提示是否打开 默认打开 0-关闭 1-打开
     private Integer isOpenRestReminder;
 
+    /**
+     * 是否开启按项目自动看课重粉限制(关闭则不限制)
+     */
+    private Boolean projectRepeatLimitEnabled;
+
+    /**
+     * 按项目重粉限制生效时间,此时间之后的看课行为才受限
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date projectRepeatLimitEffectiveTime;
+
+    /**
+     * 先导课是否豁免重粉限制,默认 true
+     */
+    private Boolean pilotCourseSkipRepeatLimit;
+
 
     @Data
     public static class DisabledTimeVo{

+ 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/mapper/FsUserTalentFollowMapper.java

@@ -68,6 +68,16 @@ public interface FsUserTalentFollowMapper
     @Select("select ifnull(count(1),0) from fs_user_talent_follow  where talent_id =#{talentId} and user_id=#{userId}")
     int checkFollow(@Param("talentId") Long talentId, @Param("userId")long userId);
 
+    @Select("<script>" +
+            "select talent_id from fs_user_talent_follow " +
+            "where user_id = #{userId} " +
+            "and talent_id in " +
+            "<foreach item='talentId' collection='talentIds' open='(' separator=',' close=')'>" +
+            "#{talentId}" +
+            "</foreach>" +
+            "</script>")
+    List<Long> selectFollowedTalentIds(@Param("talentIds") List<Long> talentIds, @Param("userId") long userId);
+
 
     @Delete("delete from fs_user_talent_follow  where talent_id =#{talentId} and user_id=#{userId} ")
     int deleteFollow(@Param("talentId") Long talentId,@Param("userId")long userId);

+ 1 - 1
fs-service/src/main/java/com/fs/course/mapper/FsUserVideoFavoriteMapper.java

@@ -76,7 +76,7 @@ public interface FsUserVideoFavoriteMapper
 
 
     @Select("<script>" +
-            "select video_id, ifnull(count(1), 0) > 0 as favorited " +
+            "select video_id, ifnull(count(1), 0) > 0 as favorite " +
             "from fs_user_video_favorite " +
             "where video_id in " +
             "<foreach item='videoId' collection='videoIds' open='(' separator=',' close=')'>" +

+ 6 - 6
fs-service/src/main/java/com/fs/course/mapper/FsUserVideoMapper.java

@@ -132,7 +132,7 @@ public interface FsUserVideoMapper
     int minusFavorite(Long videoId);
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,p.img_url,p.package_name,v.upload_type,v.shares,v.add_num from fs_user_video v " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
@@ -150,7 +150,7 @@ public interface FsUserVideoMapper
     void updateCommentCount(@Param("videoId") Long videoId, @Param("commentCount") Integer commentCount);
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares from fs_user_video_favorite f " +
             "left join fs_user_video v on v.video_id = f.video_id " +
@@ -163,7 +163,7 @@ public interface FsUserVideoMapper
 
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares from fs_user_video_like l " +
             "left join fs_user_video v on v.video_id = l.video_id " +
@@ -176,7 +176,7 @@ public interface FsUserVideoMapper
 
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares from fs_user_video_comment c " +
             "left join fs_user_video v on v.video_id = c.video_id " +
@@ -205,7 +205,7 @@ public interface FsUserVideoMapper
     int updateViews(Long videoId);
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,p.img_url,p.package_name,v.upload_type,v.shares from fs_user_video v " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
@@ -252,7 +252,7 @@ public interface FsUserVideoMapper
     int countFavoriteVideos(@Param("userId") Long userId);
 
     @Select({"<script> " +
-            "select v.video_id as id,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
+            "select v.video_id as id,v.talent_id as talentId,t.user_id as userId,v.title,v.description as msg,t.nick_name as username,t.avatar as headImg, " +
             "v.thumbnail as cover,v.url as src,v.likes as likeNum,v.comments as smsNum,v.favorite_num," +
             "v.create_time,v.views as playNumber,v.product_id,p.img_url,p.package_name,v.upload_type,v.shares,v.add_num,v.is_audit,v.status from fs_user_video v " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +

+ 19 - 0
fs-service/src/main/java/com/fs/course/mq/CourseRepeatByProjectMqConstants.java

@@ -0,0 +1,19 @@
+package com.fs.course.mq;
+
+/**
+ * 看课按项目重粉判断 MQ 常量
+ */
+public final class CourseRepeatByProjectMqConstants {
+
+    public static final String TOPIC = "course-repeat-by-project";
+
+    public static final String CONSUMER_GROUP = "course-repeat-by-project-group";
+
+    /** 发送去重 Redis 前缀,key = prefix + userId + ":" + videoId */
+    public static final String DEDUP_KEY_PREFIX = "course:repeat:by-project:dedup:";
+
+    public static final int DEDUP_SECONDS = 60;
+
+    private CourseRepeatByProjectMqConstants() {
+    }
+}

+ 33 - 0
fs-service/src/main/java/com/fs/course/service/ICourseProjectSalesBindService.java

@@ -0,0 +1,33 @@
+package com.fs.course.service;
+
+import com.fs.common.core.domain.R;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+
+/**
+ * 按项目看课销售绑定:isAddKf 入口同步校验与首次绑定
+ */
+public interface ICourseProjectSalesBindService {
+
+    /**
+     * 全局开关开启且已到生效时间
+     */
+    boolean isLimitActive(CourseConfig config);
+
+    /**
+     * 加载 course.config
+     */
+    CourseConfig loadCourseConfig();
+
+    /**
+     * 是否先导课(豁免重粉限制)
+     */
+    boolean isPilotCourse(Long videoId, CourseConfig config);
+
+    /**
+     * 同步校验并在首次进入时写入 fs_user_company_qw。
+     *
+     * @return null 表示通过,否则为拦截响应
+     */
+    R checkAndBind(FsUserCourseVideoAddKfUParam param);
+}

+ 15 - 0
fs-service/src/main/java/com/fs/course/service/ICourseRepeatByProjectService.java

@@ -0,0 +1,15 @@
+package com.fs.course.service;
+
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import com.fs.his.domain.FsUser;
+
+/**
+ * 看课按项目重粉判断与打标
+ */
+public interface ICourseRepeatByProjectService {
+
+    /**
+     * 按看课项目判断并重粉打标:同一项目下由不同销售观看视为重粉,跨项目不同销售不算重粉。
+     */
+    void checkAndMarkRepeatByProject(FsUserCourseVideoAddKfUParam param, FsUser fsUser);
+}

+ 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();
+
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserVideoService.java

@@ -86,6 +86,8 @@ public interface IFsUserVideoService {
 
     FsUserVideoListUVO getVideoById(Long videoId);
 
+    FsUserVideoListUVO getVideoById(Long videoId, Long userId);
+
     int auditUserVideos(FsUserVideoAuditParam param);
 
     int updateFsUserVideoIsShow(Long[] videoIds, int i);

+ 249 - 0
fs-service/src/main/java/com/fs/course/service/impl/CourseProjectSalesBindServiceImpl.java

@@ -0,0 +1,249 @@
+package com.fs.course.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsUserCompanyQw;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCompanyQwMapper;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import com.fs.course.service.ICourseProjectSalesBindService;
+import com.fs.course.support.CourseProjectEquivalence;
+import com.fs.course.support.CourseProjectSalesBindConstants;
+import com.fs.enums.ExceptionCodeEnum;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class CourseProjectSalesBindServiceImpl implements ICourseProjectSalesBindService {
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FsUserCompanyQwMapper fsUserCompanyQwMapper;
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Override
+    public boolean isLimitActive(CourseConfig config) {
+        if (config == null || !Boolean.TRUE.equals(config.getProjectRepeatLimitEnabled())) {
+            return false;
+        }
+        Date effectiveTime = config.getProjectRepeatLimitEffectiveTime();
+        if (effectiveTime == null) {
+            return false;
+        }
+        return !new Date().before(effectiveTime);
+    }
+
+    @Override
+    public CourseConfig loadCourseConfig() {
+        String json = configService.selectConfigByKey("course.config");
+        if (StringUtils.isEmpty(json)) {
+            return new CourseConfig();
+        }
+        return JSONUtil.toBean(json, CourseConfig.class);
+    }
+
+    @Override
+    public boolean isPilotCourse(Long videoId, CourseConfig config) {
+        if (videoId == null) {
+            return false;
+        }
+        if (config != null && Boolean.FALSE.equals(config.getPilotCourseSkipRepeatLimit())) {
+            return false;
+        }
+        FsUserCourseVideo video = getVideoCached(videoId);
+        if (video == null) {
+            return false;
+        }
+        if (video.getIsFirst() != null && video.getIsFirst() == 1) {
+            return true;
+        }
+        return StringUtils.isNotEmpty(video.getTitle()) && video.getTitle().contains("先导课");
+    }
+
+    @Override
+    public R checkAndBind(FsUserCourseVideoAddKfUParam param) {
+        if (param == null || param.getUserId() == null || param.getCompanyUserId() == null) {
+            return null;
+        }
+
+        CourseConfig config = loadCourseConfig();
+        if (!isLimitActive(config)) {
+            return null;
+        }
+
+        if (isPilotCourse(param.getVideoId(), config)) {
+            log.debug("按项目重粉限制跳过先导课 userId={}, videoId={}", param.getUserId(), param.getVideoId());
+            return null;
+        }
+
+        Long projectId = resolveCourseProjectId(param);
+        if (projectId == null || projectId == 0L) {
+            return null;
+        }
+
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        if (CollectionUtils.isEmpty(equivalentProjectIds)) {
+            return null;
+        }
+
+        Long currentCompanyUserId = param.getCompanyUserId();
+        FsUserCompanyQw matchedBind = null;
+
+        for (Long equivalentProjectId : equivalentProjectIds) {
+            FsUserCompanyQw existBind = getBindRecord(param.getUserId(), equivalentProjectId);
+            if (existBind == null || existBind.getCompanyUserId() == null) {
+                continue;
+            }
+            if (!existBind.getCompanyUserId().equals(currentCompanyUserId)) {
+                log.info("按项目重粉限制拦截 userId={}, projectId={}, boundSales={}, currentSales={}, firstBindTime={}",
+                        param.getUserId(), equivalentProjectId, existBind.getCompanyUserId(),
+                        currentCompanyUserId, existBind.getFirstBindTime());
+                return R.error(ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getCode(),
+                        ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getDescription());
+            }
+            matchedBind = existBind;
+        }
+
+        if (matchedBind != null) {
+            for (Long equivalentProjectId : equivalentProjectIds) {
+                cacheBind(param.getUserId(), equivalentProjectId, matchedBind.getCompanyUserId());
+            }
+            return null;
+        }
+
+        return tryFirstBind(param, projectId, currentCompanyUserId);
+    }
+
+    private R tryFirstBind(FsUserCourseVideoAddKfUParam param, Long projectId, Long currentCompanyUserId) {
+        Long qwUserId = parseQwUserId(param.getQwUserId());
+        try {
+            fsUserCompanyQwMapper.insertOrUpdate(
+                    param.getUserId(),
+                    projectId,
+                    qwUserId,
+                    currentCompanyUserId,
+                    param.getCompanyId(),
+                    param.getQwExternalId(),
+                    param.getCourseId(),
+                    param.getVideoId()
+            );
+        } catch (Exception e) {
+            log.error("按项目销售绑定写入失败 userId={}, projectId={}", param.getUserId(), projectId, e);
+            return null;
+        }
+
+        FsUserCompanyQw bind = fsUserCompanyQwMapper.selectByUserAndProject(param.getUserId(), projectId);
+        if (bind != null && bind.getCompanyUserId() != null
+                && !bind.getCompanyUserId().equals(currentCompanyUserId)) {
+            log.info("按项目重粉限制并发拦截 userId={}, projectId={}, boundSales={}, currentSales={}, firstBindTime={}",
+                    param.getUserId(), projectId, bind.getCompanyUserId(), currentCompanyUserId, bind.getFirstBindTime());
+            return R.error(ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getCode(),
+                    ExceptionCodeEnum.USER_PROJECT_OTHER_SALES_BOUND.getDescription());
+        }
+
+        cacheBind(param.getUserId(), projectId, currentCompanyUserId);
+        log.info("按项目销售首次绑定 userId={}, projectId={}, companyUserId={}, firstBindTime={}",
+                param.getUserId(), projectId, currentCompanyUserId,
+                bind != null ? bind.getFirstBindTime() : null);
+        return null;
+    }
+
+    private FsUserCompanyQw getBindRecord(Long userId, Long projectId) {
+        Long cachedCompanyUserId = redisCache.getCacheObject(CourseProjectSalesBindConstants.bindCacheKey(userId, projectId));
+        if (cachedCompanyUserId != null) {
+            FsUserCompanyQw cached = new FsUserCompanyQw();
+            cached.setFsUserId(userId);
+            cached.setProjectId(projectId);
+            cached.setCompanyUserId(cachedCompanyUserId);
+            return cached;
+        }
+
+        FsUserCompanyQw existBind = fsUserCompanyQwMapper.selectByUserAndProject(userId, projectId);
+        if (existBind != null && existBind.getCompanyUserId() != null) {
+            cacheBind(userId, projectId, existBind.getCompanyUserId());
+        }
+        return existBind;
+    }
+
+    private void cacheBind(Long userId, Long projectId, Long companyUserId) {
+        if (userId == null || projectId == null || companyUserId == null) {
+            return;
+        }
+        redisCache.setCacheObject(
+                CourseProjectSalesBindConstants.bindCacheKey(userId, projectId),
+                companyUserId,
+                CourseProjectSalesBindConstants.BIND_CACHE_DAYS,
+                TimeUnit.DAYS
+        );
+    }
+
+    private Long resolveCourseProjectId(FsUserCourseVideoAddKfUParam param) {
+        if (param.getCourseId() != null) {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(param.getCourseId());
+            if (course != null && course.getProject() != null && course.getProject() != 0L) {
+                return course.getProject();
+            }
+        }
+        if (param.getVideoId() != null) {
+            FsUserCourseVideo video = getVideoCached(param.getVideoId());
+            if (video != null && video.getCourseId() != null) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+                if (course != null && course.getProject() != null && course.getProject() != 0L) {
+                    return course.getProject();
+                }
+            }
+        }
+        return 0L;
+    }
+
+    private FsUserCourseVideo getVideoCached(Long videoId) {
+        String cacheKey = CourseProjectSalesBindConstants.videoMetaCacheKey(videoId);
+        FsUserCourseVideo cached = redisCache.getCacheObject(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (video != null) {
+            FsUserCourseVideo meta = new FsUserCourseVideo();
+            meta.setVideoId(video.getVideoId());
+            meta.setCourseId(video.getCourseId());
+            meta.setTitle(video.getTitle());
+            meta.setIsFirst(video.getIsFirst());
+            redisCache.setCacheObject(cacheKey, meta,
+                    CourseProjectSalesBindConstants.VIDEO_META_CACHE_HOURS, TimeUnit.HOURS);
+            return meta;
+        }
+        return null;
+    }
+
+    private Long parseQwUserId(String qwUserId) {
+        if (StringUtils.isEmpty(qwUserId)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(qwUserId);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 74 - 0
fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectMqProducer.java

@@ -0,0 +1,74 @@
+package com.fs.course.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.mq.CourseRepeatByProjectMqConstants;
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import com.fs.course.vo.CourseRepeatByProjectMqMessage;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.client.producer.SendCallback;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 看课按项目重粉:投递 MQ(由 fs-qw-mq 消费)
+ */
+@Slf4j
+@Service
+public class CourseRepeatByProjectMqProducer {
+
+    @Autowired(required = false)
+    private RocketMQTemplate rocketMQTemplate;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 去重后异步投递重粉判断消息
+     */
+    public void submitCheck(FsUserCourseVideoAddKfUParam param) {
+        if (param == null || param.getUserId() == null) {
+            return;
+        }
+        if (rocketMQTemplate == null) {
+            log.error("RocketMQTemplate 未注入,跳过重粉 MQ 投递 userId={}", param.getUserId());
+            return;
+        }
+        String dedupKey = buildDedupKey(param);
+        if (!redisCache.setIfAbsent(dedupKey, "1", CourseRepeatByProjectMqConstants.DEDUP_SECONDS, TimeUnit.SECONDS)) {
+            log.debug("看课重粉 MQ 去重跳过 userId={}, videoId={}", param.getUserId(), param.getVideoId());
+            return;
+        }
+
+        CourseRepeatByProjectMqMessage message = new CourseRepeatByProjectMqMessage();
+        message.setParam(param);
+
+        try {
+            String payload = JSON.toJSONString(message);
+            rocketMQTemplate.asyncSend(CourseRepeatByProjectMqConstants.TOPIC, payload, new SendCallback() {
+                @Override
+                public void onSuccess(SendResult sendResult) {
+                    log.debug("看课重粉 MQ 投递成功 userId={}, msgId={}", param.getUserId(), sendResult.getMsgId());
+                }
+
+                @Override
+                public void onException(Throwable e) {
+                    log.error("看课重粉 MQ 投递失败 userId={}, videoId={}", param.getUserId(), param.getVideoId(), e);
+                    redisCache.deleteObject(dedupKey);
+                }
+            });
+        } catch (Exception e) {
+            log.error("看课重粉 MQ 投递异常 userId={}", param.getUserId(), e);
+            redisCache.deleteObject(dedupKey);
+        }
+    }
+
+    private String buildDedupKey(FsUserCourseVideoAddKfUParam param) {
+        Long videoId = param.getVideoId() != null ? param.getVideoId() : 0L;
+        return CourseRepeatByProjectMqConstants.DEDUP_KEY_PREFIX + param.getUserId() + ":" + videoId;
+    }
+}

+ 250 - 0
fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java

@@ -0,0 +1,250 @@
+package com.fs.course.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.*;
+import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.mapper.FsUserCompanyQwMapper;
+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;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.service.IQwExternalContactService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectService {
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+    @Autowired
+    private FsUserCompanyQwMapper fsUserCompanyQwMapper;
+    @Autowired
+    private IFsUserCompanyUserService userCompanyUserService;
+    @Autowired
+    private IFsUserCompanyBindService fsUserCompanyBindService;
+    @Autowired
+    private FsCourseWatchLogMapper courseWatchLogMapper;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Override
+    public void checkAndMarkRepeatByProject(FsUserCourseVideoAddKfUParam param, FsUser fsUser) {
+        Long projectId = resolveCourseProjectId(param);
+        if (projectId == null || projectId == 0L) {
+            log.debug("看课重粉判断跳过:课程未关联项目,userId={}", param.getUserId());
+            return;
+        }
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        boolean projectRepeat = isRepeatWatchByProject(param, equivalentProjectIds);
+        boolean userAlreadyRepeat = fsUser.getIsRepeat() != null && fsUser.getIsRepeat() == 1;
+        log.info("看课重粉判断(按项目):userId={}, projectId={}, equivalentProjectIds={}, projectRepeat={}, userAlreadyRepeat={}",
+                param.getUserId(), projectId, equivalentProjectIds, projectRepeat, userAlreadyRepeat);
+        if (projectRepeat || userAlreadyRepeat) {
+            markRepeatFansByProject(fsUser, param, equivalentProjectIds);
+            return;
+        }
+        if (param.getQwExternalId() != null) {
+            recordUserProjectBind(param, param.getQwExternalId(), "isAddKf");
+        }
+    }
+
+    private Long resolveCourseProjectId(FsUserCourseVideoAddKfUParam param) {
+        if (param.getCourseId() != null) {
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(param.getCourseId());
+            if (course != null && course.getProject() != null && course.getProject() != 0L) {
+                return course.getProject();
+            }
+        }
+        if (param.getVideoId() != null) {
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+            if (video != null && video.getCourseId() != null) {
+                FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+                if (course != null && course.getProject() != null) {
+                    return course.getProject();
+                }
+            }
+        }
+        return 0L;
+    }
+
+    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());
+
+        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;
+                }
+            }
+        }
+
+        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).in("project_id", equivalentProjectIds));
+        if (CollectionUtils.isNotEmpty(binds)) {
+            if (currentQwUserId != null && binds.stream().map(FsUserCompanyBind::getQwUserId).filter(Objects::nonNull)
+                    .anyMatch(qwId -> !qwId.equals(currentQwUserId))) {
+                return true;
+            }
+            if (currentCompanyUserId != null && binds.stream().map(FsUserCompanyBind::getCompanyUserId).filter(Objects::nonNull)
+                    .anyMatch(id -> !id.equals(currentCompanyUserId))) {
+                return true;
+            }
+        }
+
+        QueryWrapper<FsCourseWatchLog> watchWrapper = new QueryWrapper<FsCourseWatchLog>()
+                .in("project", equivalentProjectIds)
+                .isNotNull("company_user_id");
+        watchWrapper.and(w -> {
+            w.eq("user_id", userId);
+            if (param.getQwExternalId() != null) {
+                w.or().eq("qw_external_contact_id", param.getQwExternalId());
+            }
+            return w;
+        });
+        List<FsCourseWatchLog> watchLogs = courseWatchLogMapper.selectList(watchWrapper);
+        if (CollectionUtils.isNotEmpty(watchLogs) && currentCompanyUserId != null) {
+            return watchLogs.stream().map(FsCourseWatchLog::getCompanyUserId).filter(Objects::nonNull)
+                    .anyMatch(id -> !id.equals(currentCompanyUserId));
+        }
+        return false;
+    }
+
+    private Long parseQwUserId(String qwUserId) {
+        if (StringUtils.isEmpty(qwUserId)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(qwUserId);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    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());
+            if (current != null) {
+                current.setIsRepeat(1);
+                toUpdate.add(current);
+            }
+        }
+        List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
+                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());
+            for (Long contactId : contactIds) {
+                if (toUpdate.stream().anyMatch(c -> contactId.equals(c.getId()))) {
+                    continue;
+                }
+                QwExternalContact contact = qwExternalContactMapper.selectById(contactId);
+                if (contact != null) {
+                    contact.setIsRepeat(1);
+                    toUpdate.add(contact);
+                }
+            }
+        }
+        if (toUpdate.isEmpty()) {
+            List<QwExternalContact> noRepeatList = qwExternalContactMapper.selectList(
+                    new QueryWrapper<QwExternalContact>().eq("fs_user_id", param.getUserId()).eq("is_repeat", 0));
+            noRepeatList.forEach(e -> e.setIsRepeat(1));
+            toUpdate.addAll(noRepeatList);
+        }
+        if (!toUpdate.isEmpty()) {
+            qwExternalContactService.updateBatchById(toUpdate);
+        }
+        if (fsUser.getIsRepeat() == null || fsUser.getIsRepeat() != 1) {
+            fsUser.setIsRepeat(1);
+            fsUserMapper.updateFsUser(fsUser);
+        }
+        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);
+            }
+        }
+    }
+
+    private void recordUserProjectBind(FsUserCourseVideoAddKfUParam param, Long qwExternalId, String sceneName) {
+        try {
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+            if (video == null || video.getCourseId() == null) {
+                log.warn("【{}-记录绑定关系】视频信息不存在,视频ID: {}", sceneName, param.getVideoId());
+                return;
+            }
+
+            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+            Long projectId = course != null && course.getProject() != null ? course.getProject() : 0L;
+
+            if (projectId == 0L) {
+                log.debug("【{}-记录绑定关系】课程未关联项目,跳过记录,课程ID: {}", sceneName, video.getCourseId());
+                return;
+            }
+
+            Long qwUserId = param.getQwUserId() != null ? Long.parseLong(param.getQwUserId()) : null;
+            fsUserCompanyQwMapper.insertOrUpdate(
+                    param.getUserId(),
+                    projectId,
+                    qwUserId,
+                    param.getCompanyUserId(),
+                    param.getCompanyId(),
+                    qwExternalId,
+                    video.getCourseId(),
+                    param.getVideoId()
+            );
+
+            log.info("【{}-记录绑定关系成功】用户ID: {}, 项目ID: {}, 销售ID: {}, 外部联系人id: {}",
+                    sceneName, param.getUserId(), projectId, qwUserId, qwExternalId);
+
+        } catch (Exception e) {
+            log.error("【{}-记录绑定关系失败】用户ID: {}, 视频ID: {}, 错误: {}",
+                    sceneName, param.getUserId(), param.getVideoId(), e.getMessage(), e);
+        }
+    }
+}

+ 166 - 92
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, 500);
     }
 
+    @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,36 +543,60 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public void checkFsUserWatchStatus() {
         log.info("WXH5-开始更新会员看课中断记录>>>>>");
-        // 从 Redis 中获取所有正在看课的用户记录
-        Collection<String> keys = redisCache.keys("h5wxuser:watch:heartbeat:*");
+        // 必须遍历索引全集(含已 TTL 过期的 key),否则 key 过期后只会从索引移除、不会写 log_type=4
+        Set<String> keys = h5WxUserWatchRedisUtil.listAllHeartbeatIndexMembers();
         LocalDateTime now = LocalDateTime.now();
         List<FsCourseWatchLog> logs = new ArrayList<>();
         for (String key : keys) {
-            FsCourseWatchLog watchLog = new FsCourseWatchLog();
-            String[] parts = key.split(":");
-            Long userId = Long.parseLong(parts[3]);
-            Long videoId = Long.parseLong(parts[4]);
-            Long companyUserId = Long.parseLong(parts[5]);
-            // 获取最后心跳时间
-            String lastHeartbeatStr = redisCache.getCacheObject(key);
-            if (lastHeartbeatStr == null) {
-                continue; // 如果 Redis 中没有记录,跳过
-            }
-            LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr);
-            Duration duration = Duration.between(lastHeartbeatTime, now);
+            try {
+                Long userId;
+                Long videoId;
+                Long companyUserId;
+                try {
+                    String[] parts = key.split(":");
+                    userId = Long.parseLong(parts[3]);
+                    videoId = Long.parseLong(parts[4]);
+                    companyUserId = Long.parseLong(parts[5]);
+                } catch (Exception e) {
+                    log.error("key中id为null:{}", key);
+                    h5WxUserWatchRedisUtil.untrackHeartbeat(key);
+                    continue;
+                }
 
-            watchLog.setVideoId(videoId);
-            watchLog.setUserId(userId);
-            watchLog.setCompanyUserId(companyUserId);
-            // 如果超过一分钟没有心跳,标记为“观看中断”
-            if (duration.getSeconds() >= 60) {
-                watchLog.setLogType(4);
-                // 从 Redis 中删除该记录
-                redisCache.deleteObject(key);
-            } else {
-                watchLog.setLogType(1);
+                FsCourseWatchLog watchLog = new FsCourseWatchLog();
+                watchLog.setVideoId(videoId);
+                watchLog.setUserId(userId);
+                watchLog.setCompanyUserId(companyUserId);
+
+                if (!Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(key))) {
+                    watchLog.setLogType(4);
+                    h5WxUserWatchRedisUtil.untrackHeartbeat(key);
+                    logs.add(watchLog);
+                    continue;
+                }
+
+                String lastHeartbeatStr = redisCache.getCacheObject(key);
+                if (com.fs.common.utils.StringUtils.isEmpty(lastHeartbeatStr)) {
+                    watchLog.setLogType(4);
+                    redisCache.deleteObject(key);
+                    h5WxUserWatchRedisUtil.untrackHeartbeat(key);
+                    logs.add(watchLog);
+                    continue;
+                }
+
+                LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr);
+                Duration duration = Duration.between(lastHeartbeatTime, now);
+                if (duration.getSeconds() >= 60) {
+                    watchLog.setLogType(4);
+                    redisCache.deleteObject(key);
+                    h5WxUserWatchRedisUtil.untrackHeartbeat(key);
+                } else {
+                    watchLog.setLogType(1);
+                }
+                logs.add(watchLog);
+            } catch (Exception e) {
+                log.error("处理heartbeat key {} 时发生异常: {}", key, e.getMessage());
             }
-            logs.add(watchLog);
         }
         batchUpdateFsUserCourseWatchLog(logs, 500);
     }
@@ -1100,32 +1199,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{
                     //检查看课中断
                     // 获取最后心跳时间

+ 60 - 9
fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java

@@ -15,6 +15,7 @@ import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCompanyBindMapper;
 import com.fs.course.mapper.FsUserCourseMapper;
 import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.course.support.CourseProjectEquivalence;
 import com.fs.course.vo.CourseProgressResultVO;
 import com.fs.course.vo.RelatedSalesResultVO;
 import com.fs.course.vo.RepeatCourseHistoryVO;
@@ -35,7 +36,13 @@ import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * 用户客服关联Service业务层处理
@@ -158,12 +165,10 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
                     return false;
                 }
             }
-            if (qwExternalContact.getUserRepeat() == 0) {
-                Integer i = baseMapper.selectCount(new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", fsUserId));
-                if (i > 1) {
-                    qwExternalContact.setUserRepeat(1);
-                    qwExternalContactMapper.updateById(qwExternalContact);
-                }
+            if (qwExternalContact.getUserRepeat() == 0 && project != 0
+                    && isRepeatWatchByProject(fsUserId, project, qwUserId)) {
+                qwExternalContact.setUserRepeat(1);
+                qwExternalContactMapper.updateById(qwExternalContact);
             }
             // 当前登录账号名称
             FsUserCompanyBind one = baseMapper.selectOne(new QueryWrapper<FsUserCompanyBind>()
@@ -283,15 +288,15 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             vo.setName(contact.getName());
             vo.setAvatar(contact.getAvatar());
             vo.setRemark(contact.getRemark());
-            vo.setUserRepeat(contact.getUserRepeat());
         }
+        vo.setUserRepeat(isUserRepeatByProject(fsUserId) ? 1 : 0);
 
         // 2. 查询关联销售(单条SQL + GROUP BY 优化)
         List<RelatedSalesResultVO> salesResults = baseMapper.getRelatedSalesByFsUserId(fsUserId);
         List<RepeatCourseHistoryVO.RelatedSalesVO> salesList = new ArrayList<>();
         for (RelatedSalesResultVO r : salesResults) {
             RepeatCourseHistoryVO.RelatedSalesVO s = new RepeatCourseHistoryVO.RelatedSalesVO();
-            boolean hasPermission = r.getCorpId() == null || permittedCorpIds.contains(r.getCorpId());
+            boolean hasPermission = hasCorpPermission(companyId, permittedCorpIds, r.getCorpId());
             s.setHasPermission(hasPermission);
             if (hasPermission) {
                 s.setQwUserName(r.getQwUserName());
@@ -322,7 +327,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             cp.setLatestSection(c.getLatestSection());
             cp.setLatestTime(c.getLatestTime());
             cp.setFinished(total > 0 && watched >= total);
-            boolean hasPermission = c.getCorpId() == null || permittedCorpIds.contains(c.getCorpId());
+            boolean hasPermission = hasCorpPermission(companyId, permittedCorpIds, c.getCorpId());
             cp.setHasPermission(hasPermission);
             if (hasPermission) {
                 cp.setQwUserName(c.getQwUserName());
@@ -353,4 +358,50 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
         }
         return corpIds;
     }
+
+    private boolean hasCorpPermission(Long companyId, List<String> permittedCorpIds, String corpId) {
+        if (companyId == null) {
+            return true;
+        }
+        return corpId == null || permittedCorpIds.contains(corpId);
+    }
+
+    /**
+     * 按项目判重:同一项目(含等价项目)下存在至少两个不同企微销售则视为看课重粉。
+     */
+    private boolean isUserRepeatByProject(Long fsUserId) {
+        List<FsUserCompanyBind> binds = baseMapper.selectList(
+                new QueryWrapper<FsUserCompanyBind>()
+                        .eq("fs_user_id", fsUserId)
+                        .isNotNull("project_id")
+                        .ne("project_id", 0)
+                        .isNotNull("qw_user_id"));
+        if (binds.isEmpty()) {
+            return false;
+        }
+        Map<String, Set<Long>> groupToQwUsers = new HashMap<>();
+        for (FsUserCompanyBind bind : binds) {
+            String groupKey = CourseProjectEquivalence.equivalentProjectIds(bind.getProjectId())
+                    .stream().sorted().map(String::valueOf).collect(Collectors.joining(","));
+            groupToQwUsers.computeIfAbsent(groupKey, k -> new HashSet<>()).add(bind.getQwUserId());
+        }
+        return groupToQwUsers.values().stream().anyMatch(qwUserIds -> qwUserIds.size() >= 2);
+    }
+
+    private boolean isRepeatWatchByProject(Long fsUserId, Long projectId, Long currentQwUserId) {
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        List<FsUserCompanyBind> binds = baseMapper.selectList(
+                new QueryWrapper<FsUserCompanyBind>()
+                        .eq("fs_user_id", fsUserId)
+                        .in("project_id", equivalentProjectIds)
+                        .isNotNull("qw_user_id"));
+        Set<Long> qwUserIds = binds.stream()
+                .map(FsUserCompanyBind::getQwUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (currentQwUserId != null) {
+            qwUserIds.add(currentQwUserId);
+        }
+        return qwUserIds.size() >= 2;
+    }
 }

+ 45 - 80
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -46,7 +46,9 @@ import com.fs.course.param.newfs.UserCourseVideoPageParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
+import com.fs.course.service.ICourseProjectSalesBindService;
 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 +179,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 +214,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     RedisCache redisCache;
     @Autowired
+    private H5WxUserWatchRedisUtil h5WxUserWatchRedisUtil;
+    @Autowired
     private ISysConfigService sysConfigService;
 
     @Autowired
@@ -258,6 +264,12 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private FsUserCompanyQwMapper fsUserCompanyQwMapper;
 
+    @Autowired
+    private CourseRepeatByProjectMqProducer courseRepeatByProjectMqProducer;
+
+    @Autowired
+    private ICourseProjectSalesBindService courseProjectSalesBindService;
+
     @Autowired
     private SysDictDataMapper sysDictDataMapper;
 
@@ -518,6 +530,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();
@@ -802,33 +824,16 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         String noRegisterMsg = "由于您还未完成注册,请联系伴学助手完成注册即可观看!";
         //非独属链接提示
         String noMemberMsg = "此链接已被绑定,请联系伴学助手领取您的专属链接,专属链接请勿分享哦!";
+
+        R bindCheck = courseProjectSalesBindService.checkAndBind(param);
+        if (bindCheck != null) {
+            return bindCheck;
+        }
+
         try {
-            new Thread(() -> {
-                log.info("自动重粉判断开始!");
-                try {
-                    QwExternalContact qwExternalContact = qwExternalContactMapper.selectById(param.getQwExternalId());
-                    if(fsUser.getIsRepeat() == 1){
-                        List<QwExternalContact> noRepeatList = qwExternalContactMapper.selectList(new QueryWrapper<QwExternalContact>().eq("fs_user_id", param.getUserId()).eq("is_repeat", 0));
-                        noRepeatList.add(qwExternalContact);
-                        noRepeatList.forEach(e -> e.setIsRepeat(1));
-                        qwExternalContactService.updateBatchById(noRepeatList);
-                        return;
-                    }
-                    Long userId = param.getUserId();
-                    List<QwExternalContact> qwExternalContactList = qwExternalContactMapper.selectQwExternalContactByFsUserId(userId);
-                    if(qwExternalContactList !=null && qwExternalContactList.size() > 1){
-                        qwExternalContactList.add(qwExternalContact);
-                        qwExternalContactList.forEach(e -> e.setIsRepeat(1));
-                        qwExternalContactService.updateBatchById(qwExternalContactList);
-                        fsUser.setIsRepeat(1);
-                        fsUserMapper.updateFsUser(fsUser);
-                    }
-                }catch (Exception e){
-                    log.error("看课重粉判断失败", e);
-                }
-            }).start();
-        }catch (Exception e){
-            log.error("看课重粉提交mq失败", e);
+            courseRepeatByProjectMqProducer.submitCheck(param);
+        } catch (Exception e) {
+            log.error("看课重粉 MQ 投递失败", e);
         }
 
         String json = configService.selectConfigByKey("course.config");
@@ -1279,53 +1284,6 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
     }
 
-    /**
-     * 记录用户-项目-销售绑定关系到 fs_user_company_qw 表
-     * 用于企微自动发课(send_type=2)的重粉判断优化
-     *
-     * @param param 看课参数
-     * @param qwExternalId 企微外部联系人id
-     * @param sceneName 场景名称(用于日志追踪)
-     */
-    private void recordUserProjectBind(FsUserCourseVideoAddKfUParam param, Long qwExternalId, String sceneName) {
-        try {
-            // 查询视频所属的课程和项目
-            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
-            if (video == null || video.getCourseId() == null) {
-                logger.warn("【{}-记录绑定关系】视频信息不存在,视频ID: {}", sceneName, param.getVideoId());
-                return;
-            }
-
-            FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
-            Long projectId = course != null && course.getProject() != null ? course.getProject() : 0L;
-
-            // 只对有项目ID的课程记录绑定关系
-            if (projectId == 0L) {
-                logger.debug("【{}-记录绑定关系】课程未关联项目,跳过记录,课程ID: {}", sceneName, video.getCourseId());
-                return;
-            }
-
-            Long qwUserId = param.getQwUserId() != null ? Long.parseLong(param.getQwUserId()) : null;
-            fsUserCompanyQwMapper.insertOrUpdate(
-                param.getUserId(),
-                projectId,
-                qwUserId,
-                param.getCompanyUserId(),
-                param.getCompanyId(),
-                qwExternalId,
-                video.getCourseId(),
-                param.getVideoId()
-            );
-
-            logger.info("【{}-记录绑定关系成功】用户ID: {}, 项目ID: {}, 销售ID: {}, 外部联系人id: {}",
-                sceneName, param.getUserId(), projectId, qwUserId, qwExternalId);
-
-        } catch (Exception e) {
-            logger.error("【{}-记录绑定关系失败】用户ID: {}, 视频ID: {}, 错误: {}",
-                sceneName, param.getUserId(), param.getVideoId(), e.getMessage(), e);
-        }
-    }
-
     private R addCustomerService(String qwUserById,String msg){
         if ("济南联志健康".equals(signProjectName)){
             return R.error(400,msg).put("qrcode", "");
@@ -2860,10 +2818,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);
         }
@@ -3002,10 +2960,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);
         }
 
         // 添加会员销售关系表数据
@@ -3371,7 +3329,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);
@@ -3382,10 +3340,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());
@@ -3691,10 +3655,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);
     }
 
 

+ 0 - 1
fs-service/src/main/java/com/fs/course/service/impl/FsUserTalentServiceImpl.java

@@ -160,7 +160,6 @@ public class FsUserTalentServiceImpl implements IFsUserTalentService
         fsUserTalent.setIsAudit(1l);
         fsUserTalent.setAuditTime(new Date());
         fsUserTalent.setStatus(1l);
-        fsUserTalent.setIsDel(1l);
         fsUserTalentMapper.insertFsUserTalent(fsUserTalent);
         return 1;
     }

+ 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;
+    }
 }

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

@@ -4,7 +4,6 @@ import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import com.fs.common.core.domain.R;
-import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import lombok.Synchronized;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -26,7 +25,7 @@ public class FsUserVideoFavoriteServiceImpl implements IFsUserVideoFavoriteServi
 {
     @Autowired
     private FsUserVideoFavoriteMapper fsUserVideoFavoriteMapper;
-    @Autowired(required=false)
+    @Autowired(required = false)
     private RedisTemplate<String, Boolean> redisTemplate;
 
     /**
@@ -104,14 +103,17 @@ public class FsUserVideoFavoriteServiceImpl implements IFsUserVideoFavoriteServi
     }
     private static final String FAVORITE_KEY_PREFIX = "favorite:video:";
     private static final String NO_FAVORITE_KEY_PREFIX = "nofavorite:video:";
+
     @Override
     public R checkFavorite(Long videoId, long userId) {
         String key = FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
         Boolean hasFavorite = redisTemplate.opsForValue().get(key);
         if (hasFavorite != null && hasFavorite) {
-            return R.ok().put("isFavorite",1);
-        }else {
-            return fsUserVideoFavoriteMapper.checkFavorite(videoId, userId) > 0 ? R.ok().put("isFavorite", 1) : R.error().put("isFavorite", 0);
+            return R.ok().put("isFavorite", 1);
+        } else {
+            return fsUserVideoFavoriteMapper.checkFavorite(videoId, userId) > 0
+                    ? R.ok().put("isFavorite", 1)
+                    : R.error().put("isFavorite", 0);
         }
     }
 
@@ -131,8 +133,6 @@ public class FsUserVideoFavoriteServiceImpl implements IFsUserVideoFavoriteServi
     public void deleteFavorite(Long videoId, long userId) {
         String key = FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
         redisTemplate.delete(key);
-
-        // 将取消点赞记录写入Redis
         String unlikeKey = NO_FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
         redisTemplate.opsForValue().set(unlikeKey, true, 1, TimeUnit.DAYS);
     }

+ 6 - 7
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoLikeServiceImpl.java

@@ -4,7 +4,6 @@ import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import com.fs.common.core.domain.R;
-import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import lombok.Synchronized;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -106,14 +105,17 @@ public class FsUserVideoLikeServiceImpl implements IFsUserVideoLikeService
 
     private static final String LIKE_KEY_PREFIX = "like:video:";
     private static final String UNLIKE_KEY_PREFIX = "unlike:video:";
+
     @Override
     public R checkLike(Long videoId, long userId) {
         String key = LIKE_KEY_PREFIX + videoId + ":user:" + userId;
         Boolean hasLiked = redisTemplate.opsForValue().get(key);
         if (hasLiked != null && hasLiked) {
-            return R.ok().put("isLike",1);
-        }else {
-            return fsUserVideoLikeMapper.checkLike(videoId,userId)>0?R.ok().put("isLike",1):R.error().put("isLike",0);
+            return R.ok().put("isLike", 1);
+        } else {
+            return fsUserVideoLikeMapper.checkLike(videoId, userId) > 0
+                    ? R.ok().put("isLike", 1)
+                    : R.error().put("isLike", 0);
         }
     }
 
@@ -131,11 +133,8 @@ public class FsUserVideoLikeServiceImpl implements IFsUserVideoLikeService
     @Transactional
     @Synchronized
     public void unlikeVideo(Long videoId, long userId) {
-
         String key = LIKE_KEY_PREFIX + videoId + ":user:" + userId;
         redisTemplate.delete(key);
-
-        // 将取消点赞记录写入Redis
         String unlikeKey = UNLIKE_KEY_PREFIX + videoId + ":user:" + userId;
         redisTemplate.opsForValue().set(unlikeKey, true, 1, TimeUnit.DAYS);
     }

+ 39 - 35
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java

@@ -57,6 +57,8 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
     @Autowired
     private FsUserVideoCommentMapper fsUserVideoCommentMapper;
     @Autowired
+    private FsUserTalentFollowMapper fsUserTalentFollowMapper;
+    @Autowired
     private IRecommendationService recommendationService;
     @Autowired
     private RedisTemplate redisTemplate;
@@ -208,36 +210,30 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
         if (list != null && !list.isEmpty()) {
             List<Long> videoIds = list.stream().map(vo -> Long.parseLong(vo.getId())).collect(Collectors.toList());
 
-            // 批量查询Redis中的点赞信息
             List<String> likeKeys = videoIds.stream()
                     .map(videoId -> LIKE_KEY_PREFIX + videoId + ":user:" + userId)
                     .collect(Collectors.toList());
             List<Boolean> redisLikes = redisTemplate.opsForValue().multiGet(likeKeys);
 
-            // 批量查询Redis中的收藏信息
             List<String> favoriteKeys = videoIds.stream()
                     .map(videoId -> FAVORITE_KEY_PREFIX + videoId + ":user:" + userId)
                     .collect(Collectors.toList());
             List<Boolean> redisFavorites = redisTemplate.opsForValue().multiGet(favoriteKeys);
 
-            // 缓存未命中的视频ID列表
             List<Long> missingLikeVideoIds = new ArrayList<>();
             List<Long> missingFavoriteVideoIds = new ArrayList<>();
 
-            // 设置点赞和收藏信息
             for (int i = 0; i < list.size(); i++) {
                 FsUserVideoListUVO vo = list.get(i);
                 Long videoId = Long.parseLong(vo.getId());
                 Integer commentCount = fsUserVideoCommentMapper.selectCommentCountByVideos(videoId);
                 vo.setSmsNum(commentCount);
-                // 优先使用Redis中的点赞数据
                 if (redisLikes != null && redisLikes.get(i) != null) {
                     vo.setLike(Boolean.TRUE.equals(redisLikes.get(i)) ? 1 : 0);
                 } else {
                     missingLikeVideoIds.add(videoId);
                 }
 
-                // 优先使用Redis中的收藏数据
                 if (redisFavorites != null && redisFavorites.get(i) != null) {
                     vo.setFavorite(Boolean.TRUE.equals(redisFavorites.get(i)) ? 1 : 0);
                 } else {
@@ -245,7 +241,6 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
                 }
             }
 
-            // 查询数据库中的点赞和收藏信息
             if (!missingLikeVideoIds.isEmpty()) {
                 Map<Long, VideoLikeStatusDTO> likeMap = fsUserVideoLikeMapper.checkLikes(missingLikeVideoIds, userId);
                 for (FsUserVideoListUVO vo : list) {
@@ -266,10 +261,33 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
                 }
             }
 
+            fillTalentFollowInfo(userId, list);
         }
         return list;
     }
 
+    private void fillTalentFollowInfo(Long userId, List<FsUserVideoListUVO> list) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> talentIds = list.stream()
+                .map(FsUserVideoListUVO::getTalentId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        Set<Long> followedTalentIds = Collections.emptySet();
+        if (userId != null && !talentIds.isEmpty()) {
+            followedTalentIds = new HashSet<>(fsUserTalentFollowMapper.selectFollowedTalentIds(talentIds, userId));
+        }
+        for (FsUserVideoListUVO vo : list) {
+            if (vo.getTalentId() != null && followedTalentIds.contains(vo.getTalentId())) {
+                vo.setIsFollow(1);
+            } else {
+                vo.setIsFollow(0);
+            }
+        }
+    }
+
     @Override
     public FsUserVideoPVO selectFsUserVideoPVOByVideoId(Long videoId) {
         return fsUserVideoMapper.selectFsUserVideoPVO(videoId);
@@ -282,7 +300,16 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
 
     @Override
     public FsUserVideoListUVO getVideoById(Long videoId) {
-        return fsUserVideoMapper.selectFsUserVideoListUVOByVideoId(videoId);
+        return getVideoById(videoId, null);
+    }
+
+    @Override
+    public FsUserVideoListUVO getVideoById(Long videoId, Long userId) {
+        FsUserVideoListUVO video = fsUserVideoMapper.selectFsUserVideoListUVOByVideoId(videoId);
+        if (video != null && userId != null) {
+            selectLikesAndFavorites(userId, Collections.singletonList(video));
+        }
+        return video;
     }
 
     @Override
@@ -346,9 +373,9 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
                     fsUserVideo.setAddNum(likeAddNum + "," + favoriteAddNum + "," + sharesAddNum);
                     updateFsUserVideo(fsUserVideo);
                 }
-                vo.setLikeNum(vo.getLikeNum() + likeAddNum);
-                vo.setFavoriteNum(vo.getFavoriteNum() + favoriteAddNum);
-                vo.setShares(vo.getShares() + sharesAddNum);
+                vo.setLikeNum((vo.getLikeNum() == null ? 0 : vo.getLikeNum()) + likeAddNum);
+                vo.setFavoriteNum((vo.getFavoriteNum() == null ? 0 : vo.getFavoriteNum()) + favoriteAddNum);
+                vo.setShares((vo.getShares() == null ? 0L : vo.getShares()) + sharesAddNum);
 
             }
         }
@@ -382,16 +409,9 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
     @Override
     public List<FsUserVideoListUVO> selectFsUserVideoListUVOByUser(Long talentId, boolean oneSelf, Long userId) {
         List<FsUserVideoListUVO> list = fsUserVideoMapper.selectFsUserVideoListUVOByUser(talentId, oneSelf);
-        /*if (param != null && param.getUserId() != null) {
-            Long userId = param.getUserId();
+        if (userId != null && !list.isEmpty()) {
             list = selectLikesAndFavorites(userId, list);
-        }*/
-        // 当前视频是否被自己喜欢或收藏
-        if (list.size() > 0) {
-            selectLikesAndFavoritesByMyself(list,userId);
         }
-
-
         return list;
     }
 
@@ -432,22 +452,6 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
         return R.ok();
     }
 
-    private void selectLikesAndFavoritesByMyself(List<FsUserVideoListUVO> list, long userId) {
-        List<Long> videoIds = list.stream().map(vo -> Long.parseLong(vo.getId())).collect(Collectors.toList());
-        Map<Long, VideoLikeStatusDTO> likeMaps = fsUserVideoLikeMapper.checkLikes(videoIds, userId);
-        Map<Long, VideoFavoriteStatusDTO> FavoriteMaps = fsUserVideoFavoriteMapper.checkFavorites(videoIds, userId);
-        long videoId;
-        for (FsUserVideoListUVO entity : list) {
-            videoId = Long.parseLong(entity.getId());
-            if (likeMaps.containsKey(videoId)) {
-                entity.setLike(1);
-            }
-            if (FavoriteMaps.containsKey(videoId)) {
-                entity.setFavorite(1);
-            }
-        }
-    }
-
     public static String updateUrlPrefix(String url) {
         final String oldPrefix = "https://obs.ylrztop.com";
         final String newPrefix = "https://rtobs.ylrztop.com";

+ 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));
+    }
+}

+ 30 - 0
fs-service/src/main/java/com/fs/course/support/CourseProjectSalesBindConstants.java

@@ -0,0 +1,30 @@
+package com.fs.course.support;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 按项目看课销售绑定 Redis 缓存常量
+ */
+public final class CourseProjectSalesBindConstants {
+
+    /** 用户-项目绑定销售缓存:course:project:sales:bind:{userId}:{projectId} -> companyUserId */
+    public static final String BIND_CACHE_KEY_PREFIX = "course:project:sales:bind:";
+
+    /** 视频元数据缓存:course:project:sales:video:{videoId} */
+    public static final String VIDEO_META_CACHE_KEY_PREFIX = "course:project:sales:video:";
+
+    public static final int BIND_CACHE_DAYS = 7;
+
+    public static final int VIDEO_META_CACHE_HOURS = 1;
+
+    private CourseProjectSalesBindConstants() {
+    }
+
+    public static String bindCacheKey(Long userId, Long projectId) {
+        return BIND_CACHE_KEY_PREFIX + userId + ":" + projectId;
+    }
+
+    public static String videoMetaCacheKey(Long videoId) {
+        return VIDEO_META_CACHE_KEY_PREFIX + videoId;
+    }
+}

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

@@ -0,0 +1,117 @@
+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);
+    }
+
+    /**
+     * 返回心跳索引中的全部成员(含 Redis 中已过期的 key),供中断检测写库 log_type=4。
+     */
+    public Set<String> listAllHeartbeatIndexMembers() {
+        return listAllIndexMembers(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> listAllIndexMembers(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) {
+            result.add(member.toString());
+        }
+        return result;
+    }
+
+    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;
+    }
+}

+ 17 - 0
fs-service/src/main/java/com/fs/course/vo/CourseRepeatByProjectMqMessage.java

@@ -0,0 +1,17 @@
+package com.fs.course.vo;
+
+import com.fs.course.param.FsUserCourseVideoAddKfUParam;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 看课按项目重粉 MQ 消息体
+ */
+@Data
+public class CourseRepeatByProjectMqMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private FsUserCourseVideoAddKfUParam param;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserVideoListUVO.java

@@ -9,6 +9,9 @@ import java.util.List;
 @Data
 public class FsUserVideoListUVO {
     private String id; // _id
+    private Long talentId;//达人ID
+    private Long userId;//发布者用户ID
+    private Integer isFollow = 0;//是否关注达人,0未关注,1已关注
     private String username;//2.视频拥有者名称
     private String headImg;//3.发布者头像
     private String cover;//视频封面

+ 1 - 0
fs-service/src/main/java/com/fs/enums/ExceptionCodeEnum.java

@@ -26,6 +26,7 @@ public enum ExceptionCodeEnum {
     WATCH_LATEST_COURSE(482, "请观看最新的课程项目"),
     EXCEED_COURSE_LIMIT(483, "超过项目看课数量限制"),
     ALREADY_WATCHED_OTHER_SALES_COURSE(484, "已看过其他销售分享的此课程,不能重复观看"),
+    USER_PROJECT_OTHER_SALES_BOUND(485, "您已在其他伴学助手处观看过该项目课程,请联系原助手继续学习"),
 
     // ============ 参数相关错误 (500-519) ============
     PARAM_ERROR(501, "参数错误!"),

+ 2 - 1
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -403,7 +403,8 @@ public class AiHookServiceImpl implements AiHookService {
         }
         if(user.getFastGptRoleId()==null){
             log.error("未绑定角色");
-            return userIsReply(sender, uid, user);
+            return R.ok();
+//            return userIsReply(sender, uid, user);
         }
 
         if(user.getAiStatus() == 1){

+ 3 - 0
fs-service/src/main/java/com/fs/his/mapper/FsIntegralGoodsMapper.java

@@ -85,6 +85,9 @@ public interface FsIntegralGoodsMapper
             "<if test = 'maps.goodsType != null and maps.goodsType != 0     '> " +
             "and g.goods_type = #{maps.goodsType}  " +
             "</if>" +
+            "<if test = 'maps.keyword != null and maps.keyword != \"\"'> " +
+            "and g.goods_name like CONCAT('%',#{maps.keyword},'%') " +
+            "</if>" +
             " order by g.goods_id desc "+
             "</script>"})
     List<FsIntegralGoodsListUVO> selectFsIntegralGoodsListUVO(@Param("maps")FsIntegralGoodsListUParam param);

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsIntegralGoodsScrmMapper.java

@@ -80,6 +80,9 @@ public interface FsIntegralGoodsScrmMapper
             "<if test = 'maps.goodsType != null and maps.goodsType != 0     '> " +
             "and g.goods_type = #{maps.goodsType}  " +
             "</if>" +
+            "<if test = 'maps.keyword != null and maps.keyword != \"\"'> " +
+            "and g.goods_name like CONCAT('%',#{maps.keyword},'%') " +
+            "</if>" +
             " order by g.goods_id desc "+
             "</script>"})
     List<FsIntegralGoodsListUVO> selectFsIntegralGoodsListUVO(@Param("maps")FsIntegralGoodsListUParam param);

+ 2 - 2
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreCartScrmMapper.java

@@ -101,10 +101,10 @@ public interface FsStoreCartScrmMapper
     void updateIsPay(String cartIds);
 
 
-    @Select("select ifnull(sum(c.cart_num),0) from fs_store_cart_scrm c inner join fs_store_product_scrm p on p.product_id=c.product_id inner join fs_store_product_attr_value_scrm v on v.id=c.product_attr_value_id where c.is_pay=0 and c.is_del=0 and c.is_buy=0 and p.is_show=1 and p.is_del=0 and c.user_id= #{userId}")
+    @Select("select count(*) from fs_store_cart_scrm c inner join fs_store_product_scrm p on p.product_id=c.product_id inner join fs_store_product_attr_value_scrm v on v.id=c.product_attr_value_id where c.is_pay=0 and c.is_del=0 and c.is_buy=0 and p.is_show=1 and p.is_del=0 and c.user_id= #{userId}")
     Integer selectFsStoreCartCountByUserId(long userId);
     @Select({"<script> " +
-            "select ifnull(sum(c.cart_num),0) from fs_store_cart_scrm c  " +
+            "select count(*) from fs_store_cart_scrm c  " +
             "where c.is_pay=0 and c.is_del=0 and c.is_buy=0 " +
             "<if test = 'maps.userId != null     '> " +
             "and c.user_id =#{maps.userId} " +

+ 21 - 8
fs-service/src/main/java/com/fs/live/service/impl/LiveAutoTaskServiceImpl.java

@@ -8,9 +8,11 @@ import java.util.*;
 import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
 
+import com.fs.common.constant.LiveKeysConstant;
 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.redis.LiveDelayedTaskRedisUtil;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.*;
 import com.fs.live.param.LiveLotteryProduct;
@@ -41,6 +43,8 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
     private LiveAutoTaskMapper baseMapper;
 
     @Autowired
@@ -186,8 +190,10 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
         if (live.getStatus() == 2 && liveAutoTask.getStatus() == 1L) {
             liveAutoTask.setUpdateTime(null);
             liveAutoTask.setCreateTime(null);
-            redisCache.redisTemplate.opsForZSet().add("live:auto_task:" + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
-            redisCache.redisTemplate.expire("live:auto_task:" + live.getLiveId(), 30, java.util.concurrent.TimeUnit.MINUTES);
+            String cacheKey = LiveKeysConstant.liveAutoTaskKey(live.getLiveId());
+            redisCache.redisTemplate.opsForZSet().add(cacheKey, JSON.toJSONString(liveAutoTask), liveAutoTask.getAbsValue().getTime());
+            redisCache.redisTemplate.expire(cacheKey, 30, java.util.concurrent.TimeUnit.MINUTES);
+            liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
         }
 
 
@@ -223,8 +229,10 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
         if (live.getStatus() == 2 && liveAutoTask.getStatus() == 1L) {
             liveAutoTask.setUpdateTime(null);
             liveAutoTask.setCreateTime(null);
-            redisCache.redisTemplate.opsForZSet().add("live:auto_task:" + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
-            redisCache.redisTemplate.expire("live:auto_task:" + live.getLiveId(), 30, java.util.concurrent.TimeUnit.MINUTES);
+            String cacheKey = LiveKeysConstant.liveAutoTaskKey(live.getLiveId());
+            redisCache.redisTemplate.opsForZSet().add(cacheKey, JSON.toJSONString(liveAutoTask), liveAutoTask.getAbsValue().getTime());
+            redisCache.redisTemplate.expire(cacheKey, 30, java.util.concurrent.TimeUnit.MINUTES);
+            liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
         }
         return R.ok();
     }
@@ -252,7 +260,9 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
     public R updateLiveAutoTask(LiveAutoTask liveAutoTask)
     {
         LiveAutoTask existTask = baseMapper.selectLiveAutoTaskById(liveAutoTask.getId());
-        redisCache.redisTemplate.opsForZSet().removeRangeByScore("live:auto_task:" + existTask.getLiveId(), existTask.getAbsValue().getTime(), existTask.getAbsValue().getTime());
+        String autoTaskCacheKey = LiveKeysConstant.liveAutoTaskKey(existTask.getLiveId());
+        redisCache.redisTemplate.opsForZSet().removeRangeByScore(autoTaskCacheKey, existTask.getAbsValue().getTime(), existTask.getAbsValue().getTime());
+        liveDelayedTaskRedisUtil.refreshAutoTaskIndex(existTask.getLiveId());
         if (liveAutoTask.getTaskType() == 1L) {
             // 商品
             LiveGoodsVo liveGoodsVo = goodsService.selectLiveGoodsVoByGoodsId(Long.valueOf(liveAutoTask.getContent()));
@@ -594,7 +604,7 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
     private void removeTaskFromCache(LiveAutoTask task) {
         try {
             if (task.getLiveId() != null && task.getId() != null) {
-                String cacheKey = "live:auto_task:" + task.getLiveId();
+                String cacheKey = LiveKeysConstant.liveAutoTaskKey(task.getLiveId());
                 // 获取ZSet中的所有任务
                 Set<String> allTasks = redisCache.redisTemplate.opsForZSet().range(cacheKey, 0, -1);
                 if (allTasks != null && !allTasks.isEmpty()) {
@@ -612,6 +622,7 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
                             log.warn("解析缓存中的任务失败,taskJson: {}, error: {}", taskJson, e.getMessage());
                         }
                     }
+                    liveDelayedTaskRedisUtil.refreshAutoTaskIndex(task.getLiveId());
                 }
             }
         } catch (Exception e) {
@@ -626,9 +637,10 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
      */
     public void deleteLotteryCache(Long lotteryId, Long liveId) {
         try {
-            String cacheKey = "live:lottery_task:" + liveId;
+            String cacheKey = LiveKeysConstant.liveLotteryTaskKey(liveId);
             // 从ZSet中删除指定的抽奖ID
             redisCache.redisTemplate.opsForZSet().remove(cacheKey, String.valueOf(lotteryId));
+            liveDelayedTaskRedisUtil.refreshLotteryTaskIndex(liveId);
             log.info("删除抽奖缓存,lotteryId: {}, liveId: {}", lotteryId, liveId);
         } catch (Exception e) {
             log.error("删除抽奖缓存失败,lotteryId: {}, liveId: {}", lotteryId, liveId, e);
@@ -642,9 +654,10 @@ public class LiveAutoTaskServiceImpl implements ILiveAutoTaskService {
      */
     public void deleteRedCache(Long redId, Long liveId) {
         try {
-            String cacheKey = "live:red_task:" + liveId;
+            String cacheKey = LiveKeysConstant.liveRedTaskKey(liveId);
             // 从ZSet中删除指定的红包ID
             redisCache.redisTemplate.opsForZSet().remove(cacheKey, String.valueOf(redId));
+            liveDelayedTaskRedisUtil.refreshRedTaskIndex(liveId);
             // 同时删除剩余红包数的缓存
             redisCache.deleteObject("live:red:remainingLots:" + redId);
             log.info("删除红包缓存,redId: {}, liveId: {}", redId, liveId);

+ 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("此优惠券已领完");
         }

+ 12 - 8
fs-service/src/main/java/com/fs/live/service/impl/LiveLotteryConfServiceImpl.java

@@ -6,6 +6,7 @@ import com.fs.common.constant.LiveKeysConstant;
 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.redis.LiveDelayedTaskRedisUtil;
 import com.fs.live.domain.Live;
 import com.fs.live.domain.LiveAutoTask;
 import com.fs.live.domain.LiveLotteryConf;
@@ -54,6 +55,8 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
     private LiveLotteryRegistrationMapper lotteryRegistrationMapper;
     @Autowired
     private LiveUserLotteryRecordMapper liveUserLotteryRecordMapper;
@@ -111,7 +114,8 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
     @Override
     public R updateLiveLotteryConf(LiveLotteryConf liveLotteryConf)
     {
-        String cacheKey = "live:lottery_task:" + liveLotteryConf.getLiveId();
+        Long liveId = liveLotteryConf.getLiveId();
+        String cacheKey = LiveKeysConstant.liveLotteryTaskKey(liveId);
         liveLotteryConf.setUpdateTime(DateUtils.getNowDate());
         if ("1".equals(liveLotteryConf.getLotteryStatus())) {
             List<LiveLotteryProduct> prizes = productMapper.selectLiveLotteryProductConfByLotteryId(liveLotteryConf.getLotteryId());
@@ -122,8 +126,10 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
             double score = localDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
             redisCache.redisTemplate.opsForZSet().add(cacheKey, String.valueOf(liveLotteryConf.getLotteryId()), score);
             redisCache.redisTemplate.expire(cacheKey, 30, java.util.concurrent.TimeUnit.MINUTES);
+            liveDelayedTaskRedisUtil.trackLotteryTask(liveId);
         } else {
             redisCache.deleteObject(cacheKey);
+            liveDelayedTaskRedisUtil.untrackLotteryTask(liveId);
         }
         return R.ok().put("data",baseMapper.updateLiveLotteryConf(liveLotteryConf));
     }
@@ -701,21 +707,19 @@ public class LiveLotteryConfServiceImpl implements ILiveLotteryConfService {
             liveAutoTaskService.directInsertLiveAutoTask(settlementTask);
 
             if (live.getStatus() == 2) {
+                String autoTaskKey = LiveKeysConstant.liveAutoTaskKey(live.getLiveId());
                 redisCache.redisTemplate.opsForZSet().add(
-                    "live:auto_task:" + live.getLiveId(),
+                    autoTaskKey,
                     JSON.toJSONString(startTask),
                     startTime.getTime()
                 );
                 redisCache.redisTemplate.opsForZSet().add(
-                    "live:auto_task:" + live.getLiveId(),
+                    autoTaskKey,
                     JSON.toJSONString(settlementTask),
                     settlementTime.getTime()
                 );
-                redisCache.redisTemplate.expire(
-                    "live:auto_task:" + live.getLiveId(),
-                    30,
-                    TimeUnit.MINUTES
-                );
+                redisCache.redisTemplate.expire(autoTaskKey, 30, TimeUnit.MINUTES);
+                liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
             }
 
             return R.ok("自动化抽奖设置成功");

+ 12 - 8
fs-service/src/main/java/com/fs/live/service/impl/LiveRedConfServiceImpl.java

@@ -8,6 +8,7 @@ import com.fs.common.constant.LiveKeysConstant;
 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.redis.LiveDelayedTaskRedisUtil;
 import com.fs.his.domain.FsUserIntegralLogs;
 import com.fs.his.enums.FsUserIntegralLogTypeEnum;
 import com.fs.his.mapper.FsUserIntegralLogsMapper;
@@ -53,6 +54,8 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
 
     @Autowired
     private RedisCache redisCache;
+    @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
 
     @Autowired
     private FsUserMapper userService;
@@ -122,7 +125,8 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
     @Override
     public int updateLiveRedConf(LiveRedConf liveRedConf)
     {
-        String cacheKey = "live:red_task:" + liveRedConf.getLiveId();
+        Long liveId = liveRedConf.getLiveId();
+        String cacheKey = LiveKeysConstant.liveRedTaskKey(liveId);
         liveRedConf.setUpdateTime(DateUtils.getNowDate());
         // 开始
         if (liveRedConf.getRedStatus() == 1) {
@@ -131,6 +135,7 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
             double score = localDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
             redisCache.redisTemplate.opsForZSet().add(cacheKey, String.valueOf(liveRedConf.getRedId()), score);
             redisCache.redisTemplate.expire(cacheKey, 30, TimeUnit.MINUTES);
+            liveDelayedTaskRedisUtil.trackRedTask(liveId);
 
             // 将红包配置缓存到 Redis(用于高并发查询)
             String redConfCacheKey = REDPACKET_CONF_CACHE_KEY + liveRedConf.getRedId();
@@ -140,6 +145,7 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
             // 其他
             redisCache.deleteObject(REDPACKET_REMAININGLOTS_KEY + liveRedConf.getRedId());
             redisCache.deleteObject(cacheKey);
+            liveDelayedTaskRedisUtil.untrackRedTask(liveId);
             // 删除红包配置缓存
             redisCache.deleteObject(REDPACKET_CONF_CACHE_KEY + liveRedConf.getRedId());
             redStatusUpdate(CollUtil.newHashSet(liveRedConf.getRedId()));
@@ -822,21 +828,19 @@ public class LiveRedConfServiceImpl implements ILiveRedConfService {
 
             // 如果直播中,加入Redis
             if (live.getStatus() == 2) {
+                String autoTaskKey = LiveKeysConstant.liveAutoTaskKey(live.getLiveId());
                 redisCache.redisTemplate.opsForZSet().add(
-                    "live:auto_task:" + live.getLiveId(),
+                    autoTaskKey,
                     com.alibaba.fastjson.JSON.toJSONString(startTask),
                     startTime.getTime()
                 );
                 redisCache.redisTemplate.opsForZSet().add(
-                    "live:auto_task:" + live.getLiveId(),
+                    autoTaskKey,
                     com.alibaba.fastjson.JSON.toJSONString(settlementTask),
                     settlementTime.getTime()
                 );
-                redisCache.redisTemplate.expire(
-                    "live:auto_task:" + live.getLiveId(),
-                    30,
-                    TimeUnit.MINUTES
-                );
+                redisCache.redisTemplate.expire(autoTaskKey, 30, TimeUnit.MINUTES);
+                liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
             }
 
             return R.ok("自动化红包设置成功");

+ 11 - 3
fs-service/src/main/java/com/fs/live/service/impl/LiveServiceImpl.java

@@ -33,6 +33,7 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.bean.BeanUtils;
+import com.fs.common.utils.redis.LiveDelayedTaskRedisUtil;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.*;
 import com.fs.live.param.LiveReplayParam;
@@ -116,6 +117,8 @@ public class LiveServiceImpl implements ILiveService
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
     private LiveMapper baseMapper;
     @Autowired
     private LiveMiniprogramSubNotifyTaskMapper liveMiniprogramSubNotifyTaskMapper;
@@ -997,12 +1000,16 @@ public class LiveServiceImpl implements ILiveService
         // 清除缓存
         clearLiveCache(live.getLiveId());
         List<LiveAutoTask> liveAutoTasks = liveAutoTaskService.selectNoActivedByLiveId(exist.getLiveId(), new Date());
+        String autoTaskKey = LiveKeysConstant.liveAutoTaskKey(live.getLiveId());
         liveAutoTasks.forEach(liveAutoTask -> {
             liveAutoTask.setCreateTime(null);
             liveAutoTask.setUpdateTime(null);
-            redisCache.redisTemplate.opsForZSet().add("live:auto_task:" + live.getLiveId(), JSON.toJSONString(liveAutoTask),liveAutoTask.getAbsValue().getTime());
-            redisCache.redisTemplate.expire("live:auto_task:"+live.getLiveId(), 1, TimeUnit.DAYS);
+            redisCache.redisTemplate.opsForZSet().add(autoTaskKey, JSON.toJSONString(liveAutoTask), liveAutoTask.getAbsValue().getTime());
+            redisCache.redisTemplate.expire(autoTaskKey, 1, TimeUnit.DAYS);
         });
+        if (!liveAutoTasks.isEmpty()) {
+            liveDelayedTaskRedisUtil.trackAutoTask(live.getLiveId());
+        }
         String cacheKey = String.format(LiveKeysConstant.LIVE_DATA_CACHE, live.getLiveId());
         redisCache.deleteObject(cacheKey);
         String cacheKey2 = String.format(LiveKeysConstant.LIVE_FLAG_CACHE, live.getLiveId());
@@ -1027,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);
             }

+ 2 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -296,6 +296,8 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "            <if test=\"isBind == 'noBind' \"> and ec.customer_id is null </if> \n" +
             "            <if test=\"isBindMini == 'isBindMini' \"> and ec.fs_user_id is not null </if> \n" +
             "            <if test=\"isBindMini == 'noBindMini' \"> and ec.fs_user_id is null </if> \n" +
+            "            <if test=\"extId != null\"> and ec.id = #{extId}</if>\n" +
+            "            <if test=\"fsUserId != null\"> and ec.fs_user_id = #{fsUserId}</if>\n" +
             "            <if test=\"lossTime != null \"> and DATE(ec.loss_time) = DATE(#{lossTime})</if>\n" +
             "            <if test=\"createTime != null \">and DATE(ec.create_time) = DATE(#{createTime})</if>\n" +
             "            <if test=\"delTime != null \"> and DATE(ec.del_time) = DATE(#{delTime})</if>\n" +

+ 4 - 0
fs-service/src/main/java/com/fs/sop/domain/QwSop.java

@@ -161,6 +161,10 @@ public class QwSop implements Serializable
     @TableField(exist = false)
     private List<String> userIds = new ArrayList<>();
 
+    /** 公司是否强制评级 0否 1是 */
+    @TableField(exist = false)
+    private Integer level;
+
 
     private Integer pageNum;
     private Integer pageSize;

+ 5 - 0
fs-service/src/main/java/com/fs/sop/mapper/QwSopMapper.java

@@ -20,6 +20,7 @@ import com.fs.qw.result.QwFilterSopCustomersResult;
 import com.fs.qw.vo.QwSopRuleTimeVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
 import org.springframework.stereotype.Repository;
 
 import java.time.LocalDate;
@@ -442,4 +443,8 @@ public interface QwSopMapper extends BaseMapper<QwSop> {
     void updateSopGroupIds(@Param("id") String id, @Param("chatId") String chatId);
 
     void updateSkipSopJson(@Param("sopParamStr") String sopParamStr,@Param("id") String id);
+
+    @DataSource(DataSourceType.SOP)
+    @Update("update qw_sop set is_rating = #{isRating} where company_id = #{companyId}")
+    int updateIsRatingByCompanyId(@Param("companyId") Long companyId, @Param("isRating") Integer isRating);
 }

+ 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帧

+ 104 - 0
fs-service/src/main/resources/application-config-druid-hzjs.yml

@@ -0,0 +1,104 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    com.fs: debug
+    org.springframework.web: INFO
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+wx:
+  open:
+    appId: wxfefe26a6c1ff71ba
+    secret: 4e87854df5f4051894d67cc0ecba3a9a
+  miniapp:
+    configs:
+      - appid: wx94951f52d3ac5e25   #杭州京视
+        secret: bfe27b20c6e3c4232a1d4ef36228e84b #杭州京视
+        token: Ncbnd7lJvkripxxna6NAWCxCrvC
+        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+        msgDataFormat: JSON
+  cp:
+    corpId: wwa46ffb9ff6ac35b8 #企业ID#杭州京视
+    appConfigs:
+      - agentId: 1000070       #杭州京视
+        secret: pu2EFz6gY2Fo2K-aRUxLPaAkKIaMJJRp8ES9JdpHkp4 #杭州京视
+        token: PPKOdAlCoMO
+        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+  pay:
+    appId:  #微信公众号或者小程序等的appid
+    mchId:  #微信支付商户号
+    mchKey:  #微信支付商户密钥
+    subAppId:  #服务商模式下的子商户公众账号ID
+    subMchId:  #服务商模式下的子商户号
+    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+  mp:
+    useRedis: false
+    redisConfig:
+      host: 127.0.0.1
+      port: 6379
+      timeout: 2000
+    configs:
+      - appId: wx1ee0af78d16bbf26 # 第一个公众号的appid  //公众号名称杭州京视
+        secret: 3cf9181ede6612d4b91d94e938b850bb # 公众号的appsecret--公众号名称杭州京视
+        token: PPKOdAlCoMO # 接口配置里的Token值
+        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+aifabu:  #爱链接
+  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+watch:
+  watchUrl: watch.ylrzcloud.com/prod-api
+  #  account: tcloud
+  #  password: mdf-m2h_6yw2$hq
+  account1: ccif #866655060138751
+  password1: cp-t5or_6xw7$mt
+  account2: tcloud #rt500台
+  password2: mdf-m2h_6yw2$hq
+  account3: whr
+  password3: v9xsKuqn_$d2y
+
+fs :
+  commonApi: http://172.21.0.8:7771
+  h5CommonApi: http://172.21.0.8:7771
+  jwt:
+    # 加密秘钥
+    secret: hzjs-bcdefg
+    # token有效时长,7天,单位秒
+    expire: 31536000
+    header: AppToken
+nuonuo:
+  key: 10924508
+  secret: A2EB20764D304D16
+
+# 存储捅配置
+tencent_cloud_config:
+  secret_id: AKIDLl1tguyrZ6QddTCi2BLJ4e3OXVIuJVVK
+  secret_key: g9R6kLrMp8EDzXszylLispiQxHRN6cw5
+  bucket: hzjs-1323137866
+  app_id: 1323137866
+  region: ap-chongqing
+  proxy: hzjs
+tmp_secret_config:
+  secret_id: AKIDCj7NSNAovtqeJpBau8GZ4CGB71thXIxX
+  secret_key: lTB5zwqqz7CNhzDOWivFWedgfTBgxgBT
+  bucket: fs-1319721001
+  app_id: 1319721001
+  region: ap-chongqing
+  proxy: fs
+cloud_host:
+  company_name: 杭州京视
+  projectCode: HZJS
+  spaceName: hzjs-2114522511
+  volcengineUrl: https://hzjsvolcengine.ylrztop.com
+headerImg:
+  imgUrl:
+
+ipad:
+  ipadUrl: http://ipad.jsjkhangzhou.com
+  aiApi:
+  voiceApi:
+  commonApi:
+wx_miniapp_temp:
+  pay_order_temp_id:
+  inquiry_temp_id:

+ 104 - 0
fs-service/src/main/resources/application-config-druid-jshz.yml

@@ -0,0 +1,104 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    com.fs: debug
+    org.springframework.web: INFO
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+wx:
+  open:
+    appId:
+    secret:
+  miniapp:
+    configs:
+      - appid:   #京视杭州
+        secret:  #京视杭州
+        token: Ncbnd7lJvkripxxna6NAWCxCrvC
+        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+        msgDataFormat: JSON
+  cp:
+    corpId:  #企业ID京视杭州
+    appConfigs:
+      - agentId:       #京视杭州
+        secret:  #京视杭州
+        token: PPKOdAlCoMO
+        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+  pay:
+    appId:  #微信公众号或者小程序等的appid
+    mchId:  #微信支付商户号
+    mchKey:  #微信支付商户密钥
+    subAppId:  #服务商模式下的子商户公众账号ID
+    subMchId:  #服务商模式下的子商户号
+    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+  mp:
+    useRedis: false
+    redisConfig:
+      host: 127.0.0.1
+      port: 6379
+      timeout: 2000
+    configs:
+      - appId: wxe704b34112659eb1 # 第一个公众号的appid  //公众号名称:京视杭州
+        secret: eaf3a1f3b326bf12c6b1a03e19faca6e # 公众号的appsecret--京视杭州
+        token: PPKOdAlCoMO # 接口配置里的Token值
+        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+aifabu:  #爱链接
+  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+watch:
+  watchUrl: watch.ylrzcloud.com/prod-api
+  #  account: tcloud
+  #  password: mdf-m2h_6yw2$hq
+  account1: ccif #866655060138751
+  password1: cp-t5or_6xw7$mt
+  account2: tcloud #rt500台
+  password2: mdf-m2h_6yw2$hq
+  account3: whr
+  password3: v9xsKuqn_$d2y
+
+fs :
+  commonApi:
+  h5CommonApi:
+  jwt:
+    # 加密秘钥
+    secret: jshz-bcdefg
+    # token有效时长,7天,单位秒
+    expire: 31536000
+    header: AppToken
+nuonuo:
+  key: 10924508
+  secret: A2EB20764D304D16
+
+# 存储捅配置
+tencent_cloud_config:
+  secret_id: AKIDLl1tguyrZ6QddTCi2BLJ4e3OXVIuJVVK
+  secret_key: g9R6kLrMp8EDzXszylLispiQxHRN6cw5
+  bucket: jshz-1323137866
+  app_id: 1323137866
+  region: ap-chongqing
+  proxy: jshz
+tmp_secret_config:
+  secret_id: AKIDCj7NSNAovtqeJpBau8GZ4CGB71thXIxX
+  secret_key: lTB5zwqqz7CNhzDOWivFWedgfTBgxgBT
+  bucket: fs-1319721001
+  app_id: 1319721001
+  region: ap-chongqing
+  proxy: fs
+cloud_host:
+  company_name: 京视杭州
+  projectCode: JSHZ
+  spaceName: jshz-2114522511
+  volcengineUrl: https://jshzvolcengine.ylrztop.com
+headerImg:
+  imgUrl:
+
+ipad:
+  ipadUrl: http://aipad.moonxiang.com
+  aiApi: http://49.232.181.28:3000/api
+  voiceApi: http://81.70.193.34:8667
+  commonApi: http://81.70.193.34:7771
+wx_miniapp_temp:
+  pay_order_temp_id:
+  inquiry_temp_id:

+ 4 - 0
fs-service/src/main/resources/application-druid-bjczwh.yml

@@ -149,8 +149,12 @@ rocketmq:
         access-key: default # 替换为实际的 accessKey
         secret-key: O1VnW98G8Rmn0PoZ # 替换为实际的 secretKey
     consumer:
+        # 完课打备注(RocketMQConsumerCourseFinishService)
         topic: course-finish-notes
         group: course-finish-group
+        # 看课按项目重粉(RocketMQConsumerCourseRepeatByProjectService,与华为云 Topic/消费组一致)
+        repeat-topic: course-repeat-by-project
+        repeat-group: course-repeat-by-project-group
         access-key: default # 替换为实际的 accessKey
         secret-key: O1VnW98G8Rmn0PoZ # 替换为实际的 secretKey
 openIM:

+ 175 - 0
fs-service/src/main/resources/application-druid-hzjs.yml

@@ -0,0 +1,175 @@
+# 数据源配置
+spring:
+    profiles:
+        include: config-druid-hzjs,common
+    # redis 配置
+    redis:
+        host: 172.21.0.2
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password: hzjs@^&**268..
+        # 连接超时时间
+        timeout: 10s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 100
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+#        clickhouse:
+#            type: com.alibaba.druid.pool.DruidDataSource
+#            driverClassName: com.clickhouse.jdbc.ClickHouseDriver
+#            url: jdbc:clickhouse://1.14.104.71:8123/sop_test?compress=0&use_server_time_zone=true&use_client_time_zone=false&timezone=Asia/Shanghai
+#            username: rt_2024
+#            password: Yzx_19860213
+#            initialSize: 10
+#            maxActive: 100
+#            minIdle: 10
+#            maxWait: 6000
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                  url: jdbc:mysql://172.21.0.5:3306/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                  username: root
+                  password: hzjs@^&**268..
+                # 从库数据源
+                slave:
+                    # 从数据源开关/默认关闭
+                    enabled: true
+                    url: jdbc:mysql://172.21.0.5:3306/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: hzjs@^&**268..
+              # 从库数据源
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+        sop:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://172.21.0.5:3306/fs_his_sop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: hzjs@^&**268..
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+rocketmq:
+    name-server: 192.168.0.124:8100
+    producer:
+        group: my-producer-group
+        access-key: default # 替换为实际的 accessKey
+        secret-key: O1VnW98G8Rmn0PoZ # 替换为实际的 secretKey
+    consumer:
+        # 完课打备注(RocketMQConsumerCourseFinishService)
+        topic: course-finish-notes
+        group: course-finish-group
+        # 看课按项目重粉(RocketMQConsumerCourseRepeatByProjectService,与华为云 Topic/消费组一致)
+        repeat-topic: course-repeat-by-project
+        repeat-group: course-repeat-by-project-group
+        access-key: default # 替换为实际的 accessKey
+        secret-key: O1VnW98G8Rmn0PoZ # 替换为实际的 secretKey
+openIM:
+    secret: openIM123
+    userID: imAdmin
+    url: https://webim.moonxiang.com/api
+#是否为新商户,新商户不走mpOpenId
+isNewWxMerchant: true
+#是否使用新im
+im:
+    type: OPENIM
+
+qw:
+    enableAutoTag: 1
+tag:
+    thread:
+        num: 5
+    rate:
+        limit: 30
+
+
+liveWebSocketUrl: http://192.168.0.194:7114
+

+ 172 - 0
fs-service/src/main/resources/application-druid-jshz.yml

@@ -0,0 +1,172 @@
+# 数据源配置
+spring:
+    profiles:
+        include: config-druid-jshz,common
+    # redis 配置
+    redis:
+        host: 10.0.14.17
+        port: 6379
+        # 数据库索引
+        database: 0
+        username: root
+        # 密码
+        password: 'Kel6GdXBrMm&p&E9'
+        # 连接超时时间
+        timeout: 10s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 100
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+#        clickhouse:
+#            type: com.alibaba.druid.pool.DruidDataSource
+#            driverClassName: com.clickhouse.jdbc.ClickHouseDriver
+#            url: jdbc:clickhouse://1.14.104.71:8123/sop_test?compress=0&use_server_time_zone=true&use_client_time_zone=false&timezone=Asia/Shanghai
+#            username: rt_2024
+#            password: Yzx_19860213
+#            initialSize: 10
+#            maxActive: 100
+#            minIdle: 10
+#            maxWait: 6000
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                  url: jdbc:mysql://10.0.14.15:3306/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                  username: root
+                  password: 'Kel6GdXBrMm&p&E9'
+                # 从库数据源
+                slave:
+                    # 从数据源开关/默认关闭
+                    enabled: true
+                    url: jdbc:mysql://10.0.14.15:3306/fs_his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: 'Kel6GdXBrMm&p&E9'
+              # 从库数据源
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+        sop:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://10.0.14.15:3306/fs_his_sop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: 'Kel6GdXBrMm&p&E9'
+                # 读库数据源
+                read:
+                    url: jdbc:mysql://10.0.14.15:3306/fs_his_sop?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: 'Kel6GdXBrMm&p&E9'
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+rocketmq:
+    name-server: 127.0.0.1:9876   # 占位,仅用于满足 Spring 创建 Bean
+    producer:
+        group: jshz-producer-group
+    consumer:
+        group: common-group
+openIM:
+    secret:
+    userID:
+    url:
+#是否为新商户,新商户不走mpOpenId
+isNewWxMerchant: true
+#是否使用新im
+im:
+    type: OPENIM
+
+qw:
+    enableAutoTag: 1
+tag:
+    thread:
+        num: 5
+    rate:
+        limit: 30
+
+
+liveWebSocketUrl: http://192.168.0.194:7114
+

+ 6 - 1
fs-service/src/main/resources/mapper/company/CompanyMapper.xml

@@ -42,6 +42,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="groupName"    column="group_name"    />
         <result property="maxPadNum"    column="max_pad_num"    />
         <result property="deptId"   column="dept_id" />
+        <result property="isOpenRestReminder" column="is_open_rest_reminder" />
+        <result property="level" column="level" />
     </resultMap>
 
     <sql id="selectCompanyVo">
@@ -123,6 +125,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="maxPadNum != null">max_pad_num,</if>
             <if test="deptId != null">dept_id,</if>
             <if test="isOpenRestReminder != null">is_open_rest_reminder,</if>
+            <if test="level != null">`level`,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="companyName != null">#{companyName},</if>
@@ -159,7 +162,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="groupName != null">#{groupName},</if>
             <if test="maxPadNum != null">#{maxPadNum},</if>
             <if test="deptId != null">#{deptId},</if>
-            <if test="isOpenRestReminder != null">is_open_rest_reminder = #{isOpenRestReminder},</if>
+            <if test="isOpenRestReminder != null">#{isOpenRestReminder},</if>
+            <if test="level != null">#{level},</if>
          </trim>
     </insert>
 
@@ -204,6 +208,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deptId != null">dept_id = #{deptId},</if>
             <if test="redPackageMoney != null">red_package_money = #{redPackageMoney},</if>
             <if test="isOpenRestReminder != null">is_open_rest_reminder = #{isOpenRestReminder},</if>
+            <if test="level != null">`level` = #{level},</if>
         </trim>
         where company_id = #{companyId}
     </update>

+ 3 - 0
fs-user-app/src/main/java/com/fs/app/controller/TalentController.java

@@ -97,11 +97,13 @@ public class TalentController extends  AppBaseController{
         return getDataTable(userTalentFollowService.selectFsUserTalentFansVoList(param));
     }
 
+    @Login
     @ApiOperation("获取达人关注列表")
     @GetMapping("/getTalentFollowByUserId")
     private TableDataInfo getFollowByUserId(FsUserTalentFansParam param){
         startPage();
         param.setTalentId(null);
+        param.setUserId(Long.parseLong(getUserId()));
         return getDataTable(userTalentFollowService.selectFsUserFollowVoList(param));
     }
 
@@ -253,6 +255,7 @@ public class TalentController extends  AppBaseController{
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         boolean oneSelf = userId == param.getUserId();
         List<FsUserVideoListUVO> list = fsUserVideoService.selectFsUserVideoListUVOByUser(fsUserTalent.getTalentId(),oneSelf,userId);
+        list = fsUserVideoService.addNum(list);
 
         PageInfo<FsUserVideoListUVO> listPageInfo=new PageInfo<>(list);
         return R.ok().put("data",listPageInfo);

+ 5 - 3
fs-user-app/src/main/java/com/fs/app/controller/VideoController.java

@@ -54,8 +54,6 @@ public class VideoController extends  AppBaseController{
         }
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<FsUserVideoListUVO> list= videoService.selectFsUserVideoListUVO(param);
-        //添加假数据
-        list = videoService.addNum(list);
         PageInfo<FsUserVideoListUVO> listPageInfo=new PageInfo<>(list);
         if (param.getUserId() != null) {
             // 对分页后的数据进行推荐排序
@@ -65,7 +63,7 @@ public class VideoController extends  AppBaseController{
         }
         if (param.getVideoId() != null && param.getPageNum()==1) {
             // 获取该 videoId 对应的视频数据
-            FsUserVideoListUVO video = videoService.getVideoById(param.getVideoId());
+            FsUserVideoListUVO video = videoService.getVideoById(param.getVideoId(), param.getUserId());
             if (video != null) {
                 // 将视频数据放到列表的第一个位置
                 list.removeIf(v -> v.getId().equals(param.getVideoId())); // 删除原列表中的该视频
@@ -73,6 +71,9 @@ public class VideoController extends  AppBaseController{
                 listPageInfo.setList(list);
             }
         }
+        // 统一在列表最终确定后叠加展示数,避免置顶视频未叠加导致与达人/我的列表不一致
+        list = videoService.addNum(listPageInfo.getList());
+        listPageInfo.setList(list);
         return R.ok().put("data",listPageInfo);
     }
 
@@ -84,6 +85,7 @@ public class VideoController extends  AppBaseController{
         param.setUserId(Long.parseLong(getUserId()));
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<FsUserVideoListUVO> list= videoService.selectFsUserFavoriteVideoListUVO(param);
+        list = videoService.addNum(list);
         PageInfo<FsUserVideoListUVO> listPageInfo=new PageInfo<>(list);
         return R.ok().put("data",listPageInfo);
     }

+ 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

+ 4 - 4
fs-user-app/src/main/java/com/fs/app/controller/store/ProductScrmController.java

@@ -113,7 +113,7 @@ public class ProductScrmController extends AppBaseController {
     @ApiOperation("获取商品列表")
     @GetMapping("/getProducts")
     public R getProducts(FsStoreProductQueryParam param, HttpServletRequest request){
-        PageHelper.startPage(param.getPage(), param.getPageSize());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setIsDisplay(1);
         List<FsStoreProductListQueryVO> productList=productService.selectFsStoreProductListQuery(param);
         PageInfo<FsStoreProductListQueryVO> listPageInfo=new PageInfo<>(productList);
@@ -354,7 +354,7 @@ public class ProductScrmController extends AppBaseController {
     @ApiOperation("获取推荐商品数据")
     @GetMapping("/getTuiProducts")
     public R getTuiProducts(BaseQueryParam param, HttpServletRequest request){
-        PageHelper.startPage(param.getPage(), param.getPageSize());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<FsStoreProductListQueryVO> list=productService.selectFsStoreProductTuiListQuery(param);
         PageInfo<FsStoreProductListQueryVO> listPageInfo=new PageInfo<>(list);
         return R.ok().put("data",listPageInfo);
@@ -362,7 +362,7 @@ public class ProductScrmController extends AppBaseController {
     @ApiOperation("获取喜欢商品数据")
     @GetMapping("/getGoodsProducts")
     public R getGoodsProducts(BaseQueryParam param, HttpServletRequest request){
-        PageHelper.startPage(param.getPage(), param.getPageSize());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<FsStoreProductListQueryVO> list=productService.selectFsStoreProductGoodListQuery(param);
         PageInfo<FsStoreProductListQueryVO> listPageInfo=new PageInfo<>(list);
         return R.ok().put("data",listPageInfo);
@@ -372,7 +372,7 @@ public class ProductScrmController extends AppBaseController {
     @ApiOperation("获取推广商品列表")
     @GetMapping("/getStoreProductAttrValueList")
     public R getStoreProductAttrValueList(FsStoreProductAttrValueQueryParam param, HttpServletRequest request){
-        PageHelper.startPage(param.getPage(), param.getPageSize());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<FsStoreProductAttrValueQueryVO> productList=attrValueService.selectStoreProductAttrValueListQuery(param);
         PageInfo<FsStoreProductAttrValueQueryVO> listPageInfo=new PageInfo<>(productList);
         return R.ok().put("data",listPageInfo);

+ 1 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/VideoScrmController.java

@@ -58,7 +58,7 @@ public class VideoScrmController extends AppBaseController {
         }
         if (param.getVideoId() != null && param.getPageNum()==1) {
             // 获取该 videoId 对应的视频数据
-            FsUserVideoListUVO video = videoService.getVideoById(param.getVideoId());
+            FsUserVideoListUVO video = videoService.getVideoById(param.getVideoId(), param.getUserId());
             if (video != null) {
                 // 将视频数据放到列表的第一个位置
                 list.removeIf(v -> v.getId().equals(param.getVideoId())); // 删除原列表中的该视频