ソースを参照

Merge branch 'bjcz_his_scrm' into jsjk_his_scrm

# Conflicts:
#	fs-admin/src/main/java/com/fs/course/controller/FsUserVideoController.java
#	fs-company/src/main/resources/application.yml
#	fs-qw-task/src/main/resources/application.yml
#	fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java
xw 2 週間 前
コミット
ee40136236
100 ファイル変更3162 行追加579 行削除
  1. 12 0
      fs-admin/src/main/java/com/fs/course/business/FsVideoResourceBusinessService.java
  2. 80 0
      fs-admin/src/main/java/com/fs/course/controller/CourseRandomRedPacketController.java
  3. 1 7
      fs-admin/src/main/java/com/fs/course/controller/FsVideoResourceController.java
  4. 20 74
      fs-admin/src/main/java/com/fs/course/task/VideoTask.java
  5. 1 1
      fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java
  6. 16 1
      fs-admin/src/main/java/com/fs/his/controller/FsUserController.java
  7. 3 1
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductScrmController.java
  8. 70 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductTagScrmController.java
  9. 94 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreUserEndCategoryScrmController.java
  10. 124 13
      fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java
  11. 1 1
      fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java
  12. 1 1
      fs-admin/src/main/resources/application.yml
  13. 36 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  14. 8 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyDeptController.java
  15. 50 28
      fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java
  16. 2 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java
  17. 36 10
      fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java
  18. 3 4
      fs-company/src/main/java/com/fs/company/controller/qw/SopUserLogsInfoController.java
  19. 40 26
      fs-company/src/main/java/com/fs/user/FsUserAdminController.java
  20. 77 29
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  21. 1 1
      fs-qw-api/src/main/java/com/fs/app/controller/QwController.java
  22. 6 2
      fs-qw-api/src/main/java/com/fs/app/service/QwDataCallbackService.java
  23. 41 14
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingMoreSevenDaysServiceImpl.java
  24. 6 0
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingServiceImpl.java
  25. 54 54
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  26. 2 1
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopUserLogsInfoByIsDaysNotStudyImpl.java
  27. 22 0
      fs-service/src/main/java/com/fs/company/domain/CompanyDept.java
  28. 5 0
      fs-service/src/main/java/com/fs/company/service/ICompanyDeptService.java
  29. 5 0
      fs-service/src/main/java/com/fs/company/service/ICompanyService.java
  30. 28 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyDeptServiceImpl.java
  31. 14 2
      fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java
  32. 122 0
      fs-service/src/main/java/com/fs/company/util/MiniAppIdResolver.java
  33. 24 0
      fs-service/src/main/java/com/fs/course/config/CourseRandomRedPacketConfig.java
  34. 18 0
      fs-service/src/main/java/com/fs/course/config/CourseRandomRedPacketTier.java
  35. 34 0
      fs-service/src/main/java/com/fs/course/domain/FsUserProjectQw.java
  36. 38 0
      fs-service/src/main/java/com/fs/course/domain/FsUserProjectRepeat.java
  37. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyBindMapper.java
  38. 2 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCompanyUserMapper.java
  39. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  40. 24 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserProjectQwMapper.java
  41. 16 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserProjectRepeatMapper.java
  42. 4 4
      fs-service/src/main/java/com/fs/course/mapper/FsUserVideoMapper.java
  43. 25 0
      fs-service/src/main/java/com/fs/course/param/CourseRandomRedPacketBatchSaveParam.java
  44. 18 0
      fs-service/src/main/java/com/fs/course/param/CourseRandomRedPacketCopyParam.java
  45. 52 0
      fs-service/src/main/java/com/fs/course/service/ICourseRandomRedPacketService.java
  46. 6 1
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  47. 33 0
      fs-service/src/main/java/com/fs/course/service/IUserProjectRepeatMaintainService.java
  48. 350 0
      fs-service/src/main/java/com/fs/course/service/impl/CourseRandomRedPacketServiceImpl.java
  49. 31 65
      fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java
  50. 9 2
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java
  51. 5 5
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  52. 46 38
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java
  53. 61 108
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  54. 22 18
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoFavoriteServiceImpl.java
  55. 22 18
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoLikeServiceImpl.java
  56. 7 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoServiceImpl.java
  57. 144 0
      fs-service/src/main/java/com/fs/course/service/impl/UserProjectRepeatMaintainServiceImpl.java
  58. 11 0
      fs-service/src/main/java/com/fs/course/support/CourseProjectEquivalence.java
  59. 2 0
      fs-service/src/main/java/com/fs/course/vo/CourseProgressResultVO.java
  60. 18 0
      fs-service/src/main/java/com/fs/course/vo/RepeatCourseHistoryVO.java
  61. 14 0
      fs-service/src/main/java/com/fs/course/vo/RepeatProjectResultVO.java
  62. 1 1
      fs-service/src/main/java/com/fs/enums/ExceptionCodeEnum.java
  63. 9 0
      fs-service/src/main/java/com/fs/his/domain/FsUser.java
  64. 8 2
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  65. 1 1
      fs-service/src/main/java/com/fs/his/service/IFsUserService.java
  66. 8 6
      fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java
  67. 4 0
      fs-service/src/main/java/com/fs/his/vo/FsUserVO.java
  68. 30 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductTagScrm.java
  69. 23 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductUserEndCategory.java
  70. 43 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreUserEndCategoryScrm.java
  71. 18 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStoreProductSortItemDTO.java
  72. 1 1
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePaymentScrmMapper.java
  73. 8 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductScrmMapper.java
  74. 27 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagRelationScrmMapper.java
  75. 28 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagScrmMapper.java
  76. 47 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductUserEndCategoryMapper.java
  77. 30 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreUserEndCategoryScrmMapper.java
  78. 6 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStoreProductAddEditParam.java
  79. 10 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductScrmService.java
  80. 29 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductTagScrmService.java
  81. 43 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreUserEndCategoryScrmService.java
  82. 2 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  83. 65 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductScrmServiceImpl.java
  84. 81 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductTagScrmServiceImpl.java
  85. 295 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreUserEndCategoryScrmServiceImpl.java
  86. 23 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagListVO.java
  87. 19 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagNameVO.java
  88. 21 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreTagProductVO.java
  89. 41 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreUserEndCategoryProductVO.java
  90. 62 15
      fs-service/src/main/java/com/fs/ipad/IpadSendUtils.java
  91. 8 7
      fs-service/src/main/java/com/fs/live/mapper/LiveDataMapper.java
  92. 2 0
      fs-service/src/main/java/com/fs/live/service/ILiveDataService.java
  93. 30 4
      fs-service/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java
  94. 18 0
      fs-service/src/main/java/com/fs/live/vo/LiveAppSimpleVO.java
  95. 6 0
      fs-service/src/main/java/com/fs/qw/domain/QwExternalContact.java
  96. 52 5
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  97. 1 1
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java
  98. 10 0
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatUserMapper.java
  99. 9 5
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  100. 19 0
      fs-service/src/main/java/com/fs/qw/param/QwExternalContactManageParam.java

+ 12 - 0
fs-admin/src/main/java/com/fs/course/business/FsVideoResourceBusinessService.java

@@ -1,5 +1,6 @@
 package com.fs.course.business;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.domain.FsVideoResource;
@@ -8,6 +9,8 @@ import com.fs.course.service.IFsVideoResourceService;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.List;
+
 @Service
 public class FsVideoResourceBusinessService {
 
@@ -24,6 +27,9 @@ public class FsVideoResourceBusinessService {
     @Transactional(rollbackFor = Exception.class)
     public void edit(FsVideoResource fsVideoResource) {
         FsVideoResource oldResource = fsVideoResourceService.getById(fsVideoResource.getId());
+        List<FsUserCourseVideo> courseVideos = fsUserCourseVideoService.list(new LambdaQueryWrapper<FsUserCourseVideo>()
+                .eq(FsUserCourseVideo::getVideoUrl, oldResource.getVideoUrl())
+                .eq(FsUserCourseVideo::getIsDel, 0));
         fsVideoResourceService.updateById(fsVideoResource);
         fsUserCourseVideoService.update(new UpdateWrapper<FsUserCourseVideo>()
                 .eq("video_url", oldResource.getVideoUrl())
@@ -37,5 +43,11 @@ public class FsVideoResourceBusinessService {
                 .set("file_name", fsVideoResource.getFileName())
                 .set("thumbnail", fsVideoResource.getThumbnail())
         );
+        if (fsVideoResource.getDuration() != null) {
+            Long duration = fsVideoResource.getDuration().longValue();
+            for (FsUserCourseVideo courseVideo : courseVideos) {
+                fsUserCourseVideoService.syncVideoDurationCache(courseVideo.getVideoId(), duration);
+            }
+        }
     }
 }

+ 80 - 0
fs-admin/src/main/java/com/fs/course/controller/CourseRandomRedPacketController.java

@@ -0,0 +1,80 @@
+package com.fs.course.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.enums.BusinessType;
+import com.fs.course.config.CourseRandomRedPacketConfig;
+import com.fs.course.param.CourseRandomRedPacketBatchSaveParam;
+import com.fs.course.param.CourseRandomRedPacketCopyParam;
+import com.fs.course.service.ICourseRandomRedPacketService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 课程随机红包配置控制器,对应 fs_user_course.project
+ */
+@RestController
+@RequestMapping("/course/courseRandomRedPacket")
+public class CourseRandomRedPacketController extends BaseController {
+
+    @Autowired
+    private ICourseRandomRedPacketService courseRandomRedPacketService;
+
+    /**
+     * 获取课程随机红包配置
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseRandomRedPacket:query')")
+    @GetMapping("/{project}")
+    public R getConfig(@PathVariable Long project) {
+        CourseRandomRedPacketConfig config = courseRandomRedPacketService.getConfigByProject(project);
+        return R.ok().put("data", config);
+    }
+
+    /**
+     * 查询所有已配置的课程项目
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseRandomRedPacket:query')")
+    @GetMapping("/list")
+    public R listConfiguredProjects() {
+        List<CourseRandomRedPacketConfig> list = courseRandomRedPacketService.listConfiguredProjects();
+        return R.ok().put("data", list);
+    }
+
+    /**
+     * 修改/保存课程随机红包配置(单个项目)
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseRandomRedPacket:edit')")
+    @Log(title = "课程随机红包配置", businessType = BusinessType.UPDATE)
+    @PostMapping
+    public AjaxResult saveConfig(@RequestBody CourseRandomRedPacketConfig config) {
+        courseRandomRedPacketService.saveConfig(config);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 批量保存:同一套配置应用到多个课程项目
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseRandomRedPacket:edit')")
+    @Log(title = "课程随机红包批量配置", businessType = BusinessType.UPDATE)
+    @PostMapping("/batch")
+    public AjaxResult batchSaveConfig(@RequestBody CourseRandomRedPacketBatchSaveParam param) {
+        courseRandomRedPacketService.batchSaveConfig(param);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 复制配置:将源项目配置复制到多个目标项目
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseRandomRedPacket:edit')")
+    @Log(title = "课程随机红包配置复制", businessType = BusinessType.UPDATE)
+    @PostMapping("/copy")
+    public AjaxResult copyConfig(@RequestBody CourseRandomRedPacketCopyParam param) {
+        courseRandomRedPacketService.copyConfig(param);
+        return AjaxResult.success();
+    }
+}

+ 1 - 7
fs-admin/src/main/java/com/fs/course/controller/FsVideoResourceController.java

@@ -137,14 +137,8 @@ public class FsVideoResourceController extends BaseController {
         fsVideoResourceResult.setOldVideoUrl(fsVideoResourceResult.getVideoUrl());
         fsVideoResource.setUpdateTime(LocalDateTime.now());
         //12.10要求素材名称跟着视频名称走
-        //fsVideoResource.setResourceName(fsVideoResource.getFileName());
-        if (("今正科技".equals(cloudHostProper.getCompanyName()))) {
-            // 同步资源到课程
-            videoResourceBusinessService.edit(fsVideoResource);
-            return AjaxResult.success();
-        }
         fsVideoResourceService.updateById(fsVideoResource);
-        fsUserCourseVideoService.updateVideoByVideoUrl(fsVideoResource.getVideoUrl(),fsVideoResourceResult.getOldVideoUrl(),fsVideoResource.getThumbnail(),fsVideoResource.getFileName(),fsVideoResource.getDuration());
+        fsUserCourseVideoService.updateVideoByVideoUrl(fsVideoResource.getVideoUrl(), fsVideoResourceResult.getOldVideoUrl(), fsVideoResource.getThumbnail(), fsVideoResource.getFileName(), fsVideoResource.getDuration(), fsVideoResource.getLine1(), fsVideoResource.getLine2());
         return AjaxResult.success();
     }
 

+ 20 - 74
fs-admin/src/main/java/com/fs/course/task/VideoTask.java

@@ -30,10 +30,6 @@ public class VideoTask {
     private final Logger logger = LoggerFactory.getLogger(this.getClass());
     private static final String YL_SECRET_ID = "AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT";
     private static final String YL_SECRET_KEY = "u5SuS80342xzx8FRBukza9lVNHKNMSaB";
-    private static final String LIKE_KEY_PREFIX = "like:video:";
-    private static final String UNLIKE_KEY_PREFIX = "unlike:video:";
-    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 COMMENT_REPLY_COUNT_KEY_PATTERN = "reply:count:comment:*";
     @Autowired
@@ -68,86 +64,36 @@ public class VideoTask {
         }
     }
 
-    //同步点赞到数据库
+    /**
+     * 同步点赞到数据库。
+     * 已停用全库 SCAN:点赞/收藏现已实时写库,定时任务再扫 Redis 只会徒增 CPU。
+     */
     public void syncLikesToDatabase() {
-        Collection<String> keys = redisCache.keys(LIKE_KEY_PREFIX + "*:user:*");
-        if (keys != null && !keys.isEmpty()) {
-            for (String key : keys) {
-                String[] parts = key.split(":");
-                Long videoId = Long.parseLong(parts[2]);
-                long userId = Long.parseLong(parts[4]);
-                //判断是否存在数据库
-                int check = videoLikeMapper.checkLike(videoId, userId);
-                if (check==0){
-                    FsUserVideoLike map=new FsUserVideoLike();
-                    map.setVideoId(videoId);
-                    map.setUserId(userId);
-                    map.setCreateTime(new Date());
-                    videoLikeMapper.insertFsUserVideoLike(map);
-                }
-                //删除key
-                redisCache.deleteObject(key);
-            }
-        }
+        logger.debug("syncLikesToDatabase skipped: like is persisted on request");
     }
 
-    //同步取消点赞到数据库
+    /**
+     * 同步取消点赞到数据库。
+     * 已停用:业务不再写入 unlike:video:* 键。
+     */
     public void syncUnlikesToDatabase() {
-        Collection<String> keys = redisCache.keys(UNLIKE_KEY_PREFIX + "*:user:*");
-        if (keys != null && !keys.isEmpty()) {
-            for (String key : keys) {
-                String[] parts = key.split(":");
-                Long videoId = Long.parseLong(parts[2]);
-                long userId = Long.parseLong(parts[4]);
-
-                // 从数据库中删除点赞记录
-                videoLikeMapper.deleteLike(videoId, userId);
-
-                // 从Redis中删除记录
-                redisCache.deleteObject(key);
-            }
-        }
+        logger.debug("syncUnlikesToDatabase skipped: unlike key is no longer used");
     }
 
-    //同步收藏到数据库
+    /**
+     * 同步收藏到数据库。
+     * 已停用全库 SCAN:收藏操作现已实时写库。
+     */
     public void syncFavoritesToDatabase() {
-        Collection<String> keys = redisCache.keys(FAVORITE_KEY_PREFIX + "*:user:*");
-        if (keys != null && !keys.isEmpty()) {
-            for (String key : keys) {
-                String[] parts = key.split(":");
-                Long videoId = Long.parseLong(parts[2]);
-                long userId = Long.parseLong(parts[4]);
-                //判断是否存在数据库
-                int check = videoFavoriteMapper.checkFavorite(videoId, userId);
-                if (check==0){
-                    FsUserVideoFavorite map=new FsUserVideoFavorite();
-                    map.setVideoId(videoId);
-                    map.setUserId(userId);
-                    map.setCreateTime(new Date());
-                    videoFavoriteMapper.insertFsUserVideoFavorite(map);
-                }
-                //删除key
-                redisCache.deleteObject(key);
-            }
-        }
+        logger.debug("syncFavoritesToDatabase skipped: favorite is persisted on request");
     }
 
-    //同步取消收藏到数据库
+    /**
+     * 同步取消收藏到数据库。
+     * 已停用:业务不再写入 nofavorite:video:* 键。
+     */
     public void syncUnFavoritesToDatabase() {
-        Collection<String> keys = redisCache.keys(NO_FAVORITE_KEY_PREFIX + "*:user:*");
-        if (keys != null && !keys.isEmpty()) {
-            for (String key : keys) {
-                String[] parts = key.split(":");
-                Long videoId = Long.parseLong(parts[2]);
-                long userId = Long.parseLong(parts[4]);
-
-                // 从数据库中删除点赞记录
-                videoFavoriteMapper.deleteFavorite(videoId, userId);
-
-                // 从Redis中删除记录
-                redisCache.deleteObject(key);
-            }
-        }
+        logger.debug("syncUnFavoritesToDatabase skipped: nofavorite key is no longer used");
     }
 
     //同步评论数量

+ 1 - 1
fs-admin/src/main/java/com/fs/his/controller/FsCompanyController.java

@@ -168,7 +168,7 @@ public class FsCompanyController extends BaseController {
     {
         company.setMoney(null);
         company.setUpdateMiniApp(true);
-        return toAjax(companyService.updateCompany(company));
+        return toAjax(companyService.updateFsCompany(company));
     }
 
     /**

+ 16 - 1
fs-admin/src/main/java/com/fs/his/controller/FsUserController.java

@@ -118,7 +118,7 @@ public class FsUserController extends BaseController
                     }
                 }
             }
-
+            fsUserVO.setManageStatusName(getManageStatusName(fsUserVO.getManageStatus()));
         }
         return getDataTable(list);
     }
@@ -299,6 +299,14 @@ public class FsUserController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestBody FsUser fsUser)
     {
+        if (fsUser.getUserId() == null) {
+            return AjaxResult.error("用户ID不能为空");
+        }
+        if (fsUser.getManageStatus() != null
+                && fsUser.getManageStatus() != 1
+                && fsUser.getManageStatus() != 2) {
+            return AjaxResult.error("管理状态无效,仅支持:1正常 2已被总部拉黑");
+        }
         return toAjax(fsUserService.updateFsUser(fsUser));
     }
 
@@ -423,4 +431,11 @@ public class FsUserController extends BaseController
         }
     }
 
+    private String getManageStatusName(Integer manageStatus) {
+        if (manageStatus != null && manageStatus == 2) {
+            return "已被总部拉黑";
+        }
+        return "正常";
+    }
+
 }

+ 3 - 1
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductScrmController.java

@@ -153,7 +153,9 @@ public class FsStoreProductScrmController extends BaseController
     {
         FsStoreProductScrm product=fsStoreProductService.selectFsStoreProductById(productId);
         List<FsStoreProductAttrScrm> attrs=attrService.selectFsStoreProductAttrByProductId(productId);
-        return R.ok().put("data",product).put("attrs", attrs);
+        List<Long> tagIds = fsStoreProductService.selectTagIdsByProductId(productId);
+        List<Long> userEndCategoryIds = fsStoreProductService.selectUserEndCategoryIdsByProductId(productId);
+        return R.ok().put("data",product).put("attrs", attrs).put("tagIds", tagIds).put("userEndCategoryIds", userEndCategoryIds);
     }
 
     @PreAuthorize("@ss.hasPermi('store:storeProduct:add')")

+ 70 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductTagScrmController.java

@@ -0,0 +1,70 @@
+package com.fs.hisStore.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreProductTagScrm;
+import com.fs.hisStore.service.IFsStoreProductTagScrmService;
+import com.fs.hisStore.vo.FsStoreProductTagListVO;
+import com.fs.hisStore.vo.FsStoreTagProductVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 商品标签管理
+ */
+@RestController
+@RequestMapping("/store/store/storeProductTag")
+public class FsStoreProductTagScrmController extends BaseController {
+
+    @Autowired
+    private IFsStoreProductTagScrmService productTagService;
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreProductTagScrm query) {
+        startPage();
+        List<FsStoreProductTagListVO> list = productTagService.selectListVOPage(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:query')")
+    @GetMapping("/products/{tagId}")
+    public TableDataInfo listProductsByTagId(@PathVariable Long tagId) {
+        startPage();
+        List<FsStoreTagProductVO> list = productTagService.selectProductsByTagId(tagId);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(productTagService.selectById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:add')")
+    @Log(title = "商品标签", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreProductTagScrm entity) {
+        return toAjax(productTagService.insert(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:edit')")
+    @Log(title = "商品标签", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreProductTagScrm entity) {
+        return toAjax(productTagService.update(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:storeProductTag:remove')")
+    @Log(title = "商品标签", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(productTagService.deleteByIds(ids));
+    }
+}

+ 94 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreUserEndCategoryScrmController.java

@@ -0,0 +1,94 @@
+package com.fs.hisStore.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.service.IFsStoreUserEndCategoryScrmService;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类管理(金刚区/瀑布流)
+ * 支持:分类名称模糊搜索、新增/编辑/删除、排序(用户端最多展示8个,超出不展示)、金刚区需icon/瀑布流不需、状态显示隐藏、一页10条分页、排序相同按创建时间倒序
+ */
+@RestController
+@RequestMapping("/store/store/userEndCategory")
+public class FsStoreUserEndCategoryScrmController extends BaseController {
+
+    @Autowired
+    private IFsStoreUserEndCategoryScrmService userEndCategoryService;
+
+    /** 分页列表,一页10条,排序值相同按创建时间倒序 */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsStoreUserEndCategoryScrm query) {
+        startPage();
+        List<FsStoreUserEndCategoryScrm> list = userEndCategoryService.selectListPage(query);
+        return getDataTable(list);
+    }
+
+    /** 添加/编辑商品时拉取用户端分类(金刚区、瀑布流各最多8条由前端截取) */
+    @GetMapping("/listForProduct")
+    public R listForProduct(@RequestParam(required = false) Long storeId) {
+        List<FsStoreUserEndCategoryScrm> list = userEndCategoryService.selectListForProduct(storeId);
+        return R.ok().put("data", list);
+    }
+
+    /** 按用户端分类ID分页查询关联商品(去重商品ID分页;返回商品ID、名称、售价、原价、销量及产品标签列表) */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:query')")
+    @GetMapping("/products")
+    public TableDataInfo listProductsByCategoryId(
+            @RequestParam Long id,
+            @RequestParam(value = "keyword", required = false) String keyword,
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize) {
+        startPage();
+        List<FsStoreUserEndCategoryProductVO> list = userEndCategoryService.listProductsByCategoryId(id,keyword);
+        return getDataTable(list);
+    }
+
+    /** 保存关联商品在当前页的排序(写入 fs_store_product_user_end_category.sort) */
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:edit')")
+    @Log(title = "用户分端类关联商品排序", businessType = BusinessType.UPDATE)
+    @PutMapping("/products/sort")
+    public AjaxResult saveCategoryProductsSort(@RequestParam Long id,
+                                               @RequestBody List<FsStoreProductSortItemDTO> items) {
+        return toAjax(userEndCategoryService.saveProductsSort(id, items));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(userEndCategoryService.selectById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:add')")
+    @Log(title = "用户分端类", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsStoreUserEndCategoryScrm entity) {
+        return toAjax(userEndCategoryService.insert(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:edit')")
+    @Log(title = "用户分端类", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsStoreUserEndCategoryScrm entity) {
+        return toAjax(userEndCategoryService.update(entity));
+    }
+
+    @PreAuthorize("@ss.hasPermi('store:userEndCategory:remove')")
+    @Log(title = "用户分端类", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(userEndCategoryService.deleteByIds(ids));
+    }
+}

+ 124 - 13
fs-admin/src/main/java/com/fs/qw/controller/QwExternalContactController.java

@@ -1,24 +1,31 @@
 package com.fs.qw.controller;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
 import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.json.JSONUtil;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyService;
+import com.fs.course.config.CourseConfig;
+import com.fs.framework.web.service.TokenService;
+import com.fs.his.vo.OptionsVO;
 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.domain.QwUser;
+import com.fs.qw.param.*;
 import com.fs.qw.service.IQwContactWayService;
 import com.fs.qw.service.IQwExternalContactInfoService;
 import com.fs.qw.service.IQwGroupChatUserService;
 import com.fs.qw.service.IQwTagService;
+import com.fs.qw.service.IQwUserService;
 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;
@@ -43,8 +50,10 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.enums.BusinessType;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.service.IQwExternalContactService;
+import com.fs.qw.service.IQwUserServiceAsyncHelper;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.common.core.page.TableDataInfo;
+import com.fs.system.service.ISysConfigService;
 
 /**
  * 企业微信客户Controller
@@ -71,6 +80,21 @@ public class QwExternalContactController extends BaseController
     @Autowired
     private IFsUserCompanyBindService fsUserCompanyBindService;
 
+    @Autowired
+    private ICompanyService companyService;
+
+    @Autowired
+    private IQwUserService qwUserService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private IQwUserServiceAsyncHelper qwUserServiceAsyncHelper;
+
     QwExternalContactController(IQwExternalContactService qwExternalContactService,IQwTagService iQwTagService){
         this.qwExternalContactService=qwExternalContactService;
         this.iQwTagService=iQwTagService;
@@ -84,10 +108,16 @@ 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())) {
+        boolean hasBasicQuery = qwExternalContact.getFsUserId() != null
+                || qwExternalContact.getExtId() != null
+                || qwExternalContact.getId() != null
+                || StringUtils.isNotEmpty(qwExternalContact.getExternalUserId());
+        boolean hasAddSalesQuery = qwExternalContact.getAddSalesCount() != null
+                && hasAddSalesCountScope(qwExternalContact);
+        if (!hasBasicQuery && !hasAddSalesQuery) {
+            return getDataTable(new ArrayList<>());
+        }
+        if (qwExternalContact.getAddSalesCount() != null && !hasAddSalesCountScope(qwExternalContact)) {
             return getDataTable(new ArrayList<>());
         }
 
@@ -136,6 +166,58 @@ public class QwExternalContactController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 添加销售数筛选 - 公司下拉
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:list')")
+    @GetMapping("/companyOptions")
+    public R companyOptions() {
+        Long deptId = resolveDeptIdForCompanyScope();
+        List<OptionsVO> list = companyService.selectAllCompanyList(deptId);
+        return R.ok().put("data", list);
+    }
+
+    /**
+     * 添加销售数筛选 - 销售下拉(企微昵称 qwUserName)
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:list')")
+    @GetMapping("/salesOptions")
+    public R salesOptions(@RequestParam(required = false) Long companyId) {
+        if (companyId == null) {
+            return R.ok().put("data", new ArrayList<>());
+        }
+        QwUser query = new QwUser();
+        query.setCompanyId(companyId);
+        List<Map<String, String>> options = qwUserService.selectQwUserList(query).stream()
+                .map(QwUser::getQwUserName)
+                .filter(StringUtils::isNotEmpty)
+                .distinct()
+                .sorted()
+                .map(name -> {
+                    Map<String, String> option = new HashMap<>(2);
+                    option.put("dictLabel", name);
+                    option.put("dictValue", name);
+                    return option;
+                })
+                .collect(Collectors.toList());
+        return R.ok().put("data", options);
+    }
+
+    private Long resolveDeptIdForCompanyScope() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig courseConfig = JSONUtil.toBean(json, CourseConfig.class);
+        if (!loginUser.isAdmin() && courseConfig.getDept() != null && courseConfig.getDept()) {
+            return loginUser.getDeptId();
+        }
+        return null;
+    }
+
+    private boolean hasAddSalesCountScope(QwExternalContactParam param) {
+        return param.getCompanyId() != null
+                || StringUtils.isNotEmpty(param.getQwUserName());
+    }
+
     public String getContactWayNameStream(String configStr, List<QwContactWay> wayList) {
         if (configStr == null || wayList == null || wayList.isEmpty()) {
             return null;
@@ -253,9 +335,24 @@ public class QwExternalContactController extends BaseController
     @PreAuthorize("@ss.hasPermi('qw:externalContact:edit')")
     @Log(title = "企业微信客户", businessType = BusinessType.UPDATE)
     @PutMapping
-    public AjaxResult edit(@RequestBody QwExternalContact qwExternalContact)
+    public AjaxResult edit(@RequestBody QwExternalContactManageParam param)
     {
-        return toAjax(qwExternalContactService.updateQwExternalContact(qwExternalContact));
+        if (param == null || param.getId() == null) {
+            return AjaxResult.error("企微客户ID不能为空");
+        }
+        if (param.getManageStatus() == null
+                || (param.getManageStatus() != 1 && param.getManageStatus() != 2)) {
+            return AjaxResult.error("管理状态无效,仅支持:1正常 2已被总部拉黑");
+        }
+        QwExternalContact contact = qwExternalContactService.selectQwExternalContactById(param.getId());
+        if (contact == null) {
+            return AjaxResult.error("企微客户不存在");
+        }
+        if (contact.getFsUserId() == null) {
+            return AjaxResult.error("未绑定小程序会员,无法修改拉黑状态");
+        }
+        int rows = qwExternalContactService.updateExternalContactManageStatus(param);
+        return rows > 0 ? AjaxResult.success() : AjaxResult.error("修改失败");
     }
 
     /**
@@ -300,7 +397,9 @@ public class QwExternalContactController extends BaseController
             }
         }
 
-        return qwExternalContactService.resignedTransfer(param);
+        qwUserServiceAsyncHelper.resignedTransfer(param);
+        int count = param.getIds() != null ? param.getIds().size() : 0;
+        return R.ok("转接任务已提交,共 " + count + " 人,完成后请留意转接通知");
     }
     /**
      * 在职转接 分配客户
@@ -321,6 +420,18 @@ public class QwExternalContactController extends BaseController
                 param.setIds(ids);
             }
         }
-        return qwExternalContactService.transfer(param);
+        qwUserServiceAsyncHelper.transfer(param);
+        int count = param.getIds() != null ? param.getIds().size() : 0;
+        return R.ok("转接任务已提交,共 " + count + " 人,完成后请留意转接通知");
+    }
+
+    /**
+     * 在职/离职转接前置校验(iPad好友关系)
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:transfer')")
+    @PostMapping("/transferPreCheck")
+    public R transferPreCheck(@RequestBody TransferPreCheckParam param)
+    {
+        return qwExternalContactService.transferPreCheck(param);
     }
 }

+ 1 - 1
fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java

@@ -345,7 +345,7 @@ public class qwTask {
 
     /**
      * 自动解除误封禁的用户
-     * 定时任务:将 fs_user 表中 status=0 且 remark 为 null 的用户
+     * 定时任务:将 fs_user 表中 status=0 且 manage_status=1 且 remark 为 null 的用户
      * 自动恢复为 status=1,并设置 remark="自动解除封禁"
      */
     public void autoUnbanMistakenlyDisabledUsers() {

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

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

+ 36 - 0
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -1,11 +1,15 @@
 package com.fs.common.core.redis;
 
+import java.io.IOException;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.RedisConnection;
 import org.springframework.data.redis.core.BoundSetOperations;
+import org.springframework.data.redis.core.Cursor;
 import org.springframework.data.redis.core.HashOperations;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ScanOptions;
 import org.springframework.data.redis.core.ValueOperations;
 import org.springframework.stereotype.Component;
 
@@ -258,12 +262,44 @@ public class RedisCache
      *
      * @param pattern 字符串前缀
      * @return 对象列表
+     * @deprecated 生产环境请使用 {@link #scanKeys(String)},KEYS 会阻塞 Redis
      */
+    @Deprecated
     public Collection<String> keys(final String pattern)
     {
         return redisTemplate.keys(pattern);
     }
 
+    /**
+     * 使用 SCAN 迭代匹配 key,避免 KEYS 全库扫描阻塞 Redis
+     *
+     * @param pattern key 匹配模式,例如 "favorite:video:*:user:*"
+     * @return 匹配到的 key 集合
+     */
+    public Collection<String> scanKeys(final String pattern)
+    {
+        Set<String> keys = new HashSet<>();
+        ScanOptions options = ScanOptions.scanOptions()
+                .match(pattern)
+                .count(1000)
+                .build();
+        redisTemplate.execute((RedisConnection connection) -> {
+            try (Cursor<byte[]> cursor = connection.scan(options)) {
+                while (cursor.hasNext()) {
+                    byte[] rawKey = cursor.next();
+                    String key = (String) redisTemplate.getKeySerializer().deserialize(rawKey);
+                    if (key != null) {
+                        keys.add(key);
+                    }
+                }
+            } catch (IOException e) {
+                throw new RuntimeException("Redis SCAN failed, pattern: " + pattern, e);
+            }
+            return null;
+        });
+        return keys;
+    }
+
 
     public Boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) {
         return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);

+ 8 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyDeptController.java

@@ -139,6 +139,10 @@ public class CompanyDeptController extends BaseController
         {
             return AjaxResult.error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在");
         }
+        String miniAppError = deptService.validateDeptMiniApp(dept.getCompanyId(), dept.getMiniAppMaster(), dept.getMiniAppServer());
+        if (miniAppError != null) {
+            return AjaxResult.error("新增部门'" + dept.getDeptName() + "'失败," + miniAppError);
+        }
         dept.setCreateBy(SecurityUtils.getUsername());
         return toAjax(deptService.insertCompanyDept(dept));
     }
@@ -161,6 +165,10 @@ public class CompanyDeptController extends BaseController
                 && deptService.selectNormalChildrenDeptById(dept.getDeptId()) > 0) {
             return AjaxResult.error("该部门包含未停用的子部门!");
         }
+        String miniAppError = deptService.validateDeptMiniApp(dept.getCompanyId(), dept.getMiniAppMaster(), dept.getMiniAppServer());
+        if (miniAppError != null) {
+            return AjaxResult.error("修改部门'" + dept.getDeptName() + "'失败," + miniAppError);
+        }
         dept.setUpdateBy(SecurityUtils.getUsername());
         return toAjax(deptService.updateCompanyDept(dept));
     }

+ 50 - 28
fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java

@@ -518,48 +518,59 @@ public class QwExternalContactController extends BaseController
 
 
 
-
+    /**
+     * 离职继承 分配客户
+     */
     @PreAuthorize("@ss.hasPermi('qw:externalContact:transfer')")
     @Log(title = "企业微信客户", businessType = BusinessType.UPDATE)
     @PutMapping("/resignedTransfer")
     public R resignedTransfer(@RequestBody ResignedTransferParam param)
     {
-        if (ObjectUtil.isNotEmpty(param.getQwUserName())&&ObjectUtil.isNotEmpty(param.getType())&&param.getType().equals("1")){
-            QwExternalContactParam qwExternalContact =new QwExternalContactParam();
-            qwExternalContact.setQwUserName(param.getQwUserName());
-
-            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-            qwExternalContact.setCompanyId(loginUser.getCompany().getCompanyId());
-            List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
-            if (!CollectionUtils.isEmpty(list)){
-                List<Long> ids = list.stream().map(QwExternalContactVO::getId).collect(Collectors.toList());
-                param.setIds(ids);
-            }
+        if (param.isFilter()) {
+            param.setIds(getList(param.getAddType(), param.getParam()));
         }
-
-        return qwExternalContactService.resignedTransfer(param);
+        if (param.getIds() == null || param.getIds().isEmpty()) {
+            return R.error("请选择需要分配的客户");
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setOperatorCompanyUserId(loginUser.getUser().getUserId());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        qwUserServiceAsyncHelper.resignedTransfer(param);
+        return R.ok("转接任务已提交,共 " + param.getIds().size() + " 人,完成后请留意转接通知");
     }
 
     /**
-    * 在职转接 分配客户
-    */
+     * 在职转接 分配客户
+     */
     @PreAuthorize("@ss.hasPermi('qw:externalContact:transfer')")
     @Log(title = "企业微信客户", businessType = BusinessType.UPDATE)
     @PutMapping("/transfer")
     public R transfer(@RequestBody TransferParam param)
     {
-        if (ObjectUtil.isNotEmpty(param.getQwUserName())&&ObjectUtil.isNotEmpty(param.getType())&&param.getType().equals("1")){
-            QwExternalContactParam qwExternalContact =new QwExternalContactParam();
-            qwExternalContact.setQwUserName(param.getQwUserName());
-            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-            qwExternalContact.setCompanyId(loginUser.getCompany().getCompanyId());
-            List<QwExternalContactVO> list = qwExternalContactService.selectQwExternalContactListVO(qwExternalContact);
-            if (!CollectionUtils.isEmpty(list)){
-                List<Long> ids = list.stream().map(QwExternalContactVO::getId).collect(Collectors.toList());
-                param.setIds(ids);
-            }
+        if (param.isFilter()) {
+            param.setIds(getList(param.getAddType(), param.getParam()));
+        }
+        if (param.getIds() == null || param.getIds().isEmpty()) {
+            return R.error("请选择需要分配的客户");
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setOperatorCompanyUserId(loginUser.getUser().getUserId());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        qwUserServiceAsyncHelper.transfer(param);
+        return R.ok("转接任务已提交,共 " + param.getIds().size() + " 人,完成后请留意转接通知");
+    }
+
+    /**
+     * 在职/离职转接前置校验(iPad好友关系)
+     */
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:transfer')")
+    @PostMapping("/transferPreCheck")
+    public R transferPreCheck(@RequestBody TransferPreCheckParam param)
+    {
+        if (param.isFilter()) {
+            param.setIds(getList(param.getAddType(), param.getParam()));
         }
-        return qwExternalContactService.transfer(param);
+        return qwExternalContactService.transferPreCheck(param);
     }
     /**
      * 修改企业微信客户
@@ -608,6 +619,7 @@ public class QwExternalContactController extends BaseController
     * 绑定小程序用户
     */
     @PutMapping("/bindUserId")
+    @PreAuthorize("@ss.hasPermi('qw:externalContact:bindUserId')")
     @Log(title = "换绑小程序用户", businessType = BusinessType.UPDATE)
     public R bindUserId(@RequestBody QwExternalContact qwExternalContact)
     {
@@ -838,10 +850,20 @@ public class QwExternalContactController extends BaseController
         return new ArrayList<>(set);
     }
 
+    private void prepareQueryParam(QwExternalContactParam param) {
+        if (param == null) {
+            return;
+        }
+        if (StringUtils.isNotEmpty(param.getStatuses())) {
+            param.setStatusCondition(param.getStatuses().split(","));
+        }
+    }
+
     private List<Long> getList(Integer addType, QwExternalContactParam param){
-        if(addType == null){
+        if(addType == null || param == null){
             return Collections.emptyList();
         }
+        prepareQueryParam(param);
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         if(addType == 0){
             param.setCompanyId(loginUser.getCompany().getCompanyId());

+ 2 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java

@@ -349,6 +349,8 @@ public class QwSopTempController extends BaseController
             qwSopTemp.setTemplateType(0);
             qwSopTemp.setIsTop(0);
         }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        qwSopTemp.setCreateBy(loginUser.getUser().getUserId().toString());
         qwSopTempService.copyTemplate(qwSopTemp);
         return toAjax(1);
     }

+ 36 - 10
fs-company/src/main/java/com/fs/company/controller/qw/QwUserController.java

@@ -486,18 +486,31 @@ public class QwUserController extends BaseController
     public TableDataInfo list(QwUserListParam qwUser)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        qwUser.setCompanyId(loginUser.getCompany().getCompanyId());
-        if (ObjectUtil.isNotEmpty(qwUser.getIsRemark())&&qwUser.getIsRemark().equals("1")){
-            qwUser.setCompanyUserId(loginUser.getUser().getUserId());
-        }else if (ObjectUtil.isNotEmpty(qwUser.getIsRemark())&&qwUser.getIsRemark().equals("2")){
-            qwUser.setDeptId(loginUser.getUser().getDeptId());
-            qwUser.setCorpId(null);
-        }
+        Long loginCompanyId = loginUser.getCompany().getCompanyId();
+        boolean forTransfer = !StringUtil.strIsNullOrEmpty(qwUser.getCorpId())
+                && StringUtil.strIsNullOrEmpty(qwUser.getIsRemark());
 
-        if (!StringUtil.strIsNullOrEmpty(qwUser.getCorpId())) {
+        if (forTransfer) {
             QwCompany qwCompany = qwCompanyMapper.selectQwCompanyByCorpId(qwUser.getCorpId());
-            if (qwCompany != null) {
-                qwUser.setAllowOfficial(qwCompany.getAllowOfficial());
+            if (qwCompany == null || !corpAccessibleByCompany(qwCompany, loginCompanyId)) {
+                return getDataTable(Collections.emptyList());
+            }
+            qwUser.setAllowOfficial(qwCompany.getAllowOfficial());
+            qwUser.setForTransfer(1);
+        } else {
+            qwUser.setCompanyId(loginCompanyId);
+            if (ObjectUtil.isNotEmpty(qwUser.getIsRemark()) && qwUser.getIsRemark().equals("1")) {
+                qwUser.setCompanyUserId(loginUser.getUser().getUserId());
+            } else if (ObjectUtil.isNotEmpty(qwUser.getIsRemark()) && qwUser.getIsRemark().equals("2")) {
+                qwUser.setDeptId(loginUser.getUser().getDeptId());
+                qwUser.setCorpId(null);
+            }
+
+            if (!StringUtil.strIsNullOrEmpty(qwUser.getCorpId())) {
+                QwCompany qwCompany = qwCompanyMapper.selectQwCompanyByCorpId(qwUser.getCorpId());
+                if (qwCompany != null) {
+                    qwUser.setAllowOfficial(qwCompany.getAllowOfficial());
+                }
             }
         }
 
@@ -506,6 +519,19 @@ public class QwUserController extends BaseController
         return getDataTable(list);
     }
 
+    private boolean corpAccessibleByCompany(QwCompany qwCompany, Long companyId) {
+        if (qwCompany == null || companyId == null || StringUtil.strIsNullOrEmpty(qwCompany.getCompanyIds())) {
+            return false;
+        }
+        String companyIdStr = String.valueOf(companyId);
+        for (String id : qwCompany.getCompanyIds().split(",")) {
+            if (companyIdStr.equals(id.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * 查询企微用户列表
      */

+ 3 - 4
fs-company/src/main/java/com/fs/company/controller/qw/SopUserLogsInfoController.java

@@ -83,6 +83,7 @@ public class SopUserLogsInfoController extends BaseController
         //startPage();
         if (sopUserLogsInfo.getFilterMode() == 1) {
             List<SopUserLogsInfo> list = sopUserLogsInfoService.selectSopUserLogsInfoList(sopUserLogsInfo);
+            list.forEach(item -> item.setLevel(item.getGrade()));
 
             if (!list.isEmpty()) {
                 // 使用并行流提取externalId
@@ -98,7 +99,7 @@ public class SopUserLogsInfoController extends BaseController
                 Map<Long, SopExternalContactInfo> externalContactInfoMap = qwExternalContactVOTimes.stream()
                         .collect(Collectors.toMap(
                                 QwExternalContactVOTime::getId,
-                                item -> new SopExternalContactInfo(item.getCreateTime(), item.getTagIds(), item.getRemark(),item.getLevel())
+                                item -> new SopExternalContactInfo(item.getCreateTime(), item.getTagIds(), item.getRemark(), null)
                         ));
 
                 List<String> tagIds = qwExternalContactVOTimes.stream().map(QwExternalContactVOTime::getTagIds).filter(StringUtils::isNotEmpty).flatMap(e -> JSON.parseArray(e, String.class).stream()).collect(Collectors.toList());
@@ -117,12 +118,10 @@ public class SopUserLogsInfoController extends BaseController
                 list.forEach(item -> {
                     SopExternalContactInfo info = externalContactInfoMap.getOrDefault(
                             item.getExternalId(),
-                            new SopExternalContactInfo("无进线时间", "无标签", "无备注",0));
+                            new SopExternalContactInfo("无进线时间", "无标签", "无备注", null));
                     item.setInComTime(info.getCreateTime());
                     item.setTagIds(info.getTagIds());
                     item.setRemark(info.getRemark());
-                    item.setLevel(info.getLevel());
-
                 });
             }
 

+ 40 - 26
fs-company/src/main/java/com/fs/user/FsUserAdminController.java

@@ -212,23 +212,10 @@ public class FsUserAdminController extends BaseController {
     @PutMapping("/redStatus")
     @ApiOperation("修改会员红包领取开关(red_status:1开启 0关闭)")
     public AjaxResult updateRedStatus(@Valid @RequestBody FsUserRedStatusParam param) {
-        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        Long companyId = loginUser.getCompany().getCompanyId();
         FsUser fsUser = fsUserService.selectFsUserById(param.getUserId());
         if (fsUser == null) {
             return AjaxResult.error("用户不存在");
         }
-        boolean allowed = Objects.equals(fsUser.getCompanyId(), companyId);
-        if (!allowed) {
-            FsUserCompanyUser query = new FsUserCompanyUser();
-            query.setUserId(param.getUserId());
-            query.setCompanyId(companyId);
-            List<FsUserCompanyUser> rel = fsUserCompanyUserService.selectFsUserCompanyUserList(query);
-            allowed = rel != null && !rel.isEmpty();
-        }
-        if (!allowed) {
-            return AjaxResult.error("无权限操作该用户");
-        }
         FsUser update = new FsUser();
         update.setUserId(param.getUserId());
         update.setRedStatus(param.getRedStatus());
@@ -311,7 +298,7 @@ public class FsUserAdminController extends BaseController {
 
             // 3. 查询该营期和销售下的会员ID列表
             List<Long> userIds = fsUserCompanyUserMapper
-                    .selectUserIdsByPeriodIdAndCompanyUserId(sourcePeriodId, sourceCompanyUserId);
+                    .selectUserIdsByPeriodIdAndCompanyUserId(sourcePeriodId, sourceCompanyUserId, companyId);
 
             log.info("查询营期ID:{}, 销售ID:{} 下的会员数量:{}",
                     sourcePeriodId, sourceCompanyUserId, userIds.size());
@@ -397,6 +384,14 @@ public class FsUserAdminController extends BaseController {
                 return R.error("目标销售不存在");
             }
 
+            if (!companyId.equals(sourceCompanyUser.getCompanyId())) {
+                return R.error("源销售不属于当前公司");
+            }
+
+            if (!companyId.equals(targetCompanyUser.getCompanyId())) {
+                return R.error("目标销售不属于当前公司");
+            }
+
             // 5. 批量转移会员营期关系
             int successCount = 0;
             int failCount = 0;
@@ -404,7 +399,8 @@ public class FsUserAdminController extends BaseController {
 
             // 先查询源营期和源销售下的所有有效会员ID(通过观看记录表关联)
             List<Long> validUserIds = fsUserCompanyUserMapper
-                    .selectUserIdsByPeriodIdAndCompanyUserId(param.getSourcePeriodId(), param.getSourceCompanyUserId());
+                    .selectUserIdsByPeriodIdAndCompanyUserId(
+                            param.getSourcePeriodId(), param.getSourceCompanyUserId(), companyId);
 
             log.info("源营期ID:{}, 源销售ID:{} 下查询到有效会员数:{}",
                     param.getSourcePeriodId(), param.getSourceCompanyUserId(), validUserIds.size());
@@ -418,25 +414,43 @@ public class FsUserAdminController extends BaseController {
                         continue;
                     }
 
-                    // 先删除该会员的所有fs_user_company_user记录(不管哪个营期)
+                    // 仅删除当前公司下源营期+源销售的关系,不影响其他营期/销售/公司
                     FsUserCompanyUser deleteParam = new FsUserCompanyUser();
                     deleteParam.setUserId(userId);
-                    List<FsUserCompanyUser> existingRecords = fsUserCompanyUserService.selectFsUserCompanyUserList(deleteParam);
-                    for (FsUserCompanyUser record : existingRecords) {
+                    deleteParam.setProjectId(param.getSourcePeriodId());
+                    deleteParam.setCompanyUserId(param.getSourceCompanyUserId());
+                    deleteParam.setCompanyId(companyId);
+                    List<FsUserCompanyUser> sourceRecords = fsUserCompanyUserService.selectFsUserCompanyUserList(deleteParam);
+                    if (sourceRecords.isEmpty()) {
+                        failReasons.append("会员ID:").append(userId).append("在源营期和源销售下不存在; ");
+                        failCount++;
+                        continue;
+                    }
+                    for (FsUserCompanyUser record : sourceRecords) {
                         fsUserCompanyUserService.deleteFsUserCompanyUserById(record.getId());
-                        log.info("删除旧的fs_user_company_user记录 - 会员ID:{}, 记录ID:{}, 营期ID:{}, 销售ID:{}",
+                        log.info("删除源营期关系 - 会员ID:{}, 记录ID:{}, 营期ID:{}, 销售ID:{}",
                                 userId, record.getId(), record.getProjectId(), record.getCompanyUserId());
                     }
 
-                    // 创建新的目标营期记录
-                    fsUserCompanyUserService.bindRelationship(userId, param.getTargetPeriodId(), companyId, param.getTargetCompanyUserId(), 1);
-                    log.info("创建目标营期新记录 - 会员ID:{}, 营期ID:{}, 公司ID:{}, 销售ID:{}",
-                            userId, param.getTargetPeriodId(), companyId, param.getTargetCompanyUserId());
+                    // 创建目标营期记录(已存在则跳过,避免重复绑定)
+                    FsUserCompanyUser targetQuery = new FsUserCompanyUser();
+                    targetQuery.setUserId(userId);
+                    targetQuery.setProjectId(param.getTargetPeriodId());
+                    targetQuery.setCompanyUserId(param.getTargetCompanyUserId());
+                    targetQuery.setCompanyId(companyId);
+                    List<FsUserCompanyUser> targetRecords = fsUserCompanyUserService.selectFsUserCompanyUserList(targetQuery);
+                    if (targetRecords.isEmpty()) {
+                        fsUserCompanyUserService.bindRelationship(userId, param.getTargetPeriodId(), companyId, param.getTargetCompanyUserId(), 1);
+                        log.info("创建目标营期新记录 - 会员ID:{}, 营期ID:{}, 公司ID:{}, 销售ID:{}",
+                                userId, param.getTargetPeriodId(), companyId, param.getTargetCompanyUserId());
+                    } else {
+                        log.info("目标营期关系已存在,跳过创建 - 会员ID:{}, 营期ID:{}, 销售ID:{}",
+                                userId, param.getTargetPeriodId(), param.getTargetCompanyUserId());
+                    }
 
-                    // 更新fs_user表的companyId和companyUserId
+                    // 仅当会员主归属属于当前公司时,更新fs_user表
                     FsUser fsUser = fsUserService.selectFsUserById(userId);
-                    if (fsUser != null) {
-                        fsUser.setCompanyId(companyId);
+                    if (fsUser != null && Objects.equals(fsUser.getCompanyId(), companyId)) {
                         fsUser.setCompanyUserId(param.getTargetCompanyUserId());
                         fsUserService.updateFsUser(fsUser);
                         log.info("更新fs_user表 - 会员ID:{}, 公司ID:{}, 销售ID:{}",

+ 77 - 29
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -9,7 +9,11 @@ import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.date.DateUtil;
 import com.fs.common.utils.spring.SpringUtils;
+import com.fs.company.domain.CompanyDept;
 import com.fs.company.domain.CompanyMiniapp;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyDeptMapper;
+import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.course.domain.FsCoursePlaySourceConfig;
 import com.fs.course.domain.FsCourseWatchLog;
@@ -60,6 +64,8 @@ public class IpadSendServer {
     private final IQwUserVideoService qwUserVideoService;
     private final RedisCache redisCache;
     private final ICompanyMiniappService companyMiniappService;
+    private final CompanyUserMapper companyUserMapper;
+    private final CompanyDeptMapper companyDeptMapper;
     private final IFsCoursePlaySourceConfigService playSourceConfigService;
     private final FsUserMapper fsUserMapper;
     private final FsUserCourseVideoMapper fsUserCourseVideoMapper;
@@ -68,7 +74,7 @@ public class IpadSendServer {
     private static final List<String> PROJECT_NAMES = Arrays.asList("济南联志健康", "北京存在文化", "宽益堂");
     private final LiveWatchLogMapper liveWatchLogMapper;
 
-    private void sendMiniProgram(BaseVo vo, QwSopCourseFinishTempSetting.Setting content, Map<String, FsCoursePlaySourceConfig> miniMap, Long companyId) {
+    private void sendMiniProgram(BaseVo vo, QwSopCourseFinishTempSetting.Setting content, Map<String, FsCoursePlaySourceConfig> miniMap, Long companyId, Long companyUserId) {
         log.info("ID:{} 小程序发送前关键字段 - miniprogramPage:{}, miniprogramTitle:{}, miniprogramAppid:{}, articleUrl:{}",
             vo.getId(), content.getMiniprogramPage(), content.getMiniprogramTitle(), content.getMiniprogramAppid(), content.getArticleUrl());
 
@@ -87,14 +93,14 @@ public class IpadSendServer {
             forceCompanyApp = !companyAppIds.isEmpty();
         }
         final Set<String> allowedCompanyAppIds = companyAppIds;
+        if (companyId != null) {
+            listAll = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().eq("company_id", companyId));
+        }
         // 判断销售工时ID不为空并且有小程序类型
         if (companyId != null && content.getMiniType() != null) {
-            // 获取销售公司下面绑定的主备小程序,并且根据当前应该发送的主备类型查询出数据
-            listAll = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().eq("company_id", companyId));
-            List<CompanyMiniapp> list = listAll.stream().filter(e -> e.getType().equals(content.getMiniType())).collect(Collectors.toList());
-            // 判断当前绑定的最新的小程序,并且覆盖以前的值(可以达到实时替换小程序的功能)
-            if (!list.isEmpty() && list.get(0) != null && StringUtils.isNotEmpty(list.get(0).getAppId())) {
-                appid = list.get(0).getAppId();
+            String resolvedAppId = resolveAppIdByType(companyUserId, content.getMiniType(), listAll);
+            if (StringUtils.isNotEmpty(resolvedAppId)) {
+                appid = resolvedAppId;
             }
             // 获取配置文件里面的项目名称
             String signProjectName = SpringUtils.getProperty("cloud_host.company_name");
@@ -139,10 +145,9 @@ public class IpadSendServer {
                                     appid = availableList.get(0).getAppid();
                                     log.info("ID:{}, qwUserId:{},externalId:{},判断看过一个小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
                                 } else {
-                                    // 用户看过的小程序已完全封禁,使用备用小程序
-                                    Optional<CompanyMiniapp> first = listAll.stream().filter(e -> e.getType() == 1).findFirst();
-                                    if (first.isPresent()) {
-                                        appid = first.get().getAppId();
+                                    String backupAppId = resolveAppIdByType(companyUserId, 1, listAll);
+                                    if (StringUtils.isNotEmpty(backupAppId)) {
+                                        appid = backupAppId;
                                         log.info("ID:{}, qwUserId:{},externalId:{},看过的小程序已封禁,使用备用小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
                                     }
                                 }
@@ -156,10 +161,9 @@ public class IpadSendServer {
                                     appid = optional2.get().getAppid();
                                     log.info("ID:{}, qwUserId:{},externalId:{},找到正常小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
                                 } else {
-                                    Optional<CompanyMiniapp> first = listAll.stream().filter(e -> e.getType() == 1).findFirst();
-                                    // 查找备用小程序发送备用记录ID
-                                    if (first.isPresent()) {
-                                        appid = first.get().getAppId();
+                                    String backupAppId = resolveAppIdByType(companyUserId, 1, listAll);
+                                    if (StringUtils.isNotEmpty(backupAppId)) {
+                                        appid = backupAppId;
                                         log.info("ID:{}, qwUserId:{},externalId:{},找到备用小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
                                     } else {
                                         log.info("ID:{}, qwUserId:{},externalId:{},没找到小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
@@ -174,11 +178,9 @@ public class IpadSendServer {
                             LocalDateTime lastTime = LocalDateTime.of(2025, 11, 10, 23, 59, 59);
                             // 判断客户创建时间是在这个之前还是之后
                             int listIndex = createTime.isAfter(lastTime) ? 1 : 0;
-                            // 获取公司里面的主备小程序,根据用户创建时间来发送主备,如果创建时间大于上看的时间就发送备用小程序,如果小于就发送主要小程序
-                            List<CompanyMiniapp> collect2 = listAll.stream().filter(e -> e.getType().equals(listIndex)).collect(Collectors.toList());
-                            // 判断是否获取到了配置
-                            if (!collect2.isEmpty() && collect2.get(0) != null && StringUtils.isNotEmpty(collect2.get(0).getAppId())) {
-                                appid = collect2.get(0).getAppId();
+                            String resolvedByTime = resolveAppIdByType(companyUserId, listIndex, listAll);
+                            if (StringUtils.isNotEmpty(resolvedByTime)) {
+                                appid = resolvedByTime;
                                 log.info("ID:{}, qwUserId:{},externalId:{},发送小程序:{}", vo.getId(), vo.getQwUserId(), vo.getExId(), appid);
                             }
                         }
@@ -193,11 +195,18 @@ public class IpadSendServer {
             }
         }
         if (forceCompanyApp && StringUtils.isNotEmpty(appid) && !allowedCompanyAppIds.contains(appid)) {
-            Optional<String> preferredAppId = listAll.stream()
-                    .filter(e -> e.getType().equals(content.getMiniType()) && StringUtils.isNotEmpty(e.getAppId()))
-                    .map(CompanyMiniapp::getAppId)
-                    .filter(allowedCompanyAppIds::contains)
-                    .findFirst();
+            String deptAppId = resolveDeptMiniAppId(companyUserId, content.getMiniType());
+            Optional<String> preferredAppId = Optional.empty();
+            if (StringUtils.isNotEmpty(deptAppId) && allowedCompanyAppIds.contains(deptAppId)) {
+                preferredAppId = Optional.of(deptAppId);
+            }
+            if (!preferredAppId.isPresent()) {
+                preferredAppId = listAll.stream()
+                        .filter(e -> e.getType().equals(content.getMiniType()) && StringUtils.isNotEmpty(e.getAppId()))
+                        .map(CompanyMiniapp::getAppId)
+                        .filter(allowedCompanyAppIds::contains)
+                        .findFirst();
+            }
             if (!preferredAppId.isPresent()) {
                 preferredAppId = allowedCompanyAppIds.stream().findFirst();
             }
@@ -211,9 +220,9 @@ public class IpadSendServer {
             courseMaConfig = null;
         }
         if (courseMaConfig == null) {
-            List<CompanyMiniapp> list = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().eq("company_id", companyId).eq("type", 1));
-            if (!list.isEmpty() && list.get(0) != null && StringUtils.isNotEmpty(list.get(0).getAppId())) {
-                courseMaConfig = miniMap.get(list.get(0).getAppId());
+            String backupAppId = resolveAppIdByType(companyUserId, 1, listAll);
+            if (StringUtils.isNotEmpty(backupAppId)) {
+                courseMaConfig = miniMap.get(backupAppId);
             }
         }
         if (courseMaConfig == null && forceCompanyApp) {
@@ -251,6 +260,45 @@ public class IpadSendServer {
         }
     }
 
+    private String resolveDeptMiniAppId(Long companyUserId, Integer miniType) {
+        if (companyUserId == null || miniType == null) {
+            return null;
+        }
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        if (companyUser == null || companyUser.getDeptId() == null) {
+            return null;
+        }
+        CompanyDept dept = companyDeptMapper.selectCompanyDeptById(companyUser.getDeptId());
+        if (dept == null) {
+            return null;
+        }
+        if (miniType == 0) {
+            return dept.getMiniAppMaster();
+        }
+        if (miniType == 1) {
+            return dept.getMiniAppServer();
+        }
+        return null;
+    }
+
+    private String resolveAppIdByType(Long companyUserId, Integer miniType, List<CompanyMiniapp> companyMiniapps) {
+        if (miniType == null) {
+            return null;
+        }
+        String deptAppId = resolveDeptMiniAppId(companyUserId, miniType);
+        if (StringUtils.isNotEmpty(deptAppId)) {
+            return deptAppId;
+        }
+        if (companyMiniapps == null || companyMiniapps.isEmpty()) {
+            return null;
+        }
+        return companyMiniapps.stream()
+                .filter(e -> miniType.equals(e.getType()) && StringUtils.isNotEmpty(e.getAppId()))
+                .map(CompanyMiniapp::getAppId)
+                .findFirst()
+                .orElse(null);
+    }
+
     private boolean isConfigBoundToCompany(FsCoursePlaySourceConfig config, Long companyId) {
         if (config == null || companyId == null || StringUtils.isEmpty(config.getCompanyIds())) {
             return false;
@@ -809,7 +857,7 @@ public class IpadSendServer {
                 case "4":
                 case "10":
                 case "13":
-                    sendMiniProgram(vo, content, miniMap, qwUser.getCompanyId());
+                    sendMiniProgram(vo, content, miniMap, qwUser.getCompanyId(), qwUser.getCompanyUserId());
                     break;
                 case "5":
                     // 文件

+ 1 - 1
fs-qw-api/src/main/java/com/fs/app/controller/QwController.java

@@ -107,7 +107,7 @@ public class QwController {
             StringReader sr = new StringReader(sMsg);
             InputSource is = new InputSource(sr);
             Document document = db.parse(is);
-            qwDataCallbackService.dataCallback(document,corpId,qwCompany);
+            qwDataCallbackService.dataCallback(document,corpId,qwCompany, sMsg);
 
         }
         catch(Exception e)

+ 6 - 2
fs-qw-api/src/main/java/com/fs/app/service/QwDataCallbackService.java

@@ -1,7 +1,6 @@
 package com.fs.app.service;
 
 import cn.hutool.core.util.RandomUtil;
-import cn.hutool.core.util.StrUtil;
 import com.alibaba.fastjson.JSON;
 import com.fs.app.util.WXBizMsgCrypt;
 import com.fs.common.core.domain.AjaxResult;
@@ -27,6 +26,7 @@ import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
 import com.tencent.wework.Finance;
 import lombok.extern.slf4j.Slf4j;
+import org.jdom.output.XMLOutputter;
 import org.json.JSONObject;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
@@ -98,7 +98,7 @@ public class QwDataCallbackService {
     private ILeadService leadService;
 
     @Async
-    public void dataCallback( Document document,String corpId,QwCompany qwCompany) throws Exception {
+    public void dataCallback(Document document, String corpId, QwCompany qwCompany, String sMsg) throws Exception {
 
 
         Element root = document.getDocumentElement();
@@ -255,17 +255,21 @@ public class QwDataCallbackService {
 
                             break;
                         case "edit_external_contact":
+                            log.info("企业联系人时间变更,edit_external_contact:{}", sMsg);
                             qwExternalContactService.updateQwExternalContactByExternalUserId(root.getElementsByTagName("ExternalUserID").item(0).getTextContent(),root.getElementsByTagName("UserID").item(0).getTextContent(),corpId);
                             break;
                         case "del_external_contact":
+                            log.info("企业联系人时间变更,del_external_contact:{}", sMsg);
                             qwExternalContactService.deleteQwExternalContactByExternalUserId(root.getElementsByTagName("ExternalUserID").item(0).getTextContent(),root.getElementsByTagName("UserID").item(0).getTextContent(),corpId);
                             break;
                         case "del_follow_user":
+                            log.info("企业联系人时间变更,del_follow_user:{}", sMsg);
                             qwExternalContactService.deletefollowUserByExternalUserId(root.getElementsByTagName("ExternalUserID").item(0).getTextContent(),root.getElementsByTagName("UserID").item(0).getTextContent(),corpId);
                             // 广告线索处理
                             leadService.updateDeleteMemberLead(root.getElementsByTagName("ExternalUserID").item(0).getTextContent(),root.getElementsByTagName("UserID").item(0).getTextContent(),corpId);
                             break;
                         case "transfer_fail":
+                            log.info("企业联系人时间变更,transfer_fail:{}", sMsg);
                             qwExternalContactService.transferFailByExternalUserId(root.getElementsByTagName("ExternalUserID").item(0).getTextContent(),root.getElementsByTagName("UserID").item(0).getTextContent(),corpId,root.getElementsByTagName("FailReason").item(0).getTextContent());
                             break;
                     }

+ 41 - 14
fs-qw-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingMoreSevenDaysServiceImpl.java

@@ -27,6 +27,7 @@ import javax.annotation.PostConstruct;
 import javax.annotation.PreDestroy;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.*;
@@ -209,13 +210,8 @@ public class QwExternalContactRatingMoreSevenDaysServiceImpl implements QwExtern
 
             // 如果连续3天没有看课记录,标记为E级
             if (ratingVOS == null || ratingVOS.isEmpty()) {
-                log.info("连续{}天没有看课记录,标记为E级,externalId: {}", config.getNotStudyDays(), externalId);
-                QwExternalContact externalContact = new QwExternalContact();
-                externalContact.setId(externalId);
-                externalContact.setLevel(5);  // E级
-                externalContact.setLevelType(2);  // 降级(从ABCD任何等级变为E级都是降级)
-                externalContact.setIsDaysNotStudy(1);
-                return externalContact;
+                return buildELevelContactIfEligible(logsInfo, config,
+                        "连续{}天没有看课记录,标记为E级,externalId: {}");
             }
 
             // 判断连续3天的总观看时长是否大于0
@@ -223,18 +219,14 @@ public class QwExternalContactRatingMoreSevenDaysServiceImpl implements QwExtern
 
             if (!hasWatchDuration) {
                 // 有记录但观看时长为0,也认为连续3天未看课,标记为E级
-                log.info("连续{}天看课时长为0,标记为E级,externalId: {}", config.getNotStudyDays(), externalId);
-                QwExternalContact externalContact = new QwExternalContact();
-                externalContact.setId(externalId);
-                externalContact.setLevel(5);  // E级
-                externalContact.setLevelType(2);  // E级类型
-                externalContact.setIsDaysNotStudy(1);
-                return externalContact;
+                return buildELevelContactIfEligible(logsInfo, config,
+                        "连续{}天看课时长为0,标记为E级,externalId: {}");
             } else {
                 // 有看课记录且时长>0,不是E级,恢复为ABCD评级
                 log.info("最近{}天有看课记录,不是E级,externalId: {}", config.getNotStudyDays(), externalId);
                 QwExternalContact externalContact = new QwExternalContact();
                 externalContact.setId(externalId);
+                externalContact.setSopUserLogsInfoId(logsInfo.getId());
                 // 检查当前等级是否为E级,如果是则需要重新计算
                 Integer currentLevel = ratingVOS.get(0).getLevel();
                 if (currentLevel != null && currentLevel == 5) {
@@ -342,6 +334,41 @@ public class QwExternalContactRatingMoreSevenDaysServiceImpl implements QwExtern
     }
 
 
+    /**
+     * 转接客户(is_transfer=1)需 create_time 超过3天才参与E级评定,其他客户不受影响。
+     */
+    private boolean canRateTransferContactAsELevel(Long externalId) {
+        QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(externalId);
+        if (contact == null) {
+            return true;
+        }
+        if (contact.getIsTransfer() == null || contact.getIsTransfer() != 1) {
+            return true;
+        }
+        Date createTime = contact.getCreateTime();
+        if (createTime == null) {
+            return true;
+        }
+        long daysSinceCreate = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - createTime.getTime());
+        return daysSinceCreate > 3;
+    }
+
+    private QwExternalContact buildELevelContactIfEligible(SopUserLogsInfo logsInfo, QwRatingConfig config, String logPattern) {
+        Long externalId = logsInfo.getExternalId();
+        if (!canRateTransferContactAsELevel(externalId)) {
+            log.info("转接客户创建未满3天,跳过E级评定,externalId: {}", externalId);
+            return null;
+        }
+        log.info(logPattern, config.getNotStudyDays(), externalId);
+        QwExternalContact externalContact = new QwExternalContact();
+        externalContact.setId(externalId);
+        externalContact.setSopUserLogsInfoId(logsInfo.getId());
+        externalContact.setLevel(5);
+        externalContact.setLevelType(2);
+        externalContact.setIsDaysNotStudy(1);
+        return externalContact;
+    }
+
     //查 E级
     public boolean getScoreMoreStudyLevel(List<QwRatingVO> qwRatingVOS) {
 

+ 6 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/impl/QwExternalContactRatingServiceImpl.java

@@ -212,6 +212,11 @@ public class QwExternalContactRatingServiceImpl implements QwExternalContactRati
                 return null;
             }
 
+            if (Integer.valueOf(1).equals(logsInfo.getIsDaysNotStudy())) {
+                log.info("连续未看课客户跳过ABCD评级,externalId: {}", externalId);
+                return null;
+            }
+
             // 查询ABCD评级看课记录
             List<QwRatingVO> ratingVOS = fsCourseWatchLogMapper
                     .selectFsCourseWatchLogByExtIdRatingWithTransfer(externalId, config.getLevelDay());
@@ -229,6 +234,7 @@ public class QwExternalContactRatingServiceImpl implements QwExternalContactRati
 
             QwExternalContact externalContact = new QwExternalContact();
             externalContact.setId(externalId);
+            externalContact.setSopUserLogsInfoId(logsInfo.getId());
             externalContact.setLevel(scoreLevel);
             externalContact.setLastWatchTime(latestTime);
             externalContact.setLevelType(levelUpFall);

+ 54 - 54
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -10,11 +10,15 @@ import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.PubFun;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyDept;
 import com.fs.company.domain.CompanyMiniapp;
 import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyDeptMapper;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.company.service.ICompanyUserService;
+import com.fs.company.util.MiniAppIdResolver;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.*;
@@ -185,6 +189,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private CompanyMapper companyMapper;
 
+    @Autowired
+    private CompanyDeptMapper companyDeptMapper;
+
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+
     @Autowired
     private AsyncCourseWatchFinishService asyncCourseWatchFinishService;
 
@@ -358,7 +368,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
 
         Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
-
+        Map<Long, CompanyDept> deptMiniAppMap = MiniAppIdResolver.buildDeptMiniAppMap(companyDeptMapper.queryDeptDataAll());
+        Map<Long, Long> companyUserDeptMap = MiniAppIdResolver.buildCompanyUserDeptMap(companyUserMapper.selectAllCompanyUserList());
 
         List<Company> companies = companyMapper.selectCompanyAllList();
 
@@ -369,7 +380,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         for (Map.Entry<String, List<SopUserLogsVo>> entry : sopLogsGroupedById.entrySet()) {
             String sopId = entry.getKey();
             List<SopUserLogsVo> userLogsVos = entry.getValue();
-            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch,currentTime, groupChatMap,config,miniMap,companies);
+            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch,currentTime, groupChatMap,config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
         }
 
         // 等待所有 SOP 分组处理完成
@@ -391,9 +402,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     )
     public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch ,LocalDateTime currentTime,
                                      Map<String, QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                     List<Company> companies) {
+                                     List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         try {
-            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies);
+            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
         } catch (Exception e) {
             log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
         } finally {
@@ -404,7 +415,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
                                          QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                 List<Company> companies) throws Exception {
+                                 List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) throws Exception {
         QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
 
         if (ruleTimeVO == null) {
@@ -447,7 +458,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
         for (SopUserLogsVo logVo : userLogsVos) {
             processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
-                    config,miniMap,companies);
+                    config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
         }
 
         // 等待所有用户日志处理完成
@@ -469,9 +480,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                     CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
                                     String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                    List<Company> companies) {
+                                    List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         try {
-            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies);
+            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
         } finally {
@@ -483,7 +494,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                 LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
                                 CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                List<Company> companies) {
+                                List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         try {
 
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
@@ -740,7 +751,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
                         insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
                                 companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
-                                groupChatMap, miniAppId,config,miniMap, sendMsgType,companies);
+                                groupChatMap, miniAppId,config,miniMap, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap);
 
                     }
                 } catch (Exception e) {
@@ -786,7 +797,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                    String qwUserId,String companyUserId,String companyId,String welcomeText,String qwUserName,
                                    Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
                                    Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
-                                   List<Company> companies) {
+                                   List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         String formattedSendTime = sendTime.toInstant()
                 .atZone(ZoneId.systemDefault())
                 .format(DATE_TIME_FORMATTER);
@@ -866,7 +877,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null,null);
             handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
                     type, qwUserId, companyUserId, companyId, groupChat.getChatId(), welcomeText, qwUserName,
-                    null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies,liveId);
+                    null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap);
             }
 //            if (content.getIndex() == 0) {
 //                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null);
@@ -915,7 +926,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId, isOfficial, contactId.getExternalId(),contactId.getIsDaysNotStudy());
                     handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
                             type, qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId, false, miniAppId,
-                            null,config, miniMap, grade, sendMsgType,companies,liveId);
+                            null,config, miniMap, grade, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap);
                 } catch (Exception e) {
                     e.printStackTrace();
                     log.error("处理 externalContactId {} 时发生异常", contactId, e);
@@ -1064,6 +1075,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         if (ObjectUtil.isNotEmpty(logVo.getActualQwId())){
             sopLogs.setQwUserKey(logVo.getActualQwId());
         }
+        qwSopLogsService.applyBlacklistCheck(sopLogs);
         return sopLogs;
     }
 
@@ -1073,7 +1085,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                       String qwUserName, Long fsUserId, boolean isGroupChat, String miniAppId,
                                       QwGroupChat groupChat,CourseConfig config,
                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                      Integer grade, Integer sendMsgType ,List<Company> companies ,Long liveId) {
+                                      Integer grade, Integer sendMsgType ,List<Company> companies ,Long liveId,
+                                      Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         switch (type) {
             case 1:
                 handleNormalMessage(sopLogs, content,companyUserId,companyId,isGroupChat,qwUserId,groupChat,externalId,logVo);
@@ -1081,7 +1094,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             case 2:
                 handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
                         qwUserId, companyUserId, companyId, externalId, welcomeText,qwUserName, fsUserId,
-                        isGroupChat, miniAppId, groupChat,config,miniMap, grade, sendMsgType,companies);
+                        isGroupChat, miniAppId, groupChat,config,miniMap, grade, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap);
                 break;
             case 3:
                 handleOrderMessage(sopLogs, content);
@@ -1149,6 +1162,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
         // 顺序处理每个 Setting,避免过多的并行导致线程开销
         for (QwSopTempSetting.Content.Setting setting : settings) {
+            if ("1".equals(setting.getContentType()) || "15".equals(setting.getContentType())) {
+                setting.applyRandomTextValue();
+            }
             switch (setting.getContentType()) {
                 //直播小程序单独
                 case "12":
@@ -1228,6 +1244,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 顺序处理每个 Setting,避免过多的并行导致线程开销
         for (QwSopTempSetting.Content.Setting setting : settings) {
+            if ("1".equals(setting.getContentType()) || "15".equals(setting.getContentType())) {
+                setting.applyRandomTextValue();
+            }
             switch (setting.getContentType()) {
                 //直播小程序单独
                 case "12":
@@ -1293,7 +1312,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                      String companyId, String externalId, String welcomeText, String qwUserName,
                                      Long fsUserId, boolean isGroupChat, String miniAppId, QwGroupChat groupChat,CourseConfig config,Map<Long,
                     Map<Integer, List<CompanyMiniapp>>> miniMap,Integer grade, Integer sendMsgType,
-                                     List<Company> companies) {
+                                     List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
         QwExternalContact contact = null;
         if(logVo.getExternalId() != null){
             contact = qwExternalContactMapper.selectById(logVo.getExternalId());
@@ -1323,6 +1342,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
         // 顺序处理每个 Setting,避免过多的并行导致线程开销
         for (QwSopTempSetting.Content.Setting setting : settings) {
+            if ("1".equals(setting.getContentType()) || "15".equals(setting.getContentType())) {
+                setting.applyRandomTextValue();
+            }
             switch (setting.getContentType()) {
                 //文字和短链一起
                 case "1":
@@ -1418,8 +1440,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         setting.setMiniprogramAppid(miniAppId);
                     }else {
                         int miniType = getLevel(grade);
-                        //算主备小程序
-                        String finalAppId = getAppIdFromMiniMap(miniMap, companyId, sendMsgType, grade);
+                        Long deptId = MiniAppIdResolver.getDeptId(Long.parseLong(companyUserId), companyUserDeptMap);
+                        String finalAppId = getAppIdFromMiniMap(miniMap, companyId, sendMsgType, grade, deptId, deptMiniAppMap);
 
                         if (StringUtil.strIsNullOrEmpty(finalAppId)) {
                             finalAppId = miniAppId;
@@ -1535,7 +1557,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         setting.setMiniprogramAppid(miniAppId);
                     }else {
                         int miniType = getLevel(grade);
-                        String finalAppId = getAppIdFromMiniMap(miniMap, companyId, sendMsgType, grade);
+                        Long deptIdArticle = MiniAppIdResolver.getDeptId(Long.parseLong(companyUserId), companyUserDeptMap);
+                        String finalAppId = getAppIdFromMiniMap(miniMap, companyId, sendMsgType, grade, deptIdArticle, deptMiniAppMap);
 
                         if (StringUtil.strIsNullOrEmpty(finalAppId)) {
                             finalAppId = articleSysConfig.getAppId();
@@ -1583,27 +1606,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private String getAppIdFromMiniMap(Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                        String companyId,
                                        Integer sendMsgType,
-                                       Integer grade) {
-        if (miniMap == null || miniMap.isEmpty() || sendMsgType == null || sendMsgType != 1) {
-            return null;
-        }
-
-        Map<Integer, List<CompanyMiniapp>> gradeMap = miniMap.get(Long.valueOf(companyId));
-        if (gradeMap == null) {
-            return null;
-        }
-
+                                       Integer grade,
+                                       Long deptId,
+                                       Map<Long, CompanyDept> deptMiniAppMap) {
         int listIndex = getLevel(grade);
-        List<CompanyMiniapp> miniapps = gradeMap.get(listIndex);
-
-        if (miniapps == null || miniapps.isEmpty()) {
-            return null;
-        }
-
-        CompanyMiniapp companyMiniapp = miniapps.get(0);
-        return (companyMiniapp != null && !StringUtil.strIsNullOrEmpty(companyMiniapp.getAppId()))
-                ? companyMiniapp.getAppId()
-                : null;
+        return MiniAppIdResolver.resolveMiniAppId(
+                deptId, Long.valueOf(companyId), listIndex, sendMsgType, miniMap, deptMiniAppMap);
     }
 
     /**
@@ -2754,6 +2762,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         setting.setSetting(settings);
         sopLogs.setContentJson(JSON.toJSONString(setting));
+        qwSopLogsService.applyBlacklistCheck(sopLogs);
         return sopLogs;
     }
 
@@ -2768,6 +2777,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         Date dataTime = new Date();
         List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
         Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
+        Map<Long, CompanyDept> deptMiniAppMap = MiniAppIdResolver.buildDeptMiniAppMap(companyDeptMapper.queryDeptDataAll());
+        Map<Long, Long> companyUserDeptMap = MiniAppIdResolver.buildCompanyUserDeptMap(companyUserMapper.selectAllCompanyUserList());
 
         QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(sopLogs.getCorpId());
         QwUser qwUser = qwExternalContactService.getQwUserByRedis(sopLogs.getCorpId(), sopLogs.getQwUserid());
@@ -2793,21 +2804,10 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                             String.valueOf(qwUser.getId()), qwUser.getCompanyUserId().toString(), qwUser.getCompanyId().toString(), sopLogs.getExternalId(), config);
 
 
-                    String miniAppId = null;
-                    if (!miniMap.isEmpty() && qwUser.getSendMsgType() == 1) {
-                        Map<Integer, List<CompanyMiniapp>> integerListMap = miniMap.get(Long.valueOf(qwUser.getCompanyId()));
-                        if (integerListMap != null) {
-                            int listIndex = getLevel(grade);
-                            List<CompanyMiniapp> miniapps = integerListMap.get(listIndex);
-
-                            if (miniapps != null && !miniapps.isEmpty()) {
-                                CompanyMiniapp companyMiniapp = miniapps.get(0);
-                                if (companyMiniapp != null && !StringUtil.strIsNullOrEmpty(companyMiniapp.getAppId())) {
-                                    miniAppId = companyMiniapp.getAppId();
-                                }
-                            }
-                        }
-                    }
+                    int listIndex = getLevel(grade);
+                    Long deptId = MiniAppIdResolver.getDeptId(qwUser.getCompanyUserId(), companyUserDeptMap);
+                    String miniAppId = MiniAppIdResolver.resolveMiniAppId(
+                            deptId, qwUser.getCompanyId(), listIndex, qwUser.getSendMsgType(), miniMap, deptMiniAppMap);
 
                     if (StringUtil.strIsNullOrEmpty(miniAppId) && !StringUtil.strIsNullOrEmpty(qwCompany.getMiniAppId())) {
                         miniAppId = qwCompany.getMiniAppId();

+ 2 - 1
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopUserLogsInfoByIsDaysNotStudyImpl.java

@@ -246,10 +246,11 @@ public class SopUserLogsInfoByIsDaysNotStudyImpl implements SopUserLogsInfoByIsD
             if (sumDuration!=null && sumDuration>0) {
                 QwExternalContact externalContact = new QwExternalContact();
                 externalContact.setId(externalId);
+                externalContact.setSopUserLogsInfoId(logsInfo.getId());
                 externalContact.setLevel(4);  // 从E级恢复到D级
                 externalContact.setLevelType(1);  // 升级
                 externalContact.setIsDaysNotStudy(0);  // 标记为非连续未看课
-                log.info("客户从E级恢复到D级,externalId: {}", externalId);
+                log.info("客户从E级恢复到D级,externalId: {}, logsInfoId: {}", externalId, logsInfo.getId());
                 return externalContact;
             }
 

+ 22 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyDept.java

@@ -64,6 +64,12 @@ public class CompanyDept extends BaseEntity
     @Excel(name = "是否删除")
     private Integer isDel;
 
+    /** 主小程序appId */
+    private String miniAppMaster;
+
+    /** 备小程序appId */
+    private String miniAppServer;
+
     private List<CompanyDept> children = new ArrayList<CompanyDept>();
 
     public List<CompanyDept> getChildren() {
@@ -183,6 +189,22 @@ public class CompanyDept extends BaseEntity
         return isDel;
     }
 
+    public String getMiniAppMaster() {
+        return miniAppMaster;
+    }
+
+    public void setMiniAppMaster(String miniAppMaster) {
+        this.miniAppMaster = miniAppMaster;
+    }
+
+    public String getMiniAppServer() {
+        return miniAppServer;
+    }
+
+    public void setMiniAppServer(String miniAppServer) {
+        this.miniAppServer = miniAppServer;
+    }
+
     @Override
     public String toString() {
         return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

+ 5 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyDeptService.java

@@ -91,4 +91,9 @@ public interface ICompanyDeptService
      * @return 部门
      */
     CompanyDept getDefaultCompanyDeptByCompanyId(Long companyId);
+
+    /**
+     * 校验部门主备小程序是否属于本公司可配置列表
+     */
+    String validateDeptMiniApp(Long companyId, String miniAppMaster, String miniAppServer);
 }

+ 5 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyService.java

@@ -66,6 +66,11 @@ public interface ICompanyService
      */
     public int updateCompany(Company company);
 
+    /**
+     * 修改销售公司(强制评级变更时同步SOP)
+     */
+    int updateFsCompany(Company company);
+
     /**
      * 批量删除企业
      *

+ 28 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyDeptServiceImpl.java

@@ -10,7 +10,9 @@ import com.fs.company.domain.CompanyDeptTreeSelect;
 import com.fs.company.domain.CompanyRole;
 import com.fs.company.mapper.CompanyDeptMapper;
 import com.fs.company.mapper.CompanyRoleMapper;
+import com.fs.company.service.ICompanyConfigService;
 import com.fs.company.service.ICompanyDeptService;
+import com.fs.company.vo.CompanyMiniAppVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -18,6 +20,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -34,6 +37,9 @@ public class CompanyDeptServiceImpl implements ICompanyDeptService
 
     @Autowired
     private CompanyRoleMapper roleMapper;
+
+    @Autowired
+    private ICompanyConfigService companyConfigService;
     /**
      * 查询部门
      *
@@ -291,4 +297,26 @@ public class CompanyDeptServiceImpl implements ICompanyDeptService
     {
         return getChildList(list, t).size() > 0 ? true : false;
     }
+
+    @Override
+    public String validateDeptMiniApp(Long companyId, String miniAppMaster, String miniAppServer) {
+        if (StringUtils.isEmpty(miniAppMaster) && StringUtils.isEmpty(miniAppServer)) {
+            return null;
+        }
+        List<CompanyMiniAppVO> playSourceMiniApps = companyConfigService.getCompanyMiniAppListByCompany(companyId);
+        if (playSourceMiniApps == null || playSourceMiniApps.isEmpty()) {
+            return "未找到本公司的小程序配置,请先在点播播放源配置中维护本公司小程序";
+        }
+        Set<String> validAppIds = playSourceMiniApps.stream()
+                .map(CompanyMiniAppVO::getAppid)
+                .filter(StringUtils::isNotEmpty)
+                .collect(Collectors.toSet());
+        if (StringUtils.isNotEmpty(miniAppMaster) && !validAppIds.contains(miniAppMaster)) {
+            return "主小程序不在本公司可配置列表中";
+        }
+        if (StringUtils.isNotEmpty(miniAppServer) && !validAppIds.contains(miniAppServer)) {
+            return "备小程序不在本公司可配置列表中";
+        }
+        return null;
+    }
 }

+ 14 - 2
fs-service/src/main/java/com/fs/company/service/impl/CompanyServiceImpl.java

@@ -602,8 +602,20 @@ public class CompanyServiceImpl implements ICompanyService
         if(company.isUpdateMiniApp()){
             bindMiniApp(company);
         }
-        int rows = companyMapper.updateCompany(company);
-        if (rows > 0 && company.getLevel() != null && company.getCompanyId() != null) {
+        return companyMapper.updateCompany(company);
+    }
+
+    @Override
+    public int updateFsCompany(Company company)
+    {
+        Integer oldLevel = null;
+        if (company.getCompanyId() != null && company.getLevel() != null) {
+            Company oldCompany = companyMapper.selectCompanyById(company.getCompanyId());
+            oldLevel = oldCompany != null ? oldCompany.getLevel() : null;
+        }
+        int rows = updateCompany(company);
+        if (rows > 0 && company.getLevel() != null && company.getCompanyId() != null
+                && !Objects.equals(oldLevel, company.getLevel())) {
             qwSopMapper.updateIsRatingByCompanyId(company.getCompanyId(), company.getLevel());
         }
         return rows;

+ 122 - 0
fs-service/src/main/java/com/fs/company/util/MiniAppIdResolver.java

@@ -0,0 +1,122 @@
+package com.fs.company.util;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyDept;
+import com.fs.company.domain.CompanyMiniapp;
+import com.fs.company.domain.CompanyUser;
+import com.fs.voice.utils.StringUtil;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 小程序SOP工具类,根据用户、部门、企业信息解析对应小程序appId,区分主小程序/服务小程序
+ */
+public final class MiniAppIdResolver {
+
+    // 私有构造,禁止实例化工具类
+    private MiniAppIdResolver() {
+    }
+
+    /**
+     * 构建部门ID -> 部门信息映射,只过滤配置了主小程序/服务小程序的部门
+     * @param depts 部门列表
+     * @return Map<部门ID, 部门实体>
+     */
+    public static Map<Long, CompanyDept> buildDeptMiniAppMap(List<CompanyDept> depts) {
+        if (depts == null || depts.isEmpty()) {
+            return new HashMap<>();
+        }
+        return depts.stream()
+                .filter(d -> StringUtils.isNotEmpty(d.getMiniAppMaster()) || StringUtils.isNotEmpty(d.getMiniAppServer()))
+                // key重复保留第一个部门数据
+                .collect(Collectors.toMap(CompanyDept::getDeptId, d -> d, (oldVal, newVal) -> oldVal));
+    }
+
+    /**
+     * 构建用户ID -> 部门ID映射关系
+     * @param users 企业用户列表
+     * @return Map<用户ID, 部门ID>
+     */
+    public static Map<Long, Long> buildCompanyUserDeptMap(List<CompanyUser> users) {
+        if (users == null || users.isEmpty()) {
+            return new HashMap<>();
+        }
+        Map<Long, Long> map = new HashMap<>(users.size());
+        for (CompanyUser user : users) {
+            Long userId = user.getUserId();
+            Long deptId = user.getDeptId();
+            if (userId != null && deptId != null) {
+                map.put(userId, deptId);
+            }
+        }
+        return map;
+    }
+
+    /**
+     * 根据用户ID获取所属部门ID
+     * @param companyUserId 用户ID
+     * @param companyUserDeptMap 用户-部门映射表
+     * @return 部门ID,无匹配返回null
+     */
+    public static Long getDeptId(Long companyUserId, Map<Long, Long> companyUserDeptMap) {
+        if (companyUserId == null || companyUserDeptMap == null) {
+            return null;
+        }
+        return companyUserDeptMap.get(companyUserId);
+    }
+
+    /**
+     * 解析最终可用小程序AppId
+     * 优先级:部门自定义小程序 > 企业级通用小程序
+     * @param deptId 部门ID
+     * @param companyId 企业ID
+     * @param listIndex 小程序类型下标 0=主小程序 1=服务小程序
+     * @param sendMsgType 消息发送类型,仅等于1时读取企业通用小程序
+     * @param miniMap 企业小程序总映射:key企业ID -> key消息类型 -> 小程序列表
+     * @param deptMiniAppMap 带小程序配置的部门映射表
+     * @return 匹配到的小程序AppId,无配置返回null
+     */
+    public static String resolveMiniAppId(Long deptId,
+                                          Long companyId,
+                                          int listIndex,
+                                          Integer sendMsgType,
+                                          Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                          Map<Long, CompanyDept> deptMiniAppMap) {
+        // 1.优先读取部门单独配置的小程序
+        if (deptId != null && deptMiniAppMap != null && !deptMiniAppMap.isEmpty()) {
+            CompanyDept dept = deptMiniAppMap.get(deptId);
+            if (dept != null) {
+                String targetAppId = listIndex == 0 ? dept.getMiniAppMaster() : dept.getMiniAppServer();
+                if (StringUtils.isNotEmpty(targetAppId)) {
+                    return targetAppId;
+                }
+            }
+        }
+
+        // 消息类型非1、企业小程序映射为空、企业ID为空,直接返回空
+        if (miniMap == null || miniMap.isEmpty() || sendMsgType == null || !sendMsgType.equals(1) || companyId == null) {
+            return null;
+        }
+
+        // 2.读取企业统一配置的小程序
+        Map<Integer, List<CompanyMiniapp>> typeMiniAppMap = miniMap.get(companyId);
+        if (typeMiniAppMap == null) {
+            return null;
+        }
+
+        List<CompanyMiniapp> miniAppList = typeMiniAppMap.get(listIndex);
+        if (miniAppList == null || miniAppList.isEmpty()) {
+            return null;
+        }
+
+        // 取列表第一条小程序配置
+        CompanyMiniapp firstMiniApp = miniAppList.get(0);
+        if (firstMiniApp != null && !StringUtil.strIsNullOrEmpty(firstMiniApp.getAppId())) {
+            return firstMiniApp.getAppId();
+        }
+        return null;
+    }
+}

+ 24 - 0
fs-service/src/main/java/com/fs/course/config/CourseRandomRedPacketConfig.java

@@ -0,0 +1,24 @@
+package com.fs.course.config;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 课程随机红包配置,对应 fs_user_course.project 字段
+ */
+@Data
+public class CourseRandomRedPacketConfig {
+
+    /** 课程项目ID,对应 fs_user_course.project */
+    private Long project;
+
+    /** 是否开启随机红包 */
+    private Boolean enableRandom;
+
+    /** 企业白名单,多个企业逗号分隔,ALL代表全部企业可见 */
+    private String companyWhitelist;
+
+    /** 红包档位配置 */
+    private List<CourseRandomRedPacketTier> tiers;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/course/config/CourseRandomRedPacketTier.java

@@ -0,0 +1,18 @@
+package com.fs.course.config;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 课程随机红包档位
+ */
+@Data
+public class CourseRandomRedPacketTier {
+
+    /** 红包金额 */
+    private BigDecimal amount;
+
+    /** 权重,随机抽取时按权重分配概率,权重越大中奖概率越高 */
+    private Integer weight;
+}

+ 34 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserProjectQw.java

@@ -0,0 +1,34 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户-项目-企微销售去重对象 fs_user_project_qw
+ */
+@Data
+@TableName("fs_user_project_qw")
+public class FsUserProjectQw implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long fsUserId;
+
+    private Long projectId;
+
+    private Long qwUserId;
+
+    private Long companyId;
+
+    private Date firstBindTime;
+
+    private Date updateTime;
+}

+ 38 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserProjectRepeat.java

@@ -0,0 +1,38 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 用户-项目重粉标记对象 fs_user_project_repeat
+ */
+@Data
+@TableName("fs_user_project_repeat")
+public class FsUserProjectRepeat implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long fsUserId;
+
+    /** 等价项目组 canonical 项目 ID */
+    private Long projectId;
+
+    private Long companyId;
+
+    private Integer qwUserCount;
+
+    /** 是否重粉 0否 1是 */
+    private Integer isRepeat;
+
+    private Date createTime;
+
+    private Date updateTime;
+}

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

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.course.domain.FsUserCompanyBind;
 import com.fs.course.vo.CourseProgressResultVO;
 import com.fs.course.vo.RelatedSalesResultVO;
+import com.fs.course.vo.RepeatProjectResultVO;
 import com.fs.course.vo.UserWatchLogListVo;
 import com.fs.qw.param.UserWatchLogParam;
 import org.apache.ibatis.annotations.Param;
@@ -81,4 +82,9 @@ public interface FsUserCompanyBindMapper extends BaseMapper<FsUserCompanyBind>{
      * 通过fsUserId查找FsCourseWatchLog记录,计算每门课程的进度
      */
     List<CourseProgressResultVO> getCourseProgressByFsUserId(@Param("fsUserId") Long fsUserId);
+
+    /**
+     * 查询重粉看课历史 - 客户企微发课看过的项目(send_type=2)
+     */
+    List<RepeatProjectResultVO> getUserWatchedProjectsByFsUserId(@Param("fsUserId") Long fsUserId);
 }

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

@@ -112,6 +112,7 @@ public interface FsUserCompanyUserMapper extends BaseMapper<FsUserCompanyUser>{
      * @return 会员ID列表
      */
     List<Long> selectUserIdsByPeriodIdAndCompanyUserId(@Param("periodId") Long periodId,
-                                                        @Param("companyUserId") Long companyUserId);
+                                                        @Param("companyUserId") Long companyUserId,
+                                                        @Param("companyId") Long companyId);
      List<FsUserCompanyUser> getFsUserCompanyUserByIds(@Param("ids") Long[] ids);
 }

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

@@ -65,7 +65,7 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
      * @return 结果
      */
     public int updateFsUserCourseVideo(FsUserCourseVideo fsUserCourseVideo);
-    int updateVideoByVideoUrl(@Param("videoUrl") String videoUrl,@Param("thumbnail")String thumbnail, @Param("ids") List<Long> ids,@Param("fileName") String fileName,@Param("duration") Integer duration);
+    int updateVideoByVideoUrl(@Param("videoUrl") String videoUrl, @Param("thumbnail") String thumbnail, @Param("ids") List<Long> ids, @Param("fileName") String fileName, @Param("duration") Integer duration, @Param("lineOne") String lineOne, @Param("lineTwo") String lineTwo);
     List<FsUserCourseVideo> selectByVideoUrl(String videoUrl);
 
     @Update("<script> " +

+ 24 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserProjectQwMapper.java

@@ -0,0 +1,24 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FsUserProjectQw;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface FsUserProjectQwMapper extends BaseMapper<FsUserProjectQw> {
+
+    int insertOrIgnore(@Param("fsUserId") Long fsUserId,
+                       @Param("projectId") Long projectId,
+                       @Param("qwUserId") Long qwUserId,
+                       @Param("companyId") Long companyId);
+
+    int countDistinctQwUser(@Param("fsUserId") Long fsUserId,
+                            @Param("equivalentProjectIds") List<Long> equivalentProjectIds);
+
+    int existsQwUserInProjects(@Param("fsUserId") Long fsUserId,
+                               @Param("qwUserId") Long qwUserId,
+                               @Param("equivalentProjectIds") List<Long> equivalentProjectIds);
+}

+ 16 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserProjectRepeatMapper.java

@@ -0,0 +1,16 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FsUserProjectRepeat;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface FsUserProjectRepeatMapper extends BaseMapper<FsUserProjectRepeat> {
+
+    int upsert(@Param("fsUserId") Long fsUserId,
+               @Param("projectId") Long projectId,
+               @Param("companyId") Long companyId,
+               @Param("qwUserCount") Integer qwUserCount,
+               @Param("isRepeat") Integer isRepeat);
+}

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

@@ -152,7 +152,7 @@ public interface FsUserVideoMapper
     @Select({"<script> " +
             "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 " +
+            "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares,v.add_num from fs_user_video_favorite f " +
             "left join fs_user_video v on v.video_id = f.video_id " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
             " left join fs_package p on p.package_id = v.product_id " +
@@ -165,7 +165,7 @@ public interface FsUserVideoMapper
     @Select({"<script> " +
             "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 " +
+            "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares,v.add_num from fs_user_video_like l " +
             "left join fs_user_video v on v.video_id = l.video_id " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
             " left join fs_package p on p.package_id = v.product_id " +
@@ -178,7 +178,7 @@ public interface FsUserVideoMapper
     @Select({"<script> " +
             "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 " +
+            "v.create_time,v.views as playNumber,v.product_id,v.product_json,p.img_url,p.package_name,v.shares,v.add_num from fs_user_video_comment c " +
             "left join fs_user_video v on v.video_id = c.video_id " +
             "left join fs_user_talent t on t.talent_id = v.talent_id " +
             " left join fs_package p on p.package_id = v.product_id " +
@@ -207,7 +207,7 @@ public interface FsUserVideoMapper
     @Select({"<script> " +
             "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 " +
+            "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 " +
             " left join fs_package p on p.package_id = v.product_id " +
             "where v.is_del = 0 and v.status = 1 and v.video_id = #{videoId} " +

+ 25 - 0
fs-service/src/main/java/com/fs/course/param/CourseRandomRedPacketBatchSaveParam.java

@@ -0,0 +1,25 @@
+package com.fs.course.param;
+
+import com.fs.course.config.CourseRandomRedPacketTier;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 课程随机红包批量保存入参
+ */
+@Data
+public class CourseRandomRedPacketBatchSaveParam {
+
+    /** 课程项目ID列表,对应 fs_user_course.project */
+    private List<Long> projects;
+
+    /** 是否开启随机红包 */
+    private Boolean enableRandom;
+
+    /** 企业白名单,多个企业逗号分隔,ALL代表全部企业可见 */
+    private String companyWhitelist;
+
+    /** 红包档位配置 */
+    private List<CourseRandomRedPacketTier> tiers;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/course/param/CourseRandomRedPacketCopyParam.java

@@ -0,0 +1,18 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 课程随机红包配置复制入参:将源项目配置复制到多个目标项目
+ */
+@Data
+public class CourseRandomRedPacketCopyParam {
+
+    /** 源课程项目ID */
+    private Long sourceProject;
+
+    /** 目标课程项目ID列表 */
+    private List<Long> targetProjects;
+}

+ 52 - 0
fs-service/src/main/java/com/fs/course/service/ICourseRandomRedPacketService.java

@@ -0,0 +1,52 @@
+package com.fs.course.service;
+
+import com.fs.course.config.CourseRandomRedPacketConfig;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.param.CourseRandomRedPacketBatchSaveParam;
+import com.fs.course.param.CourseRandomRedPacketCopyParam;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 课程随机红包配置服务接口
+ */
+public interface ICourseRandomRedPacketService {
+
+    /**
+     * 根据课程项目project获取红包配置,无配置返回null
+     */
+    CourseRandomRedPacketConfig getConfigByProject(Long project);
+
+    /**
+     * 保存/更新课程随机红包配置
+     */
+    void saveConfig(CourseRandomRedPacketConfig config);
+
+    /**
+     * 批量保存:同一套配置应用到多个课程项目
+     */
+    void batchSaveConfig(CourseRandomRedPacketBatchSaveParam param);
+
+    /**
+     * 复制配置:将源项目配置复制到多个目标项目
+     */
+    void copyConfig(CourseRandomRedPacketCopyParam param);
+
+    /**
+     * 查询所有已配置的课程项目
+     */
+    List<CourseRandomRedPacketConfig> listConfiguredProjects();
+
+    /**
+     * 计算最终红包金额:优先使用课程红包配置,异常时返回基础金额兜底
+     *
+     * @param baseAmount         原始基础红包金额
+     * @param courseId           课程ID,用于查询项目project
+     * @param companyId          企业ID
+     * @param video              视频信息,用于兼容旧版红包规则
+     * @param allowLegacyRandom  无课程红包配置时是否启用旧版全局红包逻辑
+     */
+    BigDecimal resolveAmount(BigDecimal baseAmount, Long courseId, Long companyId,
+                             FsUserCourseVideo video, boolean allowLegacyRandom);
+}

+ 6 - 1
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -207,7 +207,12 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
     R checkUserInfo(long l);
 
     R updateVideo();
-    AjaxResult updateVideoByVideoUrl(String videoUrl,String oldVideoUrl, String thumbnail,String fileName, Integer duration);
+    AjaxResult updateVideoByVideoUrl(String videoUrl, String oldVideoUrl, String thumbnail, String fileName, Integer duration, String lineOne, String lineTwo);
+
+    /**
+     * 同步视频时长到 Redis(看课完课判定依赖 h5user / h5wxuser 缓存)
+     */
+    void syncVideoDurationCache(Long videoId, Long duration);
 
     R checkUserInfo(Long userId);
 

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

@@ -0,0 +1,33 @@
+package com.fs.course.service;
+
+/**
+ * 维护用户-项目重粉中间表(fs_user_project_qw / fs_user_project_repeat)。
+ * 上线后看课/绑定时调用,列表按项目筛重粉走中间表索引查询。
+ */
+public interface IUserProjectRepeatMaintainService {
+
+    /**
+     * 记录用户在某项目下与某企微销售的绑定,并刷新等价项目组重粉标记。
+     */
+    void maintain(Long fsUserId, Long projectId, Long qwUserId, Long companyId);
+
+    /**
+     * 查询用户在某项目(含等价项目)下是否重粉,无中间表记录视为正常。
+     */
+    boolean isRepeatByProject(Long fsUserId, Long projectId);
+
+    /**
+     * 查询用户是否在任一等价项目组下为重粉。
+     */
+    boolean isRepeatInAnyProject(Long fsUserId);
+
+    /**
+     * 判断用户在某项目下绑定指定企微销售后是否会成为重粉(写入前判重,走中间表)。
+     */
+    boolean wouldBeRepeatAfterBind(Long fsUserId, Long projectId, Long qwUserId);
+
+    /**
+     * 转接完成:将重粉中间表里原销售的 qw_user_id 迁移到新销售,并刷新重粉标记。
+     */
+    void migrateQwUserOnTransfer(Long fsUserId, Long oldQwUserId, Long newQwUserId, Long companyId);
+}

+ 350 - 0
fs-service/src/main/java/com/fs/course/service/impl/CourseRandomRedPacketServiceImpl.java

@@ -0,0 +1,350 @@
+package com.fs.course.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseRandomRedPacketConfig;
+import com.fs.course.config.CourseRandomRedPacketTier;
+import com.fs.course.config.RandomRedPacketConfig;
+import com.fs.course.config.RandomRedPacketRule;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.param.CourseRandomRedPacketBatchSaveParam;
+import com.fs.course.param.CourseRandomRedPacketCopyParam;
+import com.fs.course.service.ICourseRandomRedPacketService;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.service.ISysConfigService;
+import org.apache.commons.collections4.CollectionUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * 课程随机红包配置服务实现,对应 fs_user_course.project 字段
+ */
+@Service
+public class CourseRandomRedPacketServiceImpl implements ICourseRandomRedPacketService {
+
+    private static final Logger logger = LoggerFactory.getLogger(CourseRandomRedPacketServiceImpl.class);
+
+    private static final String CONFIG_KEY_PREFIX = "course.randomRedPacket:project:";
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Override
+    public CourseRandomRedPacketConfig getConfigByProject(Long project) {
+        if (project == null) {
+            return null;
+        }
+        String json = configService.selectConfigByKey(buildConfigKey(project));
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        try {
+            CourseRandomRedPacketConfig config = JSONUtil.toBean(json, CourseRandomRedPacketConfig.class);
+            if (config != null) {
+                config.setProject(project);
+            }
+            return config;
+        } catch (Exception e) {
+            logger.error("解析课程随机红包配置失败 project={}", project, e);
+            return null;
+        }
+    }
+
+    @Override
+    public void saveConfig(CourseRandomRedPacketConfig config) {
+        validateConfig(config);
+        doSaveConfig(config);
+    }
+
+    @Override
+    public void batchSaveConfig(CourseRandomRedPacketBatchSaveParam param) {
+        if (param == null || CollectionUtils.isEmpty(param.getProjects())) {
+            throw new IllegalArgumentException("课程项目不能为空");
+        }
+        CourseRandomRedPacketConfig template = buildConfigFromBatchParam(param);
+        validateConfig(template);
+        for (Long project : param.getProjects()) {
+            if (project == null) {
+                continue;
+            }
+            CourseRandomRedPacketConfig config = copyConfigFields(template);
+            config.setProject(project);
+            doSaveConfig(config);
+        }
+    }
+
+    @Override
+    public void copyConfig(CourseRandomRedPacketCopyParam param) {
+        if (param == null || param.getSourceProject() == null) {
+            throw new IllegalArgumentException("源课程项目不能为空");
+        }
+        if (CollectionUtils.isEmpty(param.getTargetProjects())) {
+            throw new IllegalArgumentException("目标课程项目不能为空");
+        }
+        CourseRandomRedPacketConfig sourceConfig = getConfigByProject(param.getSourceProject());
+        if (sourceConfig == null) {
+            throw new IllegalArgumentException("源课程项目未配置红包规则");
+        }
+        for (Long targetProject : param.getTargetProjects()) {
+            if (targetProject == null || targetProject.equals(param.getSourceProject())) {
+                continue;
+            }
+            CourseRandomRedPacketConfig config = copyConfigFields(sourceConfig);
+            config.setProject(targetProject);
+            doSaveConfig(config);
+        }
+    }
+
+    @Override
+    public List<CourseRandomRedPacketConfig> listConfiguredProjects() {
+        SysConfig query = new SysConfig();
+        query.setConfigKey(CONFIG_KEY_PREFIX);
+        List<SysConfig> configList = configService.selectConfigList(query);
+        if (CollectionUtils.isEmpty(configList)) {
+            return new ArrayList<>();
+        }
+        List<CourseRandomRedPacketConfig> result = new ArrayList<>();
+        for (SysConfig sysConfig : configList) {
+            if (sysConfig == null || StringUtils.isBlank(sysConfig.getConfigKey())
+                    || !sysConfig.getConfigKey().startsWith(CONFIG_KEY_PREFIX)) {
+                continue;
+            }
+            String projectStr = sysConfig.getConfigKey().substring(CONFIG_KEY_PREFIX.length());
+            try {
+                Long project = Long.valueOf(projectStr);
+                CourseRandomRedPacketConfig config = getConfigByProject(project);
+                if (config != null) {
+                    result.add(config);
+                }
+            } catch (NumberFormatException e) {
+                logger.warn("忽略无效的课程红包配置键 configKey={}", sysConfig.getConfigKey());
+            }
+        }
+        return result;
+    }
+
+    private void doSaveConfig(CourseRandomRedPacketConfig config) {
+        String configKey = buildConfigKey(config.getProject());
+        String json = JSONUtil.toJsonStr(config);
+        SysConfig existing = configService.selectConfigByConfigKey(configKey);
+        if (existing != null) {
+            existing.setConfigValue(json);
+            configService.updateConfig(existing);
+            return;
+        }
+        SysConfig sysConfig = new SysConfig();
+        sysConfig.setConfigName("课程红包配置-" + config.getProject());
+        sysConfig.setConfigKey(configKey);
+        sysConfig.setConfigValue(json);
+        sysConfig.setConfigType("N");
+        configService.insertConfig(sysConfig);
+    }
+
+    private CourseRandomRedPacketConfig buildConfigFromBatchParam(CourseRandomRedPacketBatchSaveParam param) {
+        CourseRandomRedPacketConfig config = new CourseRandomRedPacketConfig();
+        config.setProject(param.getProjects().get(0));
+        config.setEnableRandom(param.getEnableRandom());
+        config.setCompanyWhitelist(param.getCompanyWhitelist());
+        config.setTiers(param.getTiers());
+        return config;
+    }
+
+    private CourseRandomRedPacketConfig copyConfigFields(CourseRandomRedPacketConfig source) {
+        CourseRandomRedPacketConfig config = new CourseRandomRedPacketConfig();
+        config.setEnableRandom(source.getEnableRandom());
+        config.setCompanyWhitelist(source.getCompanyWhitelist());
+        config.setTiers(source.getTiers());
+        return config;
+    }
+
+    private void validateConfig(CourseRandomRedPacketConfig config) {
+        if (config == null || config.getProject() == null) {
+            throw new IllegalArgumentException("课程项目不能为空");
+        }
+        if (!Boolean.TRUE.equals(config.getEnableRandom()) || CollectionUtils.isEmpty(config.getTiers())) {
+            return;
+        }
+        int totalWeight = config.getTiers().stream()
+                .filter(t -> t.getWeight() != null && t.getWeight() > 0)
+                .mapToInt(CourseRandomRedPacketTier::getWeight)
+                .sum();
+        if (totalWeight != 100) {
+            throw new IllegalArgumentException("权重总和必须为100%");
+        }
+    }
+
+    @Override
+    public BigDecimal resolveAmount(BigDecimal baseAmount, Long courseId, Long companyId,
+                                    FsUserCourseVideo video, boolean allowLegacyRandom) {
+        BigDecimal safeBase = baseAmount == null ? BigDecimal.ZERO : baseAmount;
+        try {
+            Long project = resolveProject(courseId, video);
+            if (project == null) {
+                return fallbackAmount(safeBase, video, allowLegacyRandom);
+            }
+            CourseRandomRedPacketConfig config = getConfigByProject(project);
+            if (config != null && Boolean.TRUE.equals(config.getEnableRandom())
+                    && CollectionUtils.isNotEmpty(config.getTiers())) {
+                if (isCompanyInWhitelist(config.getCompanyWhitelist(), companyId)) {
+                    BigDecimal randomAmount = pickAmountByTiers(config.getTiers());
+                    if (randomAmount.compareTo(BigDecimal.ZERO) > 0) {
+                        logger.info("课程随机红包生效 project={}, courseId={}, companyId={}, amount={}",
+                                project, courseId, companyId, randomAmount);
+                        return randomAmount;
+                    }
+                }
+                return safeBase;
+            }
+            return fallbackAmount(safeBase, video, allowLegacyRandom);
+        } catch (Exception e) {
+            logger.error("红包金额计算异常,使用基础金额兜底 courseId={}, companyId={}, baseAmount={}",
+                    courseId, companyId, safeBase, e);
+            return safeBase;
+        }
+    }
+
+    private BigDecimal fallbackAmount(BigDecimal safeBase, FsUserCourseVideo video, boolean allowLegacyRandom) {
+        if (allowLegacyRandom && video != null) {
+            return applyLegacyRandomRedPacket(safeBase, video);
+        }
+        return safeBase;
+    }
+
+    /**
+     * 通过 courseId 或 video.courseId 查询 fs_user_course.project
+     */
+    private Long resolveProject(Long courseId, FsUserCourseVideo video) {
+        Long targetCourseId = courseId;
+        if (targetCourseId == null && video != null) {
+            targetCourseId = video.getCourseId();
+        }
+        if (targetCourseId == null) {
+            return null;
+        }
+        FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(targetCourseId);
+        return course == null ? null : course.getProject();
+    }
+
+    private String buildConfigKey(Long project) {
+        return CONFIG_KEY_PREFIX + project;
+    }
+
+    private boolean isCompanyInWhitelist(String whitelist, Long companyId) {
+        if (StringUtils.isBlank(whitelist) || "ALL".equalsIgnoreCase(whitelist.trim())) {
+            return true;
+        }
+        if (companyId == null) {
+            return false;
+        }
+        String companyIdStr = String.valueOf(companyId);
+        for (String item : whitelist.split(",")) {
+            if (companyIdStr.equals(item.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private BigDecimal pickAmountByTiers(List<CourseRandomRedPacketTier> tiers) {
+        int totalWeight = tiers.stream()
+                .filter(t -> t.getWeight() != null && t.getWeight() > 0 && t.getAmount() != null)
+                .mapToInt(CourseRandomRedPacketTier::getWeight)
+                .sum();
+        if (totalWeight <= 0) {
+            return BigDecimal.ZERO;
+        }
+        int randomPoint = new Random().nextInt(totalWeight);
+        int currentWeight = 0;
+        for (CourseRandomRedPacketTier tier : tiers) {
+            if (tier.getWeight() == null || tier.getWeight() <= 0 || tier.getAmount() == null) {
+                continue;
+            }
+            currentWeight += tier.getWeight();
+            if (randomPoint < currentWeight) {
+                return tier.getAmount().setScale(2, RoundingMode.HALF_UP);
+            }
+        }
+        return tiers.get(0).getAmount().setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private BigDecimal applyLegacyRandomRedPacket(BigDecimal baseAmount, FsUserCourseVideo video) {
+        String json = configService.selectConfigByKey("randomRedpacket:config");
+        if (StringUtils.isBlank(json)) {
+            return baseAmount;
+        }
+        RandomRedPacketConfig randomRedPacket = JSONUtil.toBean(json, RandomRedPacketConfig.class);
+        if (randomRedPacket == null || !Boolean.TRUE.equals(randomRedPacket.getEnableRandomRedpacket())) {
+            return baseAmount;
+        }
+        BigDecimal randomMoney = BigDecimal.ZERO;
+        String randomRedPacketRules = video.getRandomRedPacketRules();
+        if (StringUtils.isNotBlank(randomRedPacketRules)) {
+            JSONArray array = JSONObject.parseArray(randomRedPacketRules);
+            List<RandomRedPacketRule> rules = new ArrayList<>();
+            for (Object o : array) {
+                rules.add(JSONObject.toJavaObject((JSONObject) o, RandomRedPacketRule.class));
+            }
+            randomMoney = getRandomMoneyByLegacyRules(rules);
+        } else if (CollectionUtils.isNotEmpty(randomRedPacket.getRules())) {
+            randomMoney = getRandomMoneyByLegacyRules(randomRedPacket.getRules());
+        }
+        if (randomMoney.compareTo(BigDecimal.ZERO) > 0) {
+            return randomMoney;
+        }
+        return baseAmount;
+    }
+
+    private BigDecimal getRandomMoneyByLegacyRules(List<RandomRedPacketRule> rules) {
+        try {
+            if (CollectionUtils.isEmpty(rules)) {
+                return BigDecimal.ZERO;
+            }
+            int totalWeight = rules.stream()
+                    .mapToInt(RandomRedPacketRule::getWeight)
+                    .sum();
+            if (totalWeight <= 0) {
+                return BigDecimal.ZERO;
+            }
+            Random random = new Random();
+            int randomPoint = random.nextInt(totalWeight);
+            RandomRedPacketRule selectedRule = null;
+            int currentWeight = 0;
+            for (RandomRedPacketRule rule : rules) {
+                currentWeight += rule.getWeight();
+                if (randomPoint < currentWeight) {
+                    selectedRule = rule;
+                    break;
+                }
+            }
+            if (selectedRule == null) {
+                selectedRule = rules.get(0);
+            }
+            BigDecimal maxAmount = selectedRule.getMaxAmount();
+            BigDecimal minAmount = selectedRule.getMinAmount();
+            if (maxAmount.compareTo(minAmount) == 0) {
+                return maxAmount;
+            }
+            BigDecimal range = maxAmount.subtract(minAmount);
+            BigDecimal randomValue = range.multiply(BigDecimal.valueOf(random.nextDouble()));
+            return minAmount.add(randomValue).setScale(2, RoundingMode.HALF_UP);
+        } catch (Exception e) {
+            logger.error("旧版红包规则计算异常", e);
+            return BigDecimal.ZERO;
+        }
+    }
+}

+ 31 - 65
fs-service/src/main/java/com/fs/course/service/impl/CourseRepeatByProjectServiceImpl.java

@@ -3,7 +3,6 @@ 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;
@@ -12,6 +11,7 @@ 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.course.service.IUserProjectRepeatMaintainService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.qw.domain.QwExternalContact;
@@ -43,13 +43,13 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
     @Autowired
     private IFsUserCompanyBindService fsUserCompanyBindService;
     @Autowired
-    private FsCourseWatchLogMapper courseWatchLogMapper;
-    @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
     @Autowired
     private IQwExternalContactService qwExternalContactService;
     @Autowired
     private FsUserMapper fsUserMapper;
+    @Autowired
+    private IUserProjectRepeatMaintainService userProjectRepeatMaintainService;
 
     @Override
     public void checkAndMarkRepeatByProject(FsUserCourseVideoAddKfUParam param, FsUser fsUser) {
@@ -59,7 +59,9 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
             return;
         }
         List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
-        boolean projectRepeat = isRepeatWatchByProject(param, equivalentProjectIds);
+        Long currentQwUserId = parseQwUserId(param.getQwUserId());
+        boolean projectRepeat = userProjectRepeatMaintainService.wouldBeRepeatAfterBind(
+                param.getUserId(), projectId, currentQwUserId);
         boolean userAlreadyRepeat = fsUser.getIsRepeat() != null && fsUser.getIsRepeat() == 1;
         log.info("看课重粉判断(按项目):userId={}, projectId={}, equivalentProjectIds={}, projectRepeat={}, userAlreadyRepeat={}",
                 param.getUserId(), projectId, equivalentProjectIds, projectRepeat, userAlreadyRepeat);
@@ -91,67 +93,6 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         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());
-            }
-        });
-        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;
@@ -164,6 +105,8 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
     }
 
     private void markRepeatFansByProject(FsUser fsUser, FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
+        syncProjectRepeatTable(param, equivalentProjectIds);
+
         List<QwExternalContact> toUpdate = new ArrayList<>();
         if (param.getQwExternalId() != null) {
             QwExternalContact current = qwExternalContactMapper.selectById(param.getQwExternalId());
@@ -210,6 +153,26 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
         }
     }
 
+    /** 将本次及已有绑定销售同步写入中间表,保证 fs_user_project_repeat 与判重结果一致 */
+    private void syncProjectRepeatTable(FsUserCourseVideoAddKfUParam param, List<Long> equivalentProjectIds) {
+        Long currentQwUserId = parseQwUserId(param.getQwUserId());
+        Long projectId = equivalentProjectIds.isEmpty() ? null : equivalentProjectIds.get(0);
+        if (currentQwUserId != null && projectId != null) {
+            userProjectRepeatMaintainService.maintain(
+                    param.getUserId(), projectId, currentQwUserId, param.getCompanyId());
+        }
+        List<FsUserCompanyBind> binds = fsUserCompanyBindService.list(
+                new QueryWrapper<FsUserCompanyBind>().eq("fs_user_id", param.getUserId()).in("project_id", equivalentProjectIds));
+        if (CollectionUtils.isNotEmpty(binds)) {
+            for (FsUserCompanyBind bind : binds) {
+                if (bind.getQwUserId() != null && bind.getProjectId() != null) {
+                    userProjectRepeatMaintainService.maintain(
+                            bind.getFsUserId(), bind.getProjectId(), bind.getQwUserId(), bind.getCompanyId());
+                }
+            }
+        }
+    }
+
     private void recordUserProjectBind(FsUserCourseVideoAddKfUParam param, Long qwExternalId, String sceneName) {
         try {
             FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
@@ -238,6 +201,9 @@ public class CourseRepeatByProjectServiceImpl implements ICourseRepeatByProjectS
                     param.getVideoId()
             );
 
+            userProjectRepeatMaintainService.maintain(
+                    param.getUserId(), projectId, qwUserId, param.getCompanyId());
+
             log.info("【{}-记录绑定关系成功】用户ID: {}, 项目ID: {}, 销售ID: {}, 外部联系人id: {}",
                     sceneName, param.getUserId(), projectId, qwUserId, qwExternalId);
 

+ 9 - 2
fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java

@@ -832,6 +832,13 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
 
     }
 
+    private String normalizeWxUrlLink(String urlLink) {
+        if (StringUtils.isBlank(urlLink)) {
+            return urlLink;
+        }
+        return urlLink.replace("wxmpurl.cn", "wxaurl.cn");
+    }
+
     /**
      * 获取跳转微信小程序的链接地址
      * @param linkStr
@@ -890,7 +897,7 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
                 }
 
                 if(!jsonObject.isEmpty() && jsonObject.containsKey("url_link")){
-                    return jsonObject.getString("url_link");
+                    return normalizeWxUrlLink(jsonObject.getString("url_link"));
                 }
             } else {
                 return "页面链接错误,获取失败";
@@ -960,7 +967,7 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
 
 
                             if(null != jsonObject && !jsonObject.isEmpty() && jsonObject.containsKey("url_link")){
-                                return R.ok().put("urlLink",jsonObject.getString("url_link")) ;
+                                return R.ok().put("urlLink", normalizeWxUrlLink(jsonObject.getString("url_link")));
                             }else {
                                 return R.error("获取失败:"+jsonObject.getString("errmsg"));
                             }

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

@@ -413,7 +413,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             }
             logs.add(watchLog);
         }
-        batchUpdateFsUserCourseWatchLog(logs, 100);
+        batchUpdateFsUserCourseWatchLog(logs, 500);
     }
 
     @Override
@@ -598,7 +598,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 log.error("处理heartbeat key {} 时发生异常: {}", key, e.getMessage());
             }
         }
-        batchUpdateFsUserCourseWatchLog(logs, 100);
+        batchUpdateFsUserCourseWatchLog(logs, 500);
     }
 
     @Override
@@ -675,7 +675,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             logs.add(watchLog);
         }
 
-        batchUpdateFsCourseWatchLogIsOpen(logs, 100);
+        batchUpdateFsCourseWatchLogIsOpen(logs, 500);
     }
 
     public void batchUpdateFsCourseWatchLogIsOpen(List<FsCourseWatchLog> logs, int batchSize) {
@@ -976,7 +976,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             executorService.shutdown();
 
             // 批量更新到数据库
-            batchUpdateFsCourseWatchLog(logs, 100);
+            batchUpdateFsCourseWatchLog(logs, 500);
 
         } catch (Exception e) {
             log.error("定时任务执行失败 scheduleBatchUpdateToDatabase", e);
@@ -1112,7 +1112,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             executorService.shutdown();
 
             // 批量更新到数据库
-            batchUpdateFsCourseWatchLog(logs, 100);
+            batchUpdateFsCourseWatchLog(logs, 500);
 
         } catch (Exception e) {
             log.error("定时任务执行失败checkWatchStatus", e);

+ 46 - 38
fs-service/src/main/java/com/fs/course/service/impl/FsUserCompanyBindServiceImpl.java

@@ -3,6 +3,7 @@ package com.fs.course.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.DictUtils;
 import com.fs.common.utils.PubFun;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
@@ -15,10 +16,12 @@ 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.service.IUserProjectRepeatMaintainService;
 import com.fs.course.support.CourseProjectEquivalence;
 import com.fs.course.vo.CourseProgressResultVO;
 import com.fs.course.vo.RelatedSalesResultVO;
 import com.fs.course.vo.RepeatCourseHistoryVO;
+import com.fs.course.vo.RepeatProjectResultVO;
 import com.fs.course.vo.UserWatchLogListVo;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.qw.domain.QwCompany;
@@ -37,12 +40,9 @@ 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;
+import java.util.TreeSet;
 
 /**
  * 用户客服关联Service业务层处理
@@ -56,6 +56,7 @@ import java.util.stream.Collectors;
 public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindMapper, FsUserCompanyBind> implements IFsUserCompanyBindService {
 
     private final CompanyUserMapper companyUserMapper;
+    private final IUserProjectRepeatMaintainService userProjectRepeatMaintainService;
     private FsUserMapper fsUserMapper;
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
     private QwExternalContactMapper qwExternalContactMapper;
@@ -166,7 +167,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
                 }
             }
             if (qwExternalContact.getUserRepeat() == 0 && project != 0
-                    && isRepeatWatchByProject(fsUserId, project, qwUserId)) {
+                    && userProjectRepeatMaintainService.isRepeatByProject(fsUserId, project)) {
                 qwExternalContact.setUserRepeat(1);
                 qwExternalContactMapper.updateById(qwExternalContact);
             }
@@ -198,6 +199,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
                 }
                 try {
                     save(bind);
+                    userProjectRepeatMaintainService.maintain(fsUserId, project, qwUserId, companyId);
                 } catch (Exception e) {
                     log.error("添加重粉失败", e);
                 }
@@ -239,6 +241,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             one.setUpdateTime(new Date());
             try {
                 save(one);
+                userProjectRepeatMaintainService.maintain(fsUserId, course.getProject(), qwUserId, one.getCompanyId());
             } catch (Exception e) {
                 log.error("添加重粉失败", e);
             }
@@ -254,6 +257,10 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
         if(param.getExternalUserId() == null &&  param.getFsUserId() == null){
             return Collections.emptyList();
         }
+        if (param.getProjectId() != null && param.getProjectId() != 0) {
+            param.setEquivalentProjectIds(
+                    CourseProjectEquivalence.equivalentProjectIds(param.getProjectId().longValue()));
+        }
         List<UserWatchLogListVo> list = baseMapper.getWatchLogList(param);
         // 权限判断:如果传了companyId,对无权限的主体脱敏
         if (param.getCompanyId() != null && list != null) {
@@ -289,7 +296,8 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             vo.setAvatar(contact.getAvatar());
             vo.setRemark(contact.getRemark());
         }
-        vo.setUserRepeat(isUserRepeatByProject(fsUserId) ? 1 : 0);
+        vo.setUserRepeat(userProjectRepeatMaintainService.isRepeatInAnyProject(fsUserId) ? 1 : 0);
+        vo.setProjectList(buildUserProjectList(fsUserId));
 
         // 2. 查询关联销售(单条SQL + GROUP BY 优化)
         List<RelatedSalesResultVO> salesResults = baseMapper.getRelatedSalesByFsUserId(fsUserId);
@@ -318,6 +326,7 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
             RepeatCourseHistoryVO.CourseProgressVO cp = new RepeatCourseHistoryVO.CourseProgressVO();
             cp.setCourseId(c.getCourseId());
             cp.setCourseName(c.getCourseName());
+            cp.setProjectId(c.getProjectId());
             cp.setTotalCount(c.getTotalCount() != null ? c.getTotalCount() : 0);
             cp.setWatchedCount(c.getWatchedCount() != null ? c.getWatchedCount() : 0);
             // 计算百分比
@@ -367,41 +376,40 @@ public class FsUserCompanyBindServiceImpl extends ServiceImpl<FsUserCompanyBindM
     }
 
     /**
-     * 按项目判重:同一项目(含等价项目)下存在至少两个不同企微销售则视为看课重粉
+     * 构建客户企微发课看过的项目列表:等价项目(如百膳食养/安康食养)分项展示,重粉项目标记 isRepeat=1
      */
-    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;
+    private List<RepeatCourseHistoryVO.RepeatProjectVO> buildUserProjectList(Long fsUserId) {
+        List<RepeatProjectResultVO> watched = baseMapper.getUserWatchedProjectsByFsUserId(fsUserId);
+        Map<Long, String> nameMap = new HashMap<>();
+        TreeSet<Long> projectIds = new TreeSet<>();
+        if (watched != null) {
+            for (RepeatProjectResultVO item : watched) {
+                if (item.getProjectId() == null) {
+                    continue;
+                }
+                projectIds.add(item.getProjectId());
+                if (item.getProjectName() != null) {
+                    nameMap.put(item.getProjectId(), item.getProjectName());
+                }
+            }
         }
-        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());
+        // 等价项目组 1/28:只要看过其中任一,两个项目名都展示
+        if (projectIds.contains(1L) || projectIds.contains(28L)) {
+            projectIds.add(1L);
+            projectIds.add(28L);
         }
-        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);
+        List<RepeatCourseHistoryVO.RepeatProjectVO> list = new ArrayList<>();
+        for (Long projectId : projectIds) {
+            RepeatCourseHistoryVO.RepeatProjectVO vo = new RepeatCourseHistoryVO.RepeatProjectVO();
+            vo.setProjectId(projectId);
+            String projectName = nameMap.get(projectId);
+            if (projectName == null) {
+                projectName = DictUtils.getDictLabel("sys_course_project", String.valueOf(projectId));
+            }
+            vo.setProjectName(projectName);
+            vo.setIsRepeat(userProjectRepeatMaintainService.isRepeatByProject(fsUserId, projectId) ? 1 : 0);
+            list.add(vo);
         }
-        return qwUserIds.size() >= 2;
+        return list;
     }
 }

+ 61 - 108
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -2,7 +2,6 @@ package com.fs.course.service.impl;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@@ -32,8 +31,6 @@ import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyService;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.config.CourseConfig;
-import com.fs.course.config.RandomRedPacketConfig;
-import com.fs.course.config.RandomRedPacketRule;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePeriodSyncResultDTO;
 import com.fs.course.dto.CoursePackageDTO;
@@ -193,6 +190,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private ISysConfigService configService;
     @Autowired
+    private ICourseRandomRedPacketService courseRandomRedPacketService;
+    @Autowired
     private QwApiService qwApiService;
     @Autowired
     private QwUserMapper qwUserMapper;
@@ -363,13 +362,26 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             }
         }
 
-        String videoRedisKey1 = "h5user:video:duration:" + fsUserCourseVideo.getVideoId();
-        redisCache.setCacheObject(videoRedisKey1, fsUserCourseVideo.getDuration());
-        String videoRedisKey2 = "h5wxuser:video:duration:" + fsUserCourseVideo.getVideoId();
-        redisCache.setCacheObject(videoRedisKey2, fsUserCourseVideo.getDuration());
+        syncVideoDurationCache(fsUserCourseVideo.getVideoId(), fsUserCourseVideo.getDuration());
         return fsUserCourseVideoMapper.updateFsUserCourseVideo(fsUserCourseVideo);
     }
 
+    @Override
+    public void syncVideoDurationCache(Long videoId, Long duration) {
+        if (videoId == null) {
+            return;
+        }
+        String videoRedisKey1 = "h5user:video:duration:" + videoId;
+        String videoRedisKey2 = "h5wxuser:video:duration:" + videoId;
+        if (duration == null) {
+            redisCache.deleteObject(videoRedisKey1);
+            redisCache.deleteObject(videoRedisKey2);
+            return;
+        }
+        redisCache.setCacheObject(videoRedisKey1, duration);
+        redisCache.setCacheObject(videoRedisKey2, duration);
+    }
+
     @Override
     public int updateFsUserCourseRedPage(FsUserCourseRedPageParam userCourseRedPageParam) {
 
@@ -801,10 +813,6 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         if (fsUser == null  ||  fsUser.getCreateTime().before(targetDate) ) {
             return R.error(ExceptionCodeEnum.USER_NOT_FOUND.getCode(), ExceptionCodeEnum.USER_NOT_FOUND.getDescription());
         }
-
-        if (fsUser.getStatus() == 0) {
-            return R.error(ExceptionCodeEnum.MEMBER_DISABLED.getCode(), ExceptionCodeEnum.MEMBER_DISABLED.getDescription());
-        }
 //        if (param.getIsOpenCourse()!=null&&param.getIsOpenCourse()==1){
 //            FsCourseWatchLog log = courseWatchLogMapper.getWatchCourseVideoByUserId(param.getUserId(), param.getVideoId());
 //            if (log==null){
@@ -922,7 +930,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
         if (isRoom == null || isRoom == 0) {
             // 当 isRoom 为 null 或 0 时走 handleExt
-            return handleExt(param,noMemberMsg, oneCompanyCourse);
+            return handleExt(param, fsUser, noMemberMsg, oneCompanyCourse);
         } else if (isRoom == 1) {
             // 当 isRoom 为 1 时走 handleRoom
             return handleRoom(param,fsUser,noRegisterMsg);
@@ -950,8 +958,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         QwExternalContact qwExternalContact =  qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
                             .eq("user_id", qwGroupChat.getOwner())
                             .eq("fs_user_id", param.getUserId())
-                            .eq("corp_id", param.getCorpId())
-                            .eq("status",0));
+                            .eq("corp_id", param.getCorpId()));
 
         if(null == qwExternalContact){
             try{
@@ -960,8 +967,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                         qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
                                 .eq("user_id", qwGroupChat.getOwner())
                                 .eq("name", user.getNickName())
-                                .eq("corp_id", param.getCorpId())
-                                .eq("status",0));
+                                .eq("corp_id", param.getCorpId()));
             } catch(Exception e){
                 log.error("群聊用户昵称匹配异常,参数user_id:{},name:{},corp_id:{}",qwGroupChat.getOwner(),user.getNickName(),param.getCorpId(),e);
             }
@@ -969,6 +975,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         if(qwExternalContact==null){
             return R.error(noRegisterMsg);
         }
+        Integer contactStatus = qwExternalContact.getStatus();
+        if (contactStatus == null || !Arrays.asList(0, 1, 2).contains(contactStatus)) {
+            return R.error("当前学习通道访问限流,暂时无法观看!");
+        }
         QwExternalContact finalQwExternalContact = qwExternalContact;
         if (qwGroupChatUsers.stream().noneMatch(e -> e.getUserId().equals(finalQwExternalContact.getExternalUserId()))) {
             log.error("客户不在群:{},里面:{}", qwGroupChat.getChatId(), qwExternalContact.getExternalUserId());
@@ -1028,6 +1038,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
 //        // 记录用户-项目-销售绑定关系
 //        recordUserProjectBind(param, qwExternalId, "handleQwRoom");
+        R disabledResult = checkMemberDisabledAfterBind(user);
+        if (disabledResult != null) {
+            return disabledResult;
+        }
         return R.error(567, "群聊通用链接").put("qwExternalId", qwExternalContact.getId());
     }
     private R handleRoom(FsUserCourseVideoAddKfUParam param,FsUser user,String noRegisterMsg) {
@@ -1144,7 +1158,14 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         courseWatchLogMapper.insertFsCourseWatchLog(log);
     }
 
-    private R handleExt(FsUserCourseVideoAddKfUParam param, String msg, boolean oneCompanyCourse) {
+    private R checkMemberDisabledAfterBind(FsUser fsUser) {
+        if (fsUser != null && fsUser.getStatus() != null && fsUser.getStatus() == 0) {
+            return R.error(ExceptionCodeEnum.MEMBER_DISABLED.getCode(), ExceptionCodeEnum.MEMBER_DISABLED.getDescription());
+        }
+        return null;
+    }
+
+    private R handleExt(FsUserCourseVideoAddKfUParam param, FsUser fsUser, String msg, boolean oneCompanyCourse) {
         //暂时注释应急短链逻辑
 //        if (param.getLinkType()!=null&&param.getLinkType()==1){
 //            if (param.getLinkId()!=null){
@@ -1234,6 +1255,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             // 记录用户-项目-销售绑定关系
 //            recordUserProjectBind(param, qwExternalId, "handleExt");
 
+            R disabledResult = checkMemberDisabledAfterBind(fsUser);
+            if (disabledResult != null) {
+                return disabledResult;
+            }
             if (param.getLinkType() != null && param.getLinkType() == 5) {
                 FsCourseLink fsCourseLink = fsCourseLinkMapper.selectExpireLinkByQwExternalId(param.getQwUserId(), param.getVideoId(), qwExternalId);
                 return R.error(566, "官方群发通用链接").put("courseLink", fsCourseLink);
@@ -1274,7 +1299,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             // 记录用户-项目-销售绑定关系
 //            recordUserProjectBind(param, qwExternalId, "handleExt");
 
-
+            R disabledResult = checkMemberDisabledAfterBind(fsUser);
+            if (disabledResult != null) {
+                return disabledResult;
+            }
             if (param.getLinkType() != null && param.getLinkType() == 5) {
                 FsCourseLink fsCourseLink = fsCourseLinkMapper.selectExpireLinkByQwExternalId(param.getQwUserId(), param.getVideoId(), qwExternalId);
                 return R.error(566, "官方群发通用链接").put("courseLink", fsCourseLink);
@@ -1560,7 +1588,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 //        }
             log.info("查询会员信息:{}", user);
             if (user.getStatus() == 0) {
-                return R.error("会员被停用,无权限,请联系客服!");
+                return R.error("当前学习通道访问限流,暂时无法观看!");
             }
 
             if (StringUtil.strIsNullOrEmpty(user.getMpOpenId()) && !"北京存在文化".equals(signProjectName)){
@@ -1825,33 +1853,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             amount = redPackage.getRedPacketMoney();
         } else if (video != null) {
             amount = video.getRedPacketMoney();
-            //是否开启了随机红包
-            String json = configService.selectConfigByKey("randomRedpacket:config");
-            if (StringUtils.isNotBlank(json)) {
-                RandomRedPacketConfig randomRedPacket = JSONUtil.toBean(json, RandomRedPacketConfig.class);
-                //是否开启拼手气红包
-                if (null != randomRedPacket && randomRedPacket.getEnableRandomRedpacket()) {
-                    BigDecimal randomMoney = BigDecimal.ZERO;
-                    //优先读取课程配置随机红包规则
-                    String randomRedPacketRules = video.getRandomRedPacketRules();
-                    if (StringUtils.isNotBlank(randomRedPacketRules)) {
-                        JSONArray array = JSONObject.parseArray(randomRedPacketRules);
-                        List<RandomRedPacketRule> rules = new ArrayList<>();
-                        for (Object o : array) {
-                            rules.add(JSONObject.toJavaObject((JSONObject) o, RandomRedPacketRule.class));
-                        }
-                        randomMoney = getRandomMoneyByRules(rules);
-                    }
-                    //如果课程没有配置 读取后台默认随机规则
-                    else {
-                        randomMoney = getRandomMoneyByRules(randomRedPacket.getRules());
-                    }
-                    //兼容拼手气红包报错情况的发放红包情况
-                    if (BigDecimal.ZERO.compareTo(randomMoney) < 0) {
-                        amount = randomMoney;
-                    }
-                }
-            }
+            amount = courseRandomRedPacketService.resolveAmount(amount, param.getCourseId(), param.getCompanyId(), video, true);
         }
 
         // 准备发送红包参数
@@ -2197,6 +2199,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         } else if (video != null && video.getRedPacketMoney() != null) {
             amount = video.getRedPacketMoney();
         }
+        amount = courseRandomRedPacketService.resolveAmount(amount, param.getCourseId(), param.getCompanyId(), video, false);
 
         // 准备发送红包参数
         WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
@@ -3962,14 +3965,20 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     }
 
     @Override
-    public AjaxResult updateVideoByVideoUrl(String videoUrl,String oldVideoUrl, String thumbnail,String fileName, Integer duration) {
+    public AjaxResult updateVideoByVideoUrl(String videoUrl, String oldVideoUrl, String thumbnail, String fileName, Integer duration, String lineOne, String lineTwo) {
         List<FsUserCourseVideo> videoList = fsUserCourseVideoMapper.selectByVideoUrl(oldVideoUrl);
         if (CollectionUtils.isEmpty(videoList)){
             log.warn("根据videoUrl:{} 未查询到fs_user_course_video表数据",oldVideoUrl);
             return AjaxResult.success();
         }
         List<Long> idList = videoList.stream().map(FsUserCourseVideo::getVideoId).collect(Collectors.toList());
-        fsUserCourseVideoMapper.updateVideoByVideoUrl(videoUrl,thumbnail, idList,fileName,duration);
+        fsUserCourseVideoMapper.updateVideoByVideoUrl(videoUrl, thumbnail, idList, fileName, duration, lineOne, lineTwo);
+        if (duration != null) {
+            Long durationValue = duration.longValue();
+            for (Long videoId : idList) {
+                syncVideoDurationCache(videoId, durationValue);
+            }
+        }
         return AjaxResult.success();
     }
     @Override
@@ -4084,7 +4093,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
 
         if (fsUser.getStatus() == 0) {
-            return R.error("会员被停用,无权限,请联系客服!");
+            return R.error("当前学习通道访问限流,暂时无法观看!");
         }
         return createWatchIsOpen(param);
     }
@@ -4165,7 +4174,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         // 获取用户信息
         FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
         if (user == null) {
-            return R.error("会员被停用,无权限,请联系客服!");
+            return R.error("当前学习通道访问限流,暂时无法观看!");
         }
         FsCourseWatchLog log = courseWatchLogMapper.getWatchCourseVideo(param.getUserId(), param.getVideoId(), param.getQwUserId(), param.getQwExternalId());
         if (log == null) {
@@ -4247,7 +4256,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
 
         if (fsUser.getStatus() != null && fsUser.getStatus() == 0) {
-            return R.error("会员被停用,无权限,请联系客服!");
+            return R.error("当前学习通道访问限流,暂时无法观看!");
         }
 
         // 处理群聊逻辑
@@ -4469,63 +4478,6 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         return R.ok();
     }
 
-    /**
-     * 根据匹配规则返回随机红包值(需求是概率独立计算)
-     *
-     * @param rules
-     * @return
-     */
-    private BigDecimal getRandomMoneyByRules(List<RandomRedPacketRule> rules) {
-        try {
-
-            if (CollectionUtils.isEmpty(rules)) {
-                logger.error("随机红包规则列表为空,不予处理");
-//            throw new ServiceException("随机红包规则列表为空");
-            }
-            // 计算总权重
-            int totalWeight = rules.stream()
-                    .mapToInt(RandomRedPacketRule::getWeight)
-                    .sum();
-            // 根据权重随机选择规则
-            Random random = new Random();
-            int randomPoint = random.nextInt(totalWeight);
-
-            RandomRedPacketRule selectedRule = null;
-            int currentWeight = 0;
-
-            for (RandomRedPacketRule rule : rules) {
-                currentWeight += rule.getWeight();
-                if (randomPoint < currentWeight) {
-                    selectedRule = rule;
-                    break;
-                }
-            }
-
-            // 如果因为计算精度问题没有选中规则,默认选第一个
-            if (selectedRule == null) {
-                selectedRule = rules.get(0);
-            }
-            // 在选中规则的范围内生成随机金额
-            BigDecimal maxAmount = selectedRule.getMaxAmount();
-            BigDecimal minAmount = selectedRule.getMinAmount();
-            // 如果最大最小金额相等,直接返回该金额
-            if (maxAmount.compareTo(minAmount) == 0) {
-                return maxAmount;
-            }
-            // 生成随机金额
-            BigDecimal range = maxAmount.subtract(minAmount);
-            BigDecimal randomValue = range.multiply(BigDecimal.valueOf(random.nextDouble()));
-            BigDecimal result = minAmount.add(randomValue);
-            // 保留两位小数,四舍五入
-            return result.setScale(2, RoundingMode.HALF_UP);
-        } catch (Exception e) {
-            logger.error("获取拼手气红包金额异常:{}", rules, e);
-        }
-
-        return BigDecimal.ZERO;
-    }
-
-
     @Override
     public R uploadVideoToHuoShanByUrl() {
         List <FsVideoResource> videos = fsUserCourseVideoMapper.selectVideoByHuaWei();
@@ -5142,6 +5094,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         } else if (video != null && video.getRedPacketMoney() != null) {
             amount = video.getRedPacketMoney();
         }
+        amount = courseRandomRedPacketService.resolveAmount(amount, log.getCourseId(), log.getCompanyId(), video, false);
         packetParam.setAmount(amount);
 
         if (amount.compareTo(BigDecimal.ZERO) > 0) {

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

@@ -5,7 +5,6 @@ import java.util.concurrent.TimeUnit;
 
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
-import lombok.Synchronized;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
@@ -102,38 +101,43 @@ public class FsUserVideoFavoriteServiceImpl implements IFsUserVideoFavoriteServi
         return fsUserVideoFavoriteMapper.deleteFsUserVideoFavoriteByFavoriteId(favoriteId);
     }
     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;
+        String key = favoriteKey(videoId, userId);
         Boolean hasFavorite = redisTemplate.opsForValue().get(key);
-        if (hasFavorite != null && hasFavorite) {
+        if (Boolean.TRUE.equals(hasFavorite)) {
             return R.ok().put("isFavorite", 1);
-        } else {
-            return fsUserVideoFavoriteMapper.checkFavorite(videoId, userId) > 0
-                    ? R.ok().put("isFavorite", 1)
-                    : R.error().put("isFavorite", 0);
         }
+        if (Boolean.FALSE.equals(hasFavorite)) {
+            return R.error().put("isFavorite", 0);
+        }
+        return fsUserVideoFavoriteMapper.checkFavorite(videoId, userId) > 0
+                ? R.ok().put("isFavorite", 1)
+                : R.error().put("isFavorite", 0);
     }
 
     @Override
     @Transactional
-    @Synchronized
     public void favoriteVideo(Long videoId, long userId) {
-        String unlikeKey = NO_FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.delete(unlikeKey);
-        String key = FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.opsForValue().set(key, true, 1, TimeUnit.DAYS);
+        if (fsUserVideoFavoriteMapper.checkFavorite(videoId, userId) == 0) {
+            FsUserVideoFavorite favorite = new FsUserVideoFavorite();
+            favorite.setVideoId(videoId);
+            favorite.setUserId(userId);
+            favorite.setCreateTime(DateUtils.getNowDate());
+            fsUserVideoFavoriteMapper.insertFsUserVideoFavorite(favorite);
+        }
+        redisTemplate.opsForValue().set(favoriteKey(videoId, userId), true, 1, TimeUnit.DAYS);
     }
 
     @Override
     @Transactional
-    @Synchronized
     public void deleteFavorite(Long videoId, long userId) {
-        String key = FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.delete(key);
-        String unlikeKey = NO_FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.opsForValue().set(unlikeKey, true, 1, TimeUnit.DAYS);
+        fsUserVideoFavoriteMapper.deleteFavorite(videoId, userId);
+        redisTemplate.opsForValue().set(favoriteKey(videoId, userId), false, 1, TimeUnit.DAYS);
+    }
+
+    private static String favoriteKey(Long videoId, long userId) {
+        return FAVORITE_KEY_PREFIX + videoId + ":user:" + userId;
     }
 }

+ 22 - 18
fs-service/src/main/java/com/fs/course/service/impl/FsUserVideoLikeServiceImpl.java

@@ -5,7 +5,6 @@ import java.util.concurrent.TimeUnit;
 
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
-import lombok.Synchronized;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
@@ -104,38 +103,43 @@ 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;
+        String key = likeKey(videoId, userId);
         Boolean hasLiked = redisTemplate.opsForValue().get(key);
-        if (hasLiked != null && hasLiked) {
+        if (Boolean.TRUE.equals(hasLiked)) {
             return R.ok().put("isLike", 1);
-        } else {
-            return fsUserVideoLikeMapper.checkLike(videoId, userId) > 0
-                    ? R.ok().put("isLike", 1)
-                    : R.error().put("isLike", 0);
         }
+        if (Boolean.FALSE.equals(hasLiked)) {
+            return R.error().put("isLike", 0);
+        }
+        return fsUserVideoLikeMapper.checkLike(videoId, userId) > 0
+                ? R.ok().put("isLike", 1)
+                : R.error().put("isLike", 0);
     }
 
     @Override
     @Transactional
-    @Synchronized
     public void likeVideo(Long videoId, long userId) {
-        String unlikeKey = UNLIKE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.delete(unlikeKey);
-        String key = LIKE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.opsForValue().set(key, true, 1, TimeUnit.DAYS);
+        if (fsUserVideoLikeMapper.checkLike(videoId, userId) == 0) {
+            FsUserVideoLike like = new FsUserVideoLike();
+            like.setVideoId(videoId);
+            like.setUserId(userId);
+            like.setCreateTime(DateUtils.getNowDate());
+            fsUserVideoLikeMapper.insertFsUserVideoLike(like);
+        }
+        redisTemplate.opsForValue().set(likeKey(videoId, userId), true, 1, TimeUnit.DAYS);
     }
 
     @Override
     @Transactional
-    @Synchronized
     public void unlikeVideo(Long videoId, long userId) {
-        String key = LIKE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.delete(key);
-        String unlikeKey = UNLIKE_KEY_PREFIX + videoId + ":user:" + userId;
-        redisTemplate.opsForValue().set(unlikeKey, true, 1, TimeUnit.DAYS);
+        fsUserVideoLikeMapper.deleteLike(videoId, userId);
+        redisTemplate.opsForValue().set(likeKey(videoId, userId), false, 1, TimeUnit.DAYS);
+    }
+
+    private static String likeKey(Long videoId, long userId) {
+        return LIKE_KEY_PREFIX + videoId + ":user:" + userId;
     }
 }

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

@@ -346,6 +346,13 @@ public class FsUserVideoServiceImpl implements IFsUserVideoService {
                 int favoriteAddNum = 0;
                 int sharesAddNum = 0;
                 String addNum = vo.getAddNum();
+                if (StringUtils.isBlank(addNum)) {
+                    FsUserVideo video = fsUserVideoMapper.selectFsUserVideoByVideoId(vo.getId());
+                    if (video != null && StringUtils.isNotBlank(video.getAddNum())) {
+                        addNum = video.getAddNum();
+                        vo.setAddNum(addNum);
+                    }
+                }
                 Random random = new Random();
                 if (StringUtils.isNotBlank(addNum)) {
                     List<String> addNums = Arrays.asList(addNum.split(","));

+ 144 - 0
fs-service/src/main/java/com/fs/course/service/impl/UserProjectRepeatMaintainServiceImpl.java

@@ -0,0 +1,144 @@
+package com.fs.course.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.course.domain.FsUserProjectQw;
+import com.fs.course.domain.FsUserProjectRepeat;
+import com.fs.course.mapper.FsUserProjectQwMapper;
+import com.fs.course.mapper.FsUserProjectRepeatMapper;
+import com.fs.course.service.IUserProjectRepeatMaintainService;
+import com.fs.course.support.CourseProjectEquivalence;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Slf4j
+@Service
+public class UserProjectRepeatMaintainServiceImpl implements IUserProjectRepeatMaintainService {
+
+    @Autowired
+    private FsUserProjectQwMapper fsUserProjectQwMapper;
+
+    @Autowired
+    private FsUserProjectRepeatMapper fsUserProjectRepeatMapper;
+
+    @Override
+    public void maintain(Long fsUserId, Long projectId, Long qwUserId, Long companyId) {
+        if (fsUserId == null || projectId == null || projectId == 0L || qwUserId == null) {
+            return;
+        }
+        try {
+            fsUserProjectQwMapper.insertOrIgnore(fsUserId, projectId, qwUserId, companyId);
+
+            List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+            Long canonicalProjectId = CourseProjectEquivalence.canonicalProjectId(projectId);
+            int qwUserCount = fsUserProjectQwMapper.countDistinctQwUser(fsUserId, equivalentProjectIds);
+            int isRepeat = qwUserCount >= 2 ? 1 : 0;
+
+            fsUserProjectRepeatMapper.upsert(fsUserId, canonicalProjectId, companyId, qwUserCount, isRepeat);
+        } catch (Exception e) {
+            log.error("维护项目重粉中间表失败 fsUserId={}, projectId={}, qwUserId={}",
+                    fsUserId, projectId, qwUserId, e);
+        }
+    }
+
+    @Override
+    public boolean isRepeatByProject(Long fsUserId, Long projectId) {
+        if (fsUserId == null || projectId == null || projectId == 0L) {
+            return false;
+        }
+        Long canonicalProjectId = CourseProjectEquivalence.canonicalProjectId(projectId);
+        FsUserProjectRepeat record = fsUserProjectRepeatMapper.selectOne(
+                new QueryWrapper<FsUserProjectRepeat>()
+                        .eq("fs_user_id", fsUserId)
+                        .eq("project_id", canonicalProjectId)
+                        .last("LIMIT 1"));
+        return record != null && record.getIsRepeat() != null && record.getIsRepeat() == 1;
+    }
+
+    @Override
+    public boolean isRepeatInAnyProject(Long fsUserId) {
+        if (fsUserId == null) {
+            return false;
+        }
+        Integer count = fsUserProjectRepeatMapper.selectCount(
+                new QueryWrapper<FsUserProjectRepeat>()
+                        .eq("fs_user_id", fsUserId)
+                        .eq("is_repeat", 1));
+        return count != null && count > 0;
+    }
+
+    @Override
+    public boolean wouldBeRepeatAfterBind(Long fsUserId, Long projectId, Long qwUserId) {
+        if (fsUserId == null || projectId == null || projectId == 0L) {
+            return false;
+        }
+        if (isRepeatByProject(fsUserId, projectId)) {
+            return true;
+        }
+        if (qwUserId == null) {
+            return false;
+        }
+        List<Long> equivalentProjectIds = CourseProjectEquivalence.equivalentProjectIds(projectId);
+        int distinctCount = fsUserProjectQwMapper.countDistinctQwUser(fsUserId, equivalentProjectIds);
+        if (distinctCount >= 2) {
+            return true;
+        }
+        if (distinctCount == 0) {
+            return false;
+        }
+        return fsUserProjectQwMapper.existsQwUserInProjects(fsUserId, qwUserId, equivalentProjectIds) == 0;
+    }
+
+    @Override
+    public void migrateQwUserOnTransfer(Long fsUserId, Long oldQwUserId, Long newQwUserId, Long companyId) {
+        if (fsUserId == null || oldQwUserId == null || newQwUserId == null || oldQwUserId.equals(newQwUserId)) {
+            return;
+        }
+        try {
+            List<FsUserProjectQw> oldRows = fsUserProjectQwMapper.selectList(
+                    new QueryWrapper<FsUserProjectQw>()
+                            .eq("fs_user_id", fsUserId)
+                            .eq("qw_user_id", oldQwUserId));
+            if (oldRows.isEmpty()) {
+                return;
+            }
+
+            Set<Long> projectsToRefresh = new HashSet<>();
+            for (FsUserProjectQw row : oldRows) {
+                projectsToRefresh.add(row.getProjectId());
+                FsUserProjectQw existingNew = fsUserProjectQwMapper.selectOne(
+                        new QueryWrapper<FsUserProjectQw>()
+                                .eq("fs_user_id", fsUserId)
+                                .eq("project_id", row.getProjectId())
+                                .eq("qw_user_id", newQwUserId)
+                                .last("LIMIT 1"));
+                if (existingNew != null) {
+                    fsUserProjectQwMapper.deleteById(row.getId());
+                } else {
+                    FsUserProjectQw update = new FsUserProjectQw();
+                    update.setId(row.getId());
+                    update.setQwUserId(newQwUserId);
+                    update.setUpdateTime(new Date());
+                    if (companyId != null) {
+                        update.setCompanyId(companyId);
+                    }
+                    fsUserProjectQwMapper.updateById(update);
+                }
+            }
+
+            for (Long projectId : projectsToRefresh) {
+                maintain(fsUserId, projectId, newQwUserId, companyId);
+            }
+            log.info("转接迁移重粉中间表成功 fsUserId={}, oldQwUserId={}, newQwUserId={}, projects={}",
+                    fsUserId, oldQwUserId, newQwUserId, projectsToRefresh);
+        } catch (Exception e) {
+            log.error("转接迁移重粉中间表失败 fsUserId={}, oldQwUserId={}, newQwUserId={}",
+                    fsUserId, oldQwUserId, newQwUserId, e);
+        }
+    }
+}

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

@@ -31,4 +31,15 @@ public final class CourseProjectEquivalence {
     public static boolean isInProjectGroup1And28(Long projectId) {
         return projectId != null && (projectId.equals(1L) || projectId.equals(28L));
     }
+
+    /**
+     * 等价项目组的 canonical ID(取最小组内 ID,用于 fs_user_project_repeat 唯一键)。
+     */
+    public static Long canonicalProjectId(Long projectId) {
+        List<Long> ids = equivalentProjectIds(projectId);
+        if (ids.isEmpty()) {
+            return projectId;
+        }
+        return ids.stream().min(Long::compareTo).orElse(projectId);
+    }
 }

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

@@ -14,6 +14,8 @@ public class CourseProgressResultVO {
     private Long courseId;
     /** 课程名称 */
     private String courseName;
+    /** 课程所属项目ID */
+    private Integer projectId;
     /** 课程总节数 */
     private Integer totalCount;
     /** 已完课节数 */

+ 18 - 0
fs-service/src/main/java/com/fs/course/vo/RepeatCourseHistoryVO.java

@@ -21,12 +21,28 @@ public class RepeatCourseHistoryVO {
     /** 重粉状态 0正常 1重粉 */
     private Integer userRepeat;
 
+    /** 客户企微发课看过的项目列表(send_type=2;等价项目如百膳食养/安康食养分别展示,重粉项目 isRepeat=1) */
+    private List<RepeatProjectVO> projectList;
+
     /** 关联销售列表 */
     private List<RelatedSalesVO> salesList;
 
     /** 课程学习进度列表 */
     private List<CourseProgressVO> courseList;
 
+    /**
+     * 客户企微发课看过的项目(含等价项目分项展示)
+     */
+    @Data
+    public static class RepeatProjectVO {
+        /** 项目ID */
+        private Long projectId;
+        /** 项目名称 */
+        private String projectName;
+        /** 是否该项目下重粉 0否 1是 */
+        private Integer isRepeat;
+    }
+
     /**
      * 关联销售信息
      */
@@ -54,6 +70,8 @@ public class RepeatCourseHistoryVO {
         private Long courseId;
         /** 课程名称 */
         private String courseName;
+        /** 课程所属项目ID */
+        private Integer projectId;
         /** 课程总节数 */
         private Integer totalCount;
         /** 已学习节数(有看课记录的小节数) */

+ 14 - 0
fs-service/src/main/java/com/fs/course/vo/RepeatProjectResultVO.java

@@ -0,0 +1,14 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+
+/**
+ * 重粉看课历史 - 客户看过的项目查询结果VO(Mapper用)
+ */
+@Data
+public class RepeatProjectResultVO {
+    /** 项目ID */
+    private Long projectId;
+    /** 项目名称 */
+    private String projectName;
+}

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

@@ -17,7 +17,7 @@ public enum ExceptionCodeEnum {
     CONTACT_SALES_FOR_INVITATION(443, "请联系销售发送邀请链接成为会员!"),
 
     // ============ 会员关系错误 (460-479) ============
-    MEMBER_DISABLED(461, "会员被停用,无权限,请联系客服!"),
+    MEMBER_DISABLED(461, "当前学习通道访问限流,暂时无法观看!"),
     USER_ALREADY_OTHER_SALES_MEMBER(462, "该用户(%s)已成为其他销售会员"),
     USER_COMPANY_OTHER_SALES_MEMBER(463, "该用户(%s)已在公司%s成为其他销售会员"),
 

+ 9 - 0
fs-service/src/main/java/com/fs/his/domain/FsUser.java

@@ -66,6 +66,15 @@ public class FsUser extends BaseEntity
     @Excel(name = "1为正常,0为禁止")
     private Integer status;
 
+    /** 管理状态:1正常 2已被总部拉黑 */
+    @ApiModelProperty("管理状态:1正常 2已被总部拉黑")
+    private Integer manageStatus;
+
+    /** 管理备注 */
+    @ApiModelProperty("管理备注")
+    @Excel(name = "管理备注")
+    private String manageRemark;
+
     /** 红包领取开关:1 可领取(默认),0 关闭后不允许领取红包 */
     @ApiModelProperty("红包领取:1开启 0关闭")
     private Integer redStatus;

+ 8 - 2
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -1,6 +1,7 @@
 package com.fs.his.mapper;
 
 import java.math.BigDecimal;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 
@@ -43,6 +44,11 @@ public interface FsUserMapper
      */
     public FsUser selectFsUserByUserId(Long userId);
 
+    /**
+     * 查询已被管理员拉黑的会员ID
+     */
+    List<Long> selectBlacklistedUserIds(@Param("userIds") Collection<Long> userIds);
+
     /**
      * 查询用户列表
      *
@@ -471,13 +477,13 @@ public interface FsUserMapper
     List<FsUser> selectFsUserListByPhone(String phone);
 
     /**
-     * 查询被误封禁的用户(status=0且remark为null)
+     * 查询被误封禁的用户(status=0且manage_status=1且remark为null)
      * @return 用户列表
      */
     List<FsUser> selectMistakenlyDisabledUsers();
 
     /**
-     * 自动解除被误封禁的用户(status=0且remark为null)
+     * 自动解除被误封禁的用户(status=0且manage_status=1且remark为null)
      * 将status改为1,remark设置为"自动解除封禁"
      * @return 更新的记录数
      */

+ 1 - 1
fs-service/src/main/java/com/fs/his/service/IFsUserService.java

@@ -206,7 +206,7 @@ public interface IFsUserService
     List<FsUser> selectUserByIds(String userIds);
 
     /**
-     * 自动解除被误封禁的用户(status=0且remark为null)
+     * 自动解除被误封禁的用户(status=0且manage_status=1且remark为null)
      * @return 解除封禁的用户数量
      */
     int autoUnbanMistakenlyDisabledUsers();

+ 8 - 6
fs-service/src/main/java/com/fs/his/service/impl/FsUserServiceImpl.java

@@ -266,10 +266,12 @@ public class FsUserServiceImpl implements IFsUserService {
         } else {
             fsUser.setPhone(null);
         }
-        if (ObjectUtils.isNotEmpty(fsUser.getLevel())&&fsUser.getLevel().equals(1)){
-            fsUser.setIsShow(1);
-        }else {
-            fsUser.setIsShow(0);
+        if (fsUser.getLevel() != null) {
+            if (fsUser.getLevel().equals(1)) {
+                fsUser.setIsShow(1);
+            } else {
+                fsUser.setIsShow(0);
+            }
         }
         return fsUserMapper.updateFsUser(fsUser);
     }
@@ -1626,13 +1628,13 @@ public class FsUserServiceImpl implements IFsUserService {
     }
 
     /**
-     * 自动解除被误封禁的用户(status=0且remark为null)
+     * 自动解除被误封禁的用户(status=0且manage_status=1且remark为null)
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public int autoUnbanMistakenlyDisabledUsers() {
         try {
-            // 查询所有status=0且remark为null的用户
+            // 查询所有status=0且manage_status=1且remark为null的用户
             List<FsUser> disabledUsers = fsUserMapper.selectMistakenlyDisabledUsers();
 
             if (disabledUsers == null || disabledUsers.isEmpty()) {

+ 4 - 0
fs-service/src/main/java/com/fs/his/vo/FsUserVO.java

@@ -156,6 +156,10 @@ public class FsUserVO extends FsUser implements Serializable
     @Excel(name = "状态", sort = 7)
     private String statusName;
 
+    @ApiModelProperty(value = "管理状态名称")
+    @Excel(name = "管理状态", sort = 13)
+    private String manageStatusName;
+
     @ApiModelProperty(value = "项目ID")
     private Long projectId;
 

+ 30 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductTagScrm.java

@@ -0,0 +1,30 @@
+package com.fs.hisStore.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 商品标签对象 fs_store_product_tag_scrm
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class FsStoreProductTagScrm extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    @Excel(name = "店铺ID")
+    private Long storeId;
+
+    @Excel(name = "标签名称")
+    private String tagName;
+
+    @Excel(name = "状态", readConverterExp = "1=显示,0=隐藏")
+    private Integer status;
+
+    @Excel(name = "排序")
+    private Long sort;
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductUserEndCategory.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 商品与用户分端类关联表 fs_store_product_user_end_category
+ */
+@Data
+public class FsStoreProductUserEndCategory implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 用户分端类ID */
+    private Long userEndCategoryId;
+
+    /** 在该用户分端类下的排序(仅关联表,非商品表 sort) */
+    private Long sort;
+}

+ 43 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreUserEndCategoryScrm.java

@@ -0,0 +1,43 @@
+package com.fs.hisStore.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 商品用户分端类(金刚区/瀑布流)对象 fs_store_user_end_category
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class FsStoreUserEndCategoryScrm extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 店铺ID */
+    @Excel(name = "店铺ID")
+    private Long storeId;
+
+    /** 分类名称 */
+    @Excel(name = "分类名称")
+    private String categoryName;
+
+    /** 分类位置:1-金刚区 2-瀑布流 */
+    @Excel(name = "分类位置", readConverterExp = "1=金刚区,2=瀑布流")
+    private Integer position;
+
+    /** 分类icon(金刚区必填,瀑布流不填) */
+    @Excel(name = "分类icon")
+    private String icon;
+
+    /** 状态:1-显示 0-隐藏 */
+    @Excel(name = "状态", readConverterExp = "1=显示,0=隐藏")
+    private Integer status;
+
+    /** 排序值,越小越靠前;用户端各位置最多展示前8个 */
+    @Excel(name = "排序")
+    private Long sort;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStoreProductSortItemDTO.java

@@ -0,0 +1,18 @@
+package com.fs.hisStore.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 批量更新商品排序项(用户端分类关联商品列表用)
+ */
+@Data
+public class FsStoreProductSortItemDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    /** 排序值,0~9999 */
+    private Long sort;
+}

+ 1 - 1
fs-service/src/main/java/com/fs/hisStore/mapper/FsStorePaymentScrmMapper.java

@@ -372,7 +372,7 @@ public interface FsStorePaymentScrmMapper
     List<FsStorePaymentScrm> selectFsStorePaymentByOrderId(Long orderId);
 
     /** App 发货(business_type=8),不对接小程序/ERP,仅本地订单发货 */
-    @Select("select * from fs_store_payment_scrm where (business_type=2 or business_type=8 or business_type=9 ) and order_id=#{orderId} and status=1   ")
+    @Select("select * from fs_store_payment_scrm where (business_type=8 or business_type=9 ) and order_id=#{orderId} and status=1   ")
     List<FsStorePaymentScrm> selectFsStorePaymentByOrderIdBusinessType8(Long orderId);
 
     @Select("select * from fs_store_payment_scrm where (business_type=2 or business_type=8 or business_type=9 ) and order_id=#{orderId} and status=1   ")

+ 8 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductScrmMapper.java

@@ -1,5 +1,6 @@
 package com.fs.hisStore.mapper;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -417,6 +418,9 @@ public interface FsStoreProductScrmMapper
 
     List<FsStoreProductScrm> getStoreProductInProductIds(List<Long> productIds);
 
+    /** 按商品ID列表查询,仅返回上线且未删除的商品(is_del=0, is_show=1),用于 App 列表 */
+    List<FsStoreProductScrm> getStoreProductInProductIdsForApp(@Param("productIds") List<Long> productIds);
+
     @Select({"<script> " +
             "SELECT distinct fsp.* FROM fs_store_product_scrm fsp " +
             " left join fs_store_product_attr_value_scrm fspav on fsp.product_id = fspav.product_id  " +
@@ -432,4 +436,8 @@ public interface FsStoreProductScrmMapper
     List<FsStoreProductListVO> liveList(@Param("maps") LiveGoods maps);
 
     List<FsStoreProductScrm> selectFsStoreProductByProductIds(@Param("productIds") List<Long> productIdsLong);
+
+    List<FsStoreProductListQueryVO> selectFsStoreProductNewQueryPage(Map<String, Object> params);
+
+    List<FsStoreProductListQueryVO> selectFsStoreProductHotQueryPage(Map<String, Object> params);
 }

+ 27 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagRelationScrmMapper.java

@@ -0,0 +1,27 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.vo.FsStoreProductTagNameVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品-标签关联 Mapper(一个商品最多3个标签)
+ */
+public interface FsStoreProductTagRelationScrmMapper {
+
+    int insertBatch(@Param("productId") Long productId, @Param("tagIds") List<Long> tagIds);
+
+    int deleteByProductId(Long productId);
+
+    List<Long> selectTagIdsByProductId(Long productId);
+
+    /** 统计某标签关联的商品数量 */
+    int countByTagId(Long tagId);
+
+    /** 按标签ID删除关联(删除标签时调用) */
+    int deleteByTagIds(@Param("tagIds") Long[] tagIds);
+
+    /** 按商品ID列表批量查询商品对应的标签名称(关联 fs_store_product_tag_scrm) */
+    List<FsStoreProductTagNameVO> selectProductTagNamesByProductIds(@Param("productIds") List<Long> productIds);
+}

+ 28 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductTagScrmMapper.java

@@ -0,0 +1,28 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreProductTagScrm;
+import com.fs.hisStore.vo.FsStoreProductTagListVO;
+import com.fs.hisStore.vo.FsStoreTagProductVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * ÉÌÆ·±êÇ© Mapper
+ */
+public interface FsStoreProductTagScrmMapper {
+
+    FsStoreProductTagScrm selectById(Long id);
+
+    List<FsStoreProductTagListVO> selectListVO(FsStoreProductTagScrm query);
+
+    int insert(FsStoreProductTagScrm entity);
+
+    int update(FsStoreProductTagScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+
+    List<FsStoreTagProductVO> selectProductsByTagId(@Param("tagId") Long tagId);
+}

+ 47 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductUserEndCategoryMapper.java

@@ -0,0 +1,47 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreProductUserEndCategory;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品-用户分端类关联 Mapper
+ */
+public interface FsStoreProductUserEndCategoryMapper {
+
+    int insertBatch(@Param("productId") Long productId, @Param("categoryIds") List<Long> categoryIds);
+
+    int deleteByProductId(Long productId);
+
+    List<Long> selectCategoryIdsByProductId(Long productId);
+
+    /** 按用户端分类ID删除关联(删除分类时调用) */
+    int deleteByCategoryIds(@Param("categoryIds") Long[] categoryIds);
+
+    /** 按用户端分类ID查询去重后的商品ID列表(用于分页,配合 PageHelper) */
+    List<Long> selectDistinctProductIdsByCategoryId(@Param("categoryId") Long categoryId, @Param("keyword") String keyword);
+
+    /** 关联表内全部去重商品ID(不按分类,配合 PageHelper 用于「全部」) */
+    List<Long> selectDistinctProductIds(@Param("keyword") String keyword);
+
+    /** 按区域位置查询商品ID:1=金刚区 2=瀑布区,配合 keyword 筛选,支持 storeId */
+    List<Long> selectDistinctProductIdsByPosition(@Param("id") Long id,@Param("storeId") Long storeId, @Param("position") Integer position, @Param("keyword") String keyword);
+
+    /** 更新指定分类下某商品的关联排序 */
+    int updateRelSortByCategoryAndProduct(@Param("userEndCategoryId") Long userEndCategoryId,
+                                          @Param("productId") Long productId,
+                                          @Param("sort") long sort);
+
+    /** 指定分类下,批量查询商品与关联 sort(管理端/App 指定分类列表用) */
+    List<FsStoreProductUserEndCategory> selectSortByCategoryAndProductIds(@Param("userEndCategoryId") Long userEndCategoryId,
+                                                                          @Param("productIds") List<Long> productIds);
+
+    /** 按金刚区/瀑布区聚合:同一商品多条关联时取最大 sort(App 未指定分类时列表展示用) */
+    List<FsStoreProductUserEndCategory> selectAggSortByPositionAndProductIds(@Param("storeId") Long storeId,
+                                                                              @Param("position") Integer position,
+                                                                              @Param("productIds") List<Long> productIds);
+
+    /** 全部分类关联聚合:同一商品多条关联时取最大 sort(App「全部」列表展示用) */
+    List<FsStoreProductUserEndCategory> selectAggSortGlobalAndProductIds(@Param("productIds") List<Long> productIds);
+}

+ 30 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreUserEndCategoryScrmMapper.java

@@ -0,0 +1,30 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类 Mapper
+ */
+public interface FsStoreUserEndCategoryScrmMapper {
+
+    FsStoreUserEndCategoryScrm selectById(Long id);
+
+    List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query);
+
+    int insert(FsStoreUserEndCategoryScrm entity);
+
+    int update(FsStoreUserEndCategoryScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+
+    /** 用于添加商品时选择:按 position 分组,各取前8条(status=1),排序:sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectListForProduct(@Param("storeId") Long storeId);
+
+    /** 用户端展示:按位置取前8条,status=1,排序 sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(@Param("storeId") Long storeId, @Param("position") Integer position);
+}

+ 6 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStoreProductAddEditParam.java

@@ -302,4 +302,10 @@ public class FsStoreProductAddEditParam implements Serializable
     /** 所属小程序app_id,多个用逗号隔开 */
     private String appIds;
 
+    /** 用户端分类ID列表(金刚区/瀑布流多选) */
+    private List<Long> userEndCategoryIds;
+
+    /** 商品标签ID列表(最多3个) */
+    private List<Long> tagIds;
+
 }

+ 10 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductScrmService.java

@@ -153,4 +153,14 @@ public interface IFsStoreProductScrmService
     R copyStoreProduct(Long productId);
 
     R updateCache(Long productId);
+
+    List<Long> selectTagIdsByProductId(Long productId);
+
+    List<Long> selectUserEndCategoryIdsByProductId(Long productId);
+
+    /** 绿色有机分页(与 selectFsStoreProductNewQuery 条件一致,支持分页) */
+    List<FsStoreProductListQueryVO> selectFsStoreProductNewQueryPage(int pageNum, int pageSize, String appId, String keyword);
+
+    /** 上新推荐分页(与 selectFsStoreProductHotQuery 条件一致,支持分页) */
+    List<FsStoreProductListQueryVO> selectFsStoreProductHotQueryPage(int pageNum, int pageSize, String appId, String keyword);
 }

+ 29 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductTagScrmService.java

@@ -0,0 +1,29 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStoreProductTagScrm;
+import com.fs.hisStore.vo.FsStoreProductTagListVO;
+import com.fs.hisStore.vo.FsStoreTagProductVO;
+
+import java.util.List;
+
+/**
+ * ÉÌÆ·±êÇ© Service ½Ó¿Ú
+ */
+public interface IFsStoreProductTagScrmService {
+
+    FsStoreProductTagScrm selectById(Long id);
+
+    List<FsStoreProductTagListVO> selectListVO(FsStoreProductTagScrm query);
+
+    List<FsStoreProductTagListVO> selectListVOPage(FsStoreProductTagScrm query);
+
+    List<FsStoreTagProductVO> selectProductsByTagId(Long tagId);
+
+    int insert(FsStoreProductTagScrm entity);
+
+    int update(FsStoreProductTagScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+}

+ 43 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreUserEndCategoryScrmService.java

@@ -0,0 +1,43 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+
+import java.util.List;
+
+/**
+ * 商品用户分端类 Service 接口
+ */
+public interface IFsStoreUserEndCategoryScrmService {
+
+    FsStoreUserEndCategoryScrm selectById(Long id);
+
+    List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query);
+
+    /** 分页列表:一页10条,排序值相同按创建时间倒序 */
+    List<FsStoreUserEndCategoryScrm> selectListPage(FsStoreUserEndCategoryScrm query);
+
+    /** 添加商品时选择:按金刚区/瀑布流分组,各最多8条(仅展示用由前端截取) */
+    List<FsStoreUserEndCategoryScrm> selectListForProduct(Long storeId);
+
+    /** 用户端首页:按位置(1金刚区 2瀑布流)取前8条,status=1,排序 sort asc, create_time desc */
+    List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(Long storeId, Integer position);
+
+    /** 按用户端分类ID分页查询关联商品(去重商品ID分页,再查商品简表+标签并组装) */
+    List<FsStoreUserEndCategoryProductVO> listProductsByCategoryId(Long categoryId, String keyword);
+
+    /** 批量更新关联排序(写入 fs_store_product_user_end_category.sort,需指定用户分端类 id) */
+    int saveProductsSort(Long userEndCategoryId, List<FsStoreProductSortItemDTO> items);
+
+    /** 首页商品列表:id 为空查全部(分页商品ID后查简表+标签),id 不为空按用户端分类查;position=1金刚区/2瀑布区 时按区域查全部;返回 list+total */
+    java.util.Map<String, Object> listProductsForApp(Long id, String keyword, Integer pageNum, Integer pageSize, Long storeId, Integer position);
+
+    int insert(FsStoreUserEndCategoryScrm entity);
+
+    int update(FsStoreUserEndCategoryScrm entity);
+
+    int deleteById(Long id);
+
+    int deleteByIds(Long[] ids);
+}

+ 2 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -1196,6 +1196,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 item.setOrderCode(orderSn);
                 item.setCartId(vo.getId());
                 item.setProductId(vo.getProductId());
+                item.setProductAttrValueId(vo.getProductAttrValueId());
                 item.setJsonInfo(JSONUtil.toJsonStr(fsStoreCartDTO));
                 item.setNum(vo.getCartNum());
                 item.setIsAfterSales(0);
@@ -1958,6 +1959,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 item.setOrderCode(orderSn);
                 item.setCartId(vo.getId());
                 item.setProductId(vo.getProductId());
+                item.setProductAttrValueId(vo.getProductAttrValueId());
                 item.setJsonInfo(JSONUtil.toJsonStr(fsStoreCartDTO));
                 item.setNum(vo.getCartNum());
                 item.setIsAfterSales(0);

+ 65 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductScrmServiceImpl.java

@@ -166,6 +166,12 @@ public class FsStoreProductScrmServiceImpl implements IFsStoreProductScrmService
     @Autowired
     private RedisCacheT<FsStoreProductScrm> redisCacheT;
 
+    @Autowired
+    private FsStoreProductTagRelationScrmMapper fsStoreProductTagRelationScrmMapper;
+
+    @Autowired
+    private FsStoreProductUserEndCategoryMapper fsStoreProductUserEndCategoryMapper;
+
     /**
      * 清除商品详情缓存
      * @param productId 商品ID
@@ -267,6 +273,14 @@ public class FsStoreProductScrmServiceImpl implements IFsStoreProductScrmService
     {
         storeAuditLogUtil.addBatchAuditArray(productIds, "", "");
         log.info("批量删除商品:{}", productIds);
+        if (productIds != null) {
+            for (Long pid : productIds) {
+                if (pid != null) {
+                    fsStoreProductUserEndCategoryMapper.deleteByProductId(pid);
+                    fsStoreProductTagRelationScrmMapper.deleteByProductId(pid);
+                }
+            }
+        }
         int result = fsStoreProductMapper.deleteFsStoreProductByIds(productIds);
 
         // 清除缓存
@@ -527,6 +541,10 @@ public class FsStoreProductScrmServiceImpl implements IFsStoreProductScrmService
     @Override
     public int deleteFsStoreProductById(Long productId)
     {
+        if (productId != null) {
+            fsStoreProductUserEndCategoryMapper.deleteByProductId(productId);
+            fsStoreProductTagRelationScrmMapper.deleteByProductId(productId);
+        }
         int result = fsStoreProductMapper.deleteFsStoreProductById(productId);
         // 清除缓存
         clearProductDetailCache(productId);
@@ -784,6 +802,17 @@ public class FsStoreProductScrmServiceImpl implements IFsStoreProductScrmService
         } else {
             addProductAttr(product.getProductId(),param.getItems(),param.getValues());
         }
+        fsStoreProductUserEndCategoryMapper.deleteByProductId(product.getProductId());
+        if (param.getUserEndCategoryIds() != null && !param.getUserEndCategoryIds().isEmpty()) {
+            fsStoreProductUserEndCategoryMapper.insertBatch(product.getProductId(), param.getUserEndCategoryIds());
+        }
+        fsStoreProductTagRelationScrmMapper.deleteByProductId(product.getProductId());
+        if (param.getTagIds() != null && !param.getTagIds().isEmpty()) {
+            if (param.getTagIds().size() > 3) {
+                throw new ServiceException("商品标签最多选择3个");
+            }
+            fsStoreProductTagRelationScrmMapper.insertBatch(product.getProductId(), param.getTagIds());
+        }
         // 数据修改缓存
         if (product.getProductId() != null) {
             FsStoreProductScrm cacheProduct = fsStoreProductMapper.selectFsStoreProductById(product.getProductId());
@@ -1720,4 +1749,40 @@ public class FsStoreProductScrmServiceImpl implements IFsStoreProductScrmService
         }
         return R.ok();
     }
+
+    @Override
+    public List<Long> selectTagIdsByProductId(Long productId) {
+        if (productId == null) {
+            return Collections.emptyList();
+        }
+        return fsStoreProductTagRelationScrmMapper.selectTagIdsByProductId(productId);
+    }
+
+    @Override
+    public List<Long> selectUserEndCategoryIdsByProductId(Long productId) {
+        if (productId == null) {
+            return Collections.emptyList();
+        }
+        return fsStoreProductUserEndCategoryMapper.selectCategoryIdsByProductId(productId);
+    }
+
+    @Override
+    public List<FsStoreProductListQueryVO> selectFsStoreProductNewQueryPage(int pageNum, int pageSize, String appId, String keyword) {
+        HashMap<String, Object> map = new HashMap<>();
+        map.put("config", medicalMallConfig);
+        map.put("appId", appId);
+        map.put("keyword", keyword);
+        com.github.pagehelper.PageHelper.startPage(pageNum, pageSize);
+        return fsStoreProductMapper.selectFsStoreProductNewQueryPage(map);
+    }
+
+    @Override
+    public List<FsStoreProductListQueryVO> selectFsStoreProductHotQueryPage(int pageNum, int pageSize, String appId, String keyword) {
+        HashMap<String, Object> map = new HashMap<>();
+        map.put("config", medicalMallConfig);
+        map.put("appId", appId);
+        map.put("keyword", keyword);
+        com.github.pagehelper.PageHelper.startPage(pageNum, pageSize);
+        return fsStoreProductMapper.selectFsStoreProductHotQueryPage(map);
+    }
 }

+ 81 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductTagScrmServiceImpl.java

@@ -0,0 +1,81 @@
+package com.fs.hisStore.service.impl;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.DateUtils;
+import com.fs.hisStore.domain.FsStoreProductTagScrm;
+import com.fs.hisStore.mapper.FsStoreProductTagRelationScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductTagScrmMapper;
+import com.fs.hisStore.service.IFsStoreProductTagScrmService;
+import com.fs.hisStore.vo.FsStoreProductTagListVO;
+import com.fs.hisStore.vo.FsStoreTagProductVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 商品标签 Service 实现
+ */
+@Service
+public class FsStoreProductTagScrmServiceImpl implements IFsStoreProductTagScrmService {
+
+    @Autowired
+    private FsStoreProductTagScrmMapper mapper;
+
+    @Autowired
+    private FsStoreProductTagRelationScrmMapper productTagRelationMapper;
+
+    @Override
+    public FsStoreProductTagScrm selectById(Long id) {
+        return mapper.selectById(id);
+    }
+
+    @Override
+    public List<FsStoreProductTagListVO> selectListVO(FsStoreProductTagScrm query) {
+        return mapper.selectListVO(query);
+    }
+
+    @Override
+    public List<FsStoreProductTagListVO> selectListVOPage(FsStoreProductTagScrm query) {
+        return mapper.selectListVO(query);
+    }
+
+    @Override
+    public List<FsStoreTagProductVO> selectProductsByTagId(Long tagId) {
+        return mapper.selectProductsByTagId(tagId);
+    }
+
+    @Override
+    public int insert(FsStoreProductTagScrm entity) {
+        if (entity.getTagName() != null && entity.getTagName().length() > 4) {
+            throw new ServiceException("标签名称最多4个字");
+        }
+        entity.setCreateTime(DateUtils.getNowDate());
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.insert(entity);
+    }
+
+    @Override
+    public int update(FsStoreProductTagScrm entity) {
+        if (entity.getTagName() != null && entity.getTagName().length() > 4) {
+            throw new ServiceException("标签名称最多4个字");
+        }
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.update(entity);
+    }
+
+    @Override
+    public int deleteById(Long id) {
+        productTagRelationMapper.deleteByTagIds(new Long[]{id});
+        return mapper.deleteById(id);
+    }
+
+    @Override
+    public int deleteByIds(Long[] ids) {
+        if (ids == null || ids.length == 0) {
+            return 0;
+        }
+        productTagRelationMapper.deleteByTagIds(ids);
+        return mapper.deleteByIds(ids);
+    }
+}

+ 295 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreUserEndCategoryScrmServiceImpl.java

@@ -0,0 +1,295 @@
+package com.fs.hisStore.service.impl;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.DateUtils;
+import com.fs.hisStore.domain.FsStoreProductScrm;
+import com.fs.hisStore.domain.FsStoreProductUserEndCategory;
+import com.fs.hisStore.domain.FsStoreUserEndCategoryScrm;
+import com.fs.hisStore.dto.FsStoreProductSortItemDTO;
+import com.fs.hisStore.mapper.FsStoreProductScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductTagRelationScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductUserEndCategoryMapper;
+import com.fs.hisStore.mapper.FsStoreUserEndCategoryScrmMapper;
+import com.fs.hisStore.service.IFsStoreUserEndCategoryScrmService;
+import com.fs.hisStore.vo.FsStoreProductTagNameVO;
+import com.fs.hisStore.vo.FsStoreUserEndCategoryProductVO;
+import com.github.pagehelper.Page;
+import com.github.pagehelper.PageHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Collectors;
+
+/**
+ * 商品用户分端类 Service 实现
+ */
+@Service
+public class FsStoreUserEndCategoryScrmServiceImpl implements IFsStoreUserEndCategoryScrmService {
+
+    @Autowired
+    private FsStoreUserEndCategoryScrmMapper mapper;
+
+    @Autowired
+    private FsStoreProductUserEndCategoryMapper productUserEndCategoryMapper;
+
+    @Autowired
+    private FsStoreProductScrmMapper fsStoreProductScrmMapper;
+
+    @Autowired
+    private FsStoreProductTagRelationScrmMapper productTagRelationMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /** Redis key 用于存储固定的好评率随机值 */
+    private static final String POSITIVE_RATING_REDIS_KEY = "product:positiveRating:fixed:";
+    private static final double MIN_RATING = 95.0;
+    private static final double MAX_RATING = 99.9;
+
+    @Override
+    public List<FsStoreUserEndCategoryProductVO> listProductsByCategoryId(Long categoryId, String keyword) {
+        if (categoryId == null) return new ArrayList<>();
+        List<Long> productIds = productUserEndCategoryMapper.selectDistinctProductIdsByCategoryId(categoryId, keyword);
+        if (productIds == null || productIds.isEmpty()) return new ArrayList<>();
+        long total = (productIds instanceof Page) ? ((Page<?>) productIds).getTotal() : productIds.size();
+        int pageNum = (productIds instanceof Page) ? ((Page<?>) productIds).getPageNum() : 1;
+        int pageSize = (productIds instanceof Page) ? ((Page<?>) productIds).getPageSize() : 10;
+        List<FsStoreProductScrm> products = fsStoreProductScrmMapper.getStoreProductInProductIds(productIds);
+        Map<Long, FsStoreProductScrm> productMap = products.stream().collect(Collectors.toMap(FsStoreProductScrm::getProductId, p -> p, (a, b) -> a));
+        List<FsStoreProductTagNameVO> tagNames = productTagRelationMapper.selectProductTagNamesByProductIds(productIds);
+        // 按商品ID分组,每个商品的标签列表按sort排序
+        Map<Long, List<FsStoreProductTagNameVO>> tagVoMap = new LinkedHashMap<>();
+        for (FsStoreProductTagNameVO tn : tagNames) {
+            tagVoMap.computeIfAbsent(tn.getProductId(), k -> new ArrayList<>()).add(tn);
+        }
+        // 转换为标签名称列表,已按sort排序(SQL已排序)
+               Map<Long, List<String>> tagMap = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<FsStoreProductTagNameVO>> entry : tagVoMap.entrySet()) {
+            List<String> tagNameList = entry.getValue().stream()
+                    .map(FsStoreProductTagNameVO::getTagName)
+                    .collect(Collectors.toList());
+            tagMap.put(entry.getKey(), tagNameList);
+        }
+        Map<Long, Long> relSortMap = toRelationSortMap(
+                productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+        List<FsStoreUserEndCategoryProductVO> result = new ArrayList<>();
+        for (Long pid : productIds) {
+            FsStoreProductScrm p = productMap.get(pid);
+            if (p == null) continue;
+            FsStoreUserEndCategoryProductVO vo = new FsStoreUserEndCategoryProductVO();
+            vo.setProductId(p.getProductId());
+            vo.setProductName(p.getProductName());
+            vo.setImage(p.getImage());
+            vo.setPrice(p.getPrice());
+            vo.setOtPrice(p.getOtPrice());
+            vo.setSales(p.getSales());
+            vo.setSort(relSortMap.getOrDefault(pid, 0L));
+            vo.setTagList(tagMap.getOrDefault(pid, new ArrayList<>()));
+            result.add(vo);
+        }
+        // 包装为 Page 以携带正确的 total,供 getDataTable 使用
+        Page<FsStoreUserEndCategoryProductVO> pageResult = new Page<>(pageNum, pageSize);
+        pageResult.setTotal(total);
+        pageResult.addAll(result);
+        return pageResult;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int saveProductsSort(Long userEndCategoryId, List<FsStoreProductSortItemDTO> items) {
+        if (userEndCategoryId == null || items == null || items.isEmpty()) {
+            return 0;
+        }
+        int n = 0;
+        for (FsStoreProductSortItemDTO item : items) {
+            if (item == null || item.getProductId() == null) {
+                continue;
+            }
+            long s = item.getSort() == null ? 0L : item.getSort();
+            if (s < 0) {
+                s = 0;
+            }
+            if (s > 9999) {
+                s = 9999;
+            }
+            n += productUserEndCategoryMapper.updateRelSortByCategoryAndProduct(userEndCategoryId, item.getProductId(), s);
+        }
+        return n;
+    }
+
+    @Override
+    public Map<String, Object> listProductsForApp(Long id, String keyword, Integer pageNum, Integer pageSize, Long storeId, Integer position) {
+        Map<String, Object> out = new HashMap<>();
+        out.put("list", new ArrayList<FsStoreUserEndCategoryProductVO>());
+        out.put("total", 0L);
+        if (pageNum == null || pageSize == null || pageSize <= 0) return out;
+        PageHelper.startPage(pageNum, pageSize);
+        List<Long> productIds;
+        if (position == null) {
+            position = 1;
+        }
+        if (position == 1 || position == 2) {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIdsByPosition(id,storeId, position, keyword);
+        } else if (id != null && id != 0L) {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIdsByCategoryId(id, keyword);
+        } else {
+            productIds = productUserEndCategoryMapper.selectDistinctProductIds(keyword);
+        }
+        long total = productIds instanceof Page ? ((Page<?>) productIds).getTotal() : (productIds != null ? productIds.size() : 0);
+        if (productIds == null || productIds.isEmpty()) {
+            out.put("total", total);
+            return out;
+        }
+        List<FsStoreProductScrm> products = fsStoreProductScrmMapper.getStoreProductInProductIdsForApp(productIds);
+        Map<Long, FsStoreProductScrm> productMap = products.stream().collect(Collectors.toMap(FsStoreProductScrm::getProductId, p -> p, (a, b) -> a));
+        List<FsStoreProductTagNameVO> tagNames = productTagRelationMapper.selectProductTagNamesByProductIds(productIds);
+        // 按商品ID分组,每个商品的标签列表按sort排序
+        Map<Long, List<FsStoreProductTagNameVO>> tagVoMap = new LinkedHashMap<>();
+        for (FsStoreProductTagNameVO tn : tagNames) {
+            tagVoMap.computeIfAbsent(tn.getProductId(), k -> new ArrayList<>()).add(tn);
+        }
+        // 转换为标签名称列表,已按sort排序(SQL已排序)
+        Map<Long, List<String>> tagMap = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<FsStoreProductTagNameVO>> entry : tagVoMap.entrySet()) {
+            List<String> tagNameList = entry.getValue().stream()
+                    .map(FsStoreProductTagNameVO::getTagName)
+                    .collect(Collectors.toList());
+            tagMap.put(entry.getKey(), tagNameList);
+        }
+        Map<Long, Long> relSortMap = relationSortMapForApp(id, storeId, position, productIds);
+        List<FsStoreUserEndCategoryProductVO> result = new ArrayList<>();
+        for (Long pid : productIds) {
+            FsStoreProductScrm p = productMap.get(pid);
+            if (p == null) continue;
+            FsStoreUserEndCategoryProductVO vo = new FsStoreUserEndCategoryProductVO();
+            vo.setProductId(p.getProductId());
+            vo.setProductName(p.getProductName());
+            vo.setImage(p.getImage());
+            vo.setPrice(p.getPrice());
+            vo.setOtPrice(p.getOtPrice());
+            vo.setSales(p.getSales());
+            vo.setSort(relSortMap.getOrDefault(pid, 0L));
+            vo.setTagList(tagMap.getOrDefault(pid, new ArrayList<>()));
+            vo.setPositiveRating(getFixedPositiveRating(p.getProductId()));
+            result.add(vo);
+        }
+        out.put("list", result);
+        out.put("total", total);
+        return out;
+    }
+
+    @Override
+    public FsStoreUserEndCategoryScrm selectById(Long id) {
+        return mapper.selectById(id);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectList(FsStoreUserEndCategoryScrm query) {
+        return mapper.selectList(query);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectListPage(FsStoreUserEndCategoryScrm query) {
+        return mapper.selectList(query);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectListForProduct(Long storeId) {
+        return mapper.selectListForProduct(storeId);
+    }
+
+    @Override
+    public List<FsStoreUserEndCategoryScrm> selectTop8ByPosition(Long storeId, Integer position) {
+        return mapper.selectTop8ByPosition(storeId, position);
+    }
+
+    @Override
+    public int insert(FsStoreUserEndCategoryScrm entity) {
+        entity.setCreateTime(DateUtils.getNowDate());
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.insert(entity);
+    }
+
+    @Override
+    public int update(FsStoreUserEndCategoryScrm entity) {
+        entity.setUpdateTime(DateUtils.getNowDate());
+        return mapper.update(entity);
+    }
+
+    @Override
+    public int deleteById(Long id) {
+        productUserEndCategoryMapper.deleteByCategoryIds(new Long[]{id});
+        return mapper.deleteById(id);
+    }
+
+    @Override
+    public int deleteByIds(Long[] ids) {
+        if (ids == null || ids.length == 0) return 0;
+        productUserEndCategoryMapper.deleteByCategoryIds(ids);
+        return mapper.deleteByIds(ids);
+    }
+
+    private Map<Long, Long> relationSortMapForApp(Long categoryId, Long storeId, Integer position, List<Long> productIds) {
+        if (productIds == null || productIds.isEmpty()) {
+            return new HashMap<>();
+        }
+        if (position != null && (position == 1 || position == 2)) {
+            if (categoryId != null && categoryId != 0L) {
+                return toRelationSortMap(
+                        productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+            }
+            return toRelationSortMap(
+                    productUserEndCategoryMapper.selectAggSortByPositionAndProductIds(storeId, position, productIds));
+        }
+        if (categoryId != null && categoryId != 0L) {
+            return toRelationSortMap(
+                    productUserEndCategoryMapper.selectSortByCategoryAndProductIds(categoryId, productIds));
+        }
+        return toRelationSortMap(productUserEndCategoryMapper.selectAggSortGlobalAndProductIds(productIds));
+    }
+
+    private static Map<Long, Long> toRelationSortMap(List<FsStoreProductUserEndCategory> rows) {
+        Map<Long, Long> m = new HashMap<>();
+        if (rows == null) {
+            return m;
+        }
+        for (FsStoreProductUserEndCategory row : rows) {
+            if (row.getProductId() == null) {
+                continue;
+            }
+            m.put(row.getProductId(), row.getSort() != null ? row.getSort() : 0L);
+        }
+        return m;
+    }
+
+    /**
+     * 获取固定的好评率随机值
+     * 先从 Redis 获取,如果不存在则生成随机值并保存到 Redis(永久保存)
+     * @return 固定的好评率值
+     */
+    private BigDecimal getFixedPositiveRating(Long productId) {
+        // 先从 Redis 获取
+        BigDecimal cachedRating = redisCache.getCacheObject(POSITIVE_RATING_REDIS_KEY + productId);
+        if (cachedRating != null) {
+            return cachedRating;
+        }
+        
+        // Redis 不存在,生成随机值
+        double rating = ThreadLocalRandom.current().nextDouble(MIN_RATING, MAX_RATING);
+        BigDecimal fixedRating = new BigDecimal(rating).setScale(1, RoundingMode.HALF_UP);
+        
+        // 保存到 Redis(永久保存,不设置过期时间)
+        redisCache.setCacheObject(POSITIVE_RATING_REDIS_KEY + productId, fixedRating);
+        
+        return fixedRating;
+    }
+}

+ 23 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagListVO.java

@@ -0,0 +1,23 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 商品标签列表VO(含关联商品数量)
+ */
+@Data
+public class FsStoreProductTagListVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+    private Long storeId;
+    private String tagName;
+    private Integer status;
+    private Long sort;
+    private Date createTime;
+    private Integer productCount;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductTagNameVO.java

@@ -0,0 +1,19 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 商品-标签名称(用于按商品ID批量查标签)
+ */
+@Data
+public class FsStoreProductTagNameVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    private String tagName;
+    /** 排序值 */
+    private Long sort;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreTagProductVO.java

@@ -0,0 +1,21 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * ±êÇ©¹ØÁªÉÌÆ·Ïî
+ */
+@Data
+public class FsStoreTagProductVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    private String productName;
+    private String image;
+    private BigDecimal price;
+    private Integer isShow;
+}

+ 41 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreUserEndCategoryProductVO.java

@@ -0,0 +1,41 @@
+package com.fs.hisStore.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 用户端分类下的商品项:商品ID、名称、售价、原价、销量、产品标签列表
+ */
+@Data
+public class FsStoreUserEndCategoryProductVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long productId;
+    private String productName;
+    /** 主图(App 列表用) */
+    private String image;
+    /** 实际销售价格 */
+    private BigDecimal price;
+    /** 原价 */
+    private BigDecimal otPrice;
+    /** 好评率 */
+    private BigDecimal positiveRating;
+    /** 销量 */
+    private Long sales;
+    /** 在用户分端类下的展示排序(fs_store_product_user_end_category.sort;App 聚合场景可能为多条关联中的最大 sort) */
+    private Long sort;
+    /** 产品标签名称列表 */
+    private List<String> tagList;
+    
+    /**
+     * 设置好评率(从外部传入固定值)
+     * @param rating 好评率值
+     */
+    public void setPositiveRating(BigDecimal rating) {
+        this.positiveRating = rating;
+    }
+}

+ 62 - 15
fs-service/src/main/java/com/fs/ipad/IpadSendUtils.java

@@ -58,7 +58,11 @@ public class IpadSendUtils {
     private final QwUserMapper qwUserMapper;
     private final RedisCache redisCacheUrl;
     private final String FILE_KEY = "ipad:upload:";
+    private final String UPLOAD_LOCK_KEY = "ipad:upload:lock:";
     private final String USER_KEY = "ipad:user:";
+    private static final long UPLOAD_LOCK_MINUTES = 5;
+    private static final long UPLOAD_WAIT_MS = 120_000;
+    private static final long UPLOAD_POLL_MS = 500;
 
     /**
      * 发送卡片消息
@@ -87,24 +91,67 @@ public class IpadSendUtils {
         return wxWorkService.SendTextMsg(dto, vo.getServerId());
     }
 
-    // 公共文件获取
-    private <T> T getFile(String key, Class<T> clazz, Supplier<T> supplier) {
-        String obj = redisCache.getCacheObject(key);
-        T t;
-        if(StringUtils.isEmpty(obj)){
-            t = supplier.get();
-            redisCache.setCacheObject(key, JSON.toJSONString(t), 3, TimeUnit.DAYS);
-        }else{
-            t = JSON.parseObject(obj, clazz);
+    private String buildUploadCacheKey(String type, Long serverId, String url) {
+        return FILE_KEY + type + ":" + serverId + ":" + url;
+    }
+
+    /**
+     * 按 iPad 服务器 + 资源 URL 缓存 CDN 上传结果,并用分布式锁避免并发重复下载/上传。
+     */
+    private <T> T getFile(Long serverId, String type, String url, Class<T> clazz, Supplier<T> supplier) {
+        String cacheKey = buildUploadCacheKey(type, serverId, url);
+        String cached = redisCache.getCacheObject(cacheKey);
+        if (StringUtils.isNotEmpty(cached)) {
+            return JSON.parseObject(cached, clazz);
+        }
+
+        String lockKey = UPLOAD_LOCK_KEY + type + ":" + serverId + ":" + url;
+        boolean locked = false;
+        try {
+            long deadline = System.currentTimeMillis() + UPLOAD_WAIT_MS;
+            while (System.currentTimeMillis() < deadline) {
+                cached = redisCache.getCacheObject(cacheKey);
+                if (StringUtils.isNotEmpty(cached)) {
+                    return JSON.parseObject(cached, clazz);
+                }
+                locked = redisCacheUrl.setIfAbsent(lockKey, "1", UPLOAD_LOCK_MINUTES, TimeUnit.MINUTES);
+                if (locked) {
+                    break;
+                }
+                Thread.sleep(UPLOAD_POLL_MS);
+            }
+
+            if (!locked) {
+                cached = redisCache.getCacheObject(cacheKey);
+                if (StringUtils.isNotEmpty(cached)) {
+                    return JSON.parseObject(cached, clazz);
+                }
+                throw new BaseException("等待文件上传超时,请稍后重试");
+            }
+
+            cached = redisCache.getCacheObject(cacheKey);
+            if (StringUtils.isNotEmpty(cached)) {
+                return JSON.parseObject(cached, clazz);
+            }
+
+            T result = supplier.get();
+            redisCache.setCacheObject(cacheKey, JSON.toJSONString(result), 3, TimeUnit.DAYS);
+            return result;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BaseException("等待文件上传被中断");
+        } finally {
+            if (locked) {
+                redisCacheUrl.deleteObject(lockKey);
+            }
         }
-        return t;
     }
 
     /**
      * 发送图片
      */
     public WxWorkResponseDTO<WxwSendCDNImgMsgRespDTO> sendImg(FileVo vo){
-        WxCdnUploadImgLinkResp resp = getFile(FILE_KEY + "img:" + vo.getUrl(), WxCdnUploadImgLinkResp.class, () -> uploadImage(vo.getUuid(), vo.getServerId(), vo.getUrl()));
+        WxCdnUploadImgLinkResp resp = getFile(vo.getServerId(), "img", vo.getUrl(), WxCdnUploadImgLinkResp.class, () -> uploadImage(vo.getUuid(), vo.getServerId(), vo.getUrl()));
         WxwSendCDNImgMsgDTO dto = new WxwSendCDNImgMsgDTO();
         dto.setCdnkey(resp.getCdn_key());
         dto.setAeskey(resp.getAes_key());
@@ -128,7 +175,7 @@ public class IpadSendUtils {
      * 发送文件
      */
     public WxWorkResponseDTO<WxwSendCDNFileMsgRespDTO> sendFile(FileVo vo){
-        WxwUploadCdnLinkFileRespDTO resp = getFile(FILE_KEY + "file:" + vo.getUrl(), WxwUploadCdnLinkFileRespDTO.class, () -> uploadFile(vo.getUuid(), vo.getServerId(), vo.getUrl()));
+        WxwUploadCdnLinkFileRespDTO resp = getFile(vo.getServerId(), "file", vo.getUrl(), WxwUploadCdnLinkFileRespDTO.class, () -> uploadFile(vo.getUuid(), vo.getServerId(), vo.getUrl()));
         WxwSendCDNFileMsgDTO dto = new WxwSendCDNFileMsgDTO();
         dto.setCdnkey(resp.getCdn_key());
         dto.setAeskey(resp.getAes_key());
@@ -148,7 +195,7 @@ public class IpadSendUtils {
      * 发送文件
      */
     public WxWorkResponseDTO<WxwSendCDNVoiceMsgRespDTO> sendVoice(FileVo vo){
-        WxwUploadCdnLinkFileRespDTO resp = getFile(FILE_KEY + "voice:" + vo.getUrl(), WxwUploadCdnLinkFileRespDTO.class, () -> uploadFile(vo.getUuid(), vo.getServerId(), vo.getUrl()));
+        WxwUploadCdnLinkFileRespDTO resp = getFile(vo.getServerId(), "voice", vo.getUrl(), WxwUploadCdnLinkFileRespDTO.class, () -> uploadFile(vo.getUuid(), vo.getServerId(), vo.getUrl()));
         WxwSendCDNVoiceMsgDTO dto = new WxwSendCDNVoiceMsgDTO();
         dto.setCdnkey(resp.getCdn_key());
         dto.setVoice_time(vo.getVoiceTime());
@@ -164,7 +211,7 @@ public class IpadSendUtils {
      * 发送文件
      */
     public WxWorkResponseDTO<WxwSendCDNVideoMsgRespDTO> sendVideo(FileVo vo){
-        WxCdnUploadVideoResp resp = getFile(FILE_KEY + "voice:" + vo.getUrl(), WxCdnUploadVideoResp.class, () -> uploadVideo(vo.getUuid(), vo.getServerId(), vo.getUrl()));
+        WxCdnUploadVideoResp resp = getFile(vo.getServerId(), "video", vo.getUrl(), WxCdnUploadVideoResp.class, () -> uploadVideo(vo.getUuid(), vo.getServerId(), vo.getUrl()));
         WxwSendCDNVideoMsgDTO dto = new WxwSendCDNVideoMsgDTO();
         dto.setCdnkey(resp.getCdn_key());
         dto.setAeskey(resp.getAes_key());
@@ -198,7 +245,7 @@ public class IpadSendUtils {
      * 发送小程序
      */
     public WxWorkResponseDTO<WxWorkSendAppMsgRespDTO> sendMiniProgram(MiniProgramVo vo){
-        WxCdnUploadImgLinkResp resp = getFile(FILE_KEY + "voice:" + vo.getImgUrl(), WxCdnUploadImgLinkResp.class, () -> uploadImage(vo.getUuid(), vo.getServerId(), vo.getImgUrl()));
+        WxCdnUploadImgLinkResp resp = getFile(vo.getServerId(), "img", vo.getImgUrl(), WxCdnUploadImgLinkResp.class, () -> uploadImage(vo.getUuid(), vo.getServerId(), vo.getImgUrl()));
         WxWorkSendAppMsgDTO dto = new WxWorkSendAppMsgDTO();
         dto.setCdnkey(resp.getCdn_key());
         dto.setAeskey(resp.getAes_key());

+ 8 - 7
fs-service/src/main/java/com/fs/live/mapper/LiveDataMapper.java

@@ -4,13 +4,7 @@ package com.fs.live.mapper;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
 import com.fs.live.domain.LiveData;
-import com.fs.live.vo.LiveDashBoardDataVo;
-import com.fs.live.vo.LiveDataDetailVo;
-import com.fs.live.vo.LiveDataListVo;
-import com.fs.live.vo.LiveDataStatisticsVo;
-import com.fs.live.vo.LiveUserDetailVo;
-import com.fs.live.vo.RecentLiveDataVo;
-import com.fs.live.vo.TrendDataVO;
+import com.fs.live.vo.*;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.springframework.stereotype.Repository;
@@ -176,4 +170,11 @@ public interface LiveDataMapper {
      */
     @DataSource(DataSourceType.SLAVE)
     List<LiveUserDetailVo> selectLiveUserDetailListBySql(@Param("liveId") Long liveId,@Param("companyId") Long companyId,@Param("companyUserId") Long companyUserId);
+
+    /**
+     * 查询正在直播的直播间(仅 liveId、liveType、封面、标题),用于首页推荐
+     * status=2 表示直播中
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<LiveAppSimpleVO> selectLivingLivesForApp();
 }

+ 2 - 0
fs-service/src/main/java/com/fs/live/service/ILiveDataService.java

@@ -169,4 +169,6 @@ public interface ILiveDataService {
     List<LiveUserDetailExportVO> exportLiveUserDetail(Long liveId, Long companyId, Long companyUserId);
 
     List<LiveDataListVo> exportLiveData(LiveDataParam param);
+
+    List<LiveAppSimpleVO> listLivingLivesForApp();
 }

+ 30 - 4
fs-service/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java

@@ -10,10 +10,7 @@ import com.fs.hisStore.mapper.FsUserScrmMapper;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.*;
 import com.fs.live.param.LiveDataParam;
-import com.fs.live.service.ILiveDataService;
-import com.fs.live.service.ILiveUserFavoriteService;
-import com.fs.live.service.ILiveUserFollowService;
-import com.fs.live.service.ILiveUserLikeService;
+import com.fs.live.service.*;
 import com.fs.live.vo.*;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
@@ -92,6 +89,8 @@ public class LiveDataServiceImpl implements ILiveDataService {
     @Autowired
     private LiveOrderMapper liveOrderMapper;
 
+    @Autowired
+    private ILiveWatchUserService liveWatchUserService;
     /* 直播大屏展示 数据接口 */
     @Override
     public R dashboardData(Long liveId) {
@@ -222,6 +221,33 @@ public class LiveDataServiceImpl implements ILiveDataService {
         }
         return liveDataList;
     }
+
+    @Override
+    public List<LiveAppSimpleVO> listLivingLivesForApp() {
+        List<LiveAppSimpleVO> list = liveDataMapper.selectLivingLivesForApp();
+
+        if (list == null || list.isEmpty()) {
+            return list;
+        }
+        // 如果查询结果超过 3 条,进行截取
+        if (list.size() > 3) {
+            list = list.subList(0, 3);
+        }
+        for (LiveAppSimpleVO vo : list) {
+            if (vo.getLiveType() != null && vo.getLiveType() == 2 && vo.getLiveId() != null) {
+                try {
+                    Map<String, Integer> flagMap = liveWatchUserService.getLiveFlagWithCache(vo.getLiveId());
+                    if (flagMap != null && flagMap.containsKey("liveFlag")) {
+                        vo.setLiveFlag(flagMap.get("liveFlag"));
+                    }
+                } catch (Exception e) {
+                    log.warn("getLiveFlagWithCache fail liveId={}", vo.getLiveId(), e);
+                }
+            }
+        }
+        return list;
+    }
+
     /**
      * 查询直播数据
      *

+ 18 - 0
fs-service/src/main/java/com/fs/live/vo/LiveAppSimpleVO.java

@@ -0,0 +1,18 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 首页推荐-正在直播的直播间简要信息(liveId、liveType、封面、标题、liveFlag当liveType=2时从Redis取)
+ */
+@Data
+public class LiveAppSimpleVO {
+    private Long liveId;
+    private Integer liveType;
+    /** 直播间封面图片 */
+    private String liveImgUrl;
+    /** 直播间标题 */
+    private String liveName;
+    /** 当 liveType=2 时由 Redis 查询填充 */
+    private Integer liveFlag;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/qw/domain/QwExternalContact.java

@@ -126,6 +126,10 @@ public class QwExternalContact extends BaseEntity
     @TableField(exist = false)
     private Integer isDaysNotStudy;
 
+    /** sop_user_logs_info 主键,营期客户记录按条更新评级/未看课标记 */
+    @TableField(exist = false)
+    private String sopUserLogsInfoId;
+
     private Integer levelType;
 
     @JsonFormat(pattern = "yyyy-MM-dd")
@@ -149,6 +153,8 @@ public class QwExternalContact extends BaseEntity
     private Integer isReply;
     // 是否被邀请进群0否1是
     private Integer joinGroup;
+    // 是否转接客户 0否 1是
+    private Integer isTransfer;
     // 广告链路唯一id
     private String traceId;
 

+ 52 - 5
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -237,13 +237,26 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
 
     @Select({"<script> " +
             "select ec.*,qu.qw_user_name,qd.dept_name as departmentName,cw.name way_name,wg.group_name way_group_name," +
-            "fu.first_login_app_time as firstLoginAppTime,fu.first_login_reward_address as firstLoginRewardAddress,fu.red_status as redStatus from qw_external_contact ec " +
+            "<if test=\"addSalesCount != null\">pr_asc.add_sales_count as addSalesCount,</if>" +
+            "fu.first_login_app_time as firstLoginAppTime,fu.first_login_reward_address as firstLoginRewardAddress,fu.red_status as redStatus," +
+            "fu.manage_status as manageStatus,fu.manage_remark as manageRemark from qw_external_contact ec " +
             "left join qw_user qu on ec.user_id=qu.qw_user_id and qu.corp_id=ec.corp_id " +
             "left join qw_dept qd on qd.dept_id=qu.department and qd.corp_id=qu.corp_id " +
             "left join company_user cu on ec.company_user_id=cu.user_id " +
             "left join fs_user fu on fu.user_id = ec.fs_user_id " +
             "left join qw_contact_way cw on cw.id = ec.way_id " +
-            "left join qw_contact_way_group wg on wg.id=cw.group_id" +
+            "left join qw_contact_way_group wg on wg.id=cw.group_id " +
+            "<if test=\"addSalesCount != null\">" +
+            " inner join (" +
+            "   select fs_user_id, max(qw_user_count) as add_sales_count" +
+            "   from fs_user_project_repeat" +
+            "   where fs_user_id is not null" +
+            "   <if test=\"companyId != null\"> and company_id = #{companyId}</if>" +
+            "   <if test=\"canonicalProjectId != null\"> and project_id = #{canonicalProjectId}</if>" +
+            "   group by fs_user_id" +
+            "   having max(qw_user_count) &gt;= #{addSalesCount}" +
+            " ) pr_asc on ec.fs_user_id = pr_asc.fs_user_id" +
+            "</if>" +
             "<where>  \n" +
             "            <if test=\"id != null  and id != ''\"> and ec.id   like concat( #{id}, '%') </if>\n" +
             "            <if test=\"isRepeat != null\"> and ec.is_repeat   = #{isRepeat} </if>\n" +
@@ -287,7 +300,16 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "   </foreach> " +
             "</if >\n" +
             "            <if test=\"stageStatus != null \"> and ec.stage_status = #{stageStatus}</if>\n" +
-            "            <if test=\"userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
+            "            <if test=\"projectId != null and userRepeat != null and canonicalProjectId != null\">\n" +
+            "                  and ec.fs_user_id is not null\n" +
+            "                  and exists (\n" +
+            "                    select 1 from fs_user_project_repeat pr\n" +
+            "                    where pr.fs_user_id = ec.fs_user_id\n" +
+            "                      and pr.project_id = #{canonicalProjectId}\n" +
+            "                      and pr.is_repeat = #{userRepeat}\n" +
+            "                  )\n" +
+            "            </if>\n" +
+            "            <if test=\"projectId == null and userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
             "            <if test=\"transferStatus != null \"> and ec.transfer_status = #{transferStatus}</if>\n" +
             "            <if test=\"qwUserId != null \"> and ec.qw_user_id = #{qwUserId}</if>\n" +
             "            <if test=\"level != null \"> and ec.level = #{level}</if>\n" +
@@ -298,6 +320,8 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "            <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=\"manageStatus != null and manageStatus == 1\"> and (fu.manage_status = 1 or fu.manage_status is null) </if>\n" +
+            "            <if test=\"manageStatus != null and manageStatus == 2\"> and fu.manage_status = 2 </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" +
@@ -314,7 +338,10 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
                 "and (cu.nick_name like concat('%', #{companyUser}, '%') or cu.phonenumber= #{companyUser})"+
             "</if> " +
             "        </where>"+
-            "order by ec.create_time desc,ec.id desc"+
+            "<choose>" +
+            "<when test=\"addSalesCount != null\">order by pr_asc.add_sales_count desc, ec.create_time desc, ec.id desc</when>" +
+            "<otherwise>order by ec.create_time desc, ec.id desc</otherwise>" +
+            "</choose>"+
             "</script>"})
     @DataSource(DataSourceType.SLAVE)
     List<QwExternalContactVO> selectQwExternalContactListVO(QwExternalContactParam qwExternalContact);
@@ -352,7 +379,27 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
             "            <if test=\"customerId != null \"> and ec.customer_id = #{customerId}</if>\n" +
             "            <if test=\"status != null \"> and ec.status = #{status}</if>\n" +
             "            <if test=\"stageStatus != null \"> and ec.stage_status = #{stageStatus}</if>\n" +
-            "            <if test=\"userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
+            "            <if test=\"projectId != null and userRepeat != null and canonicalProjectId != null\">\n" +
+            "                  and ec.fs_user_id is not null\n" +
+            "                  and exists (\n" +
+            "                    select 1 from fs_user_project_repeat pr\n" +
+            "                    where pr.fs_user_id = ec.fs_user_id\n" +
+            "                      and pr.project_id = #{canonicalProjectId}\n" +
+            "                      and pr.is_repeat = #{userRepeat}\n" +
+            "                  )\n" +
+            "            </if>\n" +
+            "            <if test=\"projectId == null and userRepeat != null \"> and ec.user_repeat = #{userRepeat}</if>\n" +
+            "            <if test=\"addSalesCount != null\">\n" +
+            "                  and ec.fs_user_id is not null\n" +
+            "                  and exists (\n" +
+            "                    select 1 from fs_user_project_repeat pr\n" +
+            "                    where pr.fs_user_id = ec.fs_user_id\n" +
+            "                      <if test=\"canonicalProjectId != null\">\n" +
+            "                        and pr.project_id = #{canonicalProjectId}\n" +
+            "                      </if>\n" +
+            "                      and pr.qw_user_count &gt;= #{addSalesCount}\n" +
+            "                  )\n" +
+            "            </if>\n" +
             "            <if test=\"transferStatus != null \"> and ec.transfer_status = #{transferStatus}</if>\n" +
             "            <if test=\"qwUserId != null \"> and ec.qw_user_id = #{qwUserId}</if>\n" +
             "            <if test=\"level != null \"> and ec.level = #{level}</if>\n" +

+ 1 - 1
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java

@@ -43,7 +43,7 @@ public interface QwGroupChatMapper
             "    ANY_VALUE(qu.qw_user_name) as qwName, " +
             "    COUNT(CASE WHEN gcu.is_out = 1 AND DATE(gcu.join_time) = CURDATE() THEN 1 END) AS todayJoinByChartUser, " +
             "    COUNT(CASE WHEN gcu.is_out = 2 AND DATE(gcu.out_time) = CURDATE() THEN 1 END) AS todayOutByChartUser, " +
-            "    COUNT(CASE WHEN gcu.is_out = 1 THEN gcu.chat_id END) AS groupSizeByChartUser, " +
+            "    ANY_VALUE(gc.group_size) AS groupSizeByChartUser, " +
             "    gc.* " +
             "FROM " +
             "    qw_group_chat gc " +

+ 10 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatUserMapper.java

@@ -128,6 +128,16 @@ public interface QwGroupChatUserMapper
     public int insertQwGroupChatUser(QwGroupChatUser qwGroupChatUser);
     public int insertOrUpdateQwGroupChatUser(QwGroupChatUser qwGroupChatUser);
 
+    /**
+     * 全量同步前:将该群当前在群/未标记成员先标为退群,再由 API 成员列表回写为在群
+     */
+    int markActiveMembersOutByChatId(@Param("chatId") String chatId, @Param("corpId") String corpId);
+
+    /**
+     * 全量同步成员:强制写入在群状态并清除退群信息
+     */
+    int insertOrUpdateSyncGroupChatUser(QwGroupChatUser qwGroupChatUser);
+
     public QwGroupChatUser selectQwGroupChatUserByExternalUserId(QwGroupChatUser qwGroupChatUser);
 
     /**

+ 9 - 5
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -207,13 +207,17 @@ public interface QwUserMapper extends BaseMapper<QwUser>
      *  查询企微用户绑定了的列表
      */
     @Select({"<script> " +
-            "select qu.*, cu.nick_name, cu.user_name, qd.dept_name as departmentName from qw_user qu " +
+            "select qu.*, " +
+            "COALESCE(cu.nick_name, qu.qw_user_name) as nickName, " +
+            "COALESCE(cu.user_name, qu.qw_user_id) as userName, " +
+            "qd.dept_name as departmentName from qw_user qu " +
             "LEFT JOIN company_user cu ON cu.user_id=qu.company_user_id " +
-            "RIGHT JOIN qw_dept qd on qu.department=qd.dept_id and qd.corp_id=qu.corp_id " +
-            "where qu.is_del=0 and qu.company_user_id is not null"+
+            "LEFT JOIN qw_dept qd on qu.department=qd.dept_id and qd.corp_id=qu.corp_id " +
+            "where qu.is_del=0 " +
+            "<if test=\"forTransfer == null or forTransfer != 1\"> and qu.company_user_id is not null </if>" +
             "            <if test=\"qwUserId != null  and qwUserId != ''\"> and qu.qw_user_id = #{qwUserId}</if>\n" +
-            "            <if test=\"companyId != null \"> and qu.company_id = #{companyId}</if>\n" +
-            "            <if test=\"nickName != null  and nickName != ''\"> and cu.nick_name like concat('%', #{nickName}, '%') </if>\n" +
+            "            <if test=\"forTransfer == null or forTransfer != 1\"><if test=\"companyId != null \"> and qu.company_id = #{companyId}</if></if>\n" +
+            "            <if test=\"nickName != null  and nickName != ''\"> and (cu.nick_name like concat('%', #{nickName}, '%') or qu.qw_user_name like concat('%', #{nickName}, '%')) </if>\n" +
             "            <if test=\"qwUserName != null  and qwUserName != ''\"> and qu.qw_user_name like concat('%', #{qwUserName}, '%') </if>\n" +
             "            <if test=\"companyUserId != null \"> and qu.company_user_id = #{companyUserId}</if>\n" +
             "            <if test=\"corpId != null \"> and qu.corp_id = #{corpId}</if>\n" +

+ 19 - 0
fs-service/src/main/java/com/fs/qw/param/QwExternalContactManageParam.java

@@ -0,0 +1,19 @@
+package com.fs.qw.param;
+
+import lombok.Data;
+
+/**
+ * 企微客户关联会员拉黑状态修改参数
+ */
+@Data
+public class QwExternalContactManageParam {
+
+    /** 企微外部联系人主键 */
+    private Long id;
+
+    /** 管理状态:1正常 2已被总部拉黑 */
+    private Integer manageStatus;
+
+    /** 管理备注(提示) */
+    private String manageRemark;
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません