Parcourir la source

SOP飞书链路修改

吴树波 il y a 1 jour
Parent
commit
d06c6499a5
31 fichiers modifiés avec 1042 ajouts et 123 suppressions
  1. 8 0
      fs-admin/src/main/java/com/fs/course/controller/FsCourseRedPacketLogController.java
  2. 6 1
      fs-admin/src/main/java/com/fs/course/controller/FsCourseWatchLogController.java
  3. 7 0
      fs-company/src/main/java/com/fs/company/controller/course/FsCourseRedPacketLogController.java
  4. 13 0
      fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java
  5. 18 7
      fs-qw-api-msg/src/main/java/com/fs/app/controller/QwMsgController.java
  6. 19 6
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  7. 14 35
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  8. 15 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  9. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseLinkService.java
  10. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  11. 46 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java
  12. 28 1
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java
  13. 92 1
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  14. 9 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  15. 125 0
      fs-service/src/main/java/com/fs/course/utils/FsCourseWatchLogProjectFiller.java
  16. 18 7
      fs-service/src/main/java/com/fs/erp/service/impl/DfOrderServiceImpl.java
  17. 152 20
      fs-service/src/main/java/com/fs/ipad/IpadSendUtils.java
  18. 5 0
      fs-service/src/main/java/com/fs/qw/domain/QwExternalContact.java
  19. 12 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  20. 5 0
      fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java
  21. 6 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwContactWayServiceImpl.java
  22. 14 3
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  23. 1 1
      fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java
  24. 1 1
      fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java
  25. 5 0
      fs-service/src/main/java/com/fs/qwApi/param/SendWelcomeMsgParam.java
  26. 24 19
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  27. 5 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsServiceImpl.java
  28. 75 0
      fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml
  29. 242 17
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  30. 21 1
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  31. 46 3
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java

+ 8 - 0
fs-admin/src/main/java/com/fs/course/controller/FsCourseRedPacketLogController.java

@@ -106,6 +106,14 @@ public class FsCourseRedPacketLogController extends BaseController
             fsCourseRedPacketLog.setPhone(encryptPhone(fsCourseRedPacketLog.getPhoneMk()));
         }
 
+        // P0:无时间范围时默认近 7 天,避免无筛选 COUNT 全表扫描
+        if (fsCourseRedPacketLog.getSTime() == null && fsCourseRedPacketLog.getETime() == null) {
+            java.time.LocalDate end = java.time.LocalDate.now();
+            java.time.LocalDate start = end.minusDays(6);
+            fsCourseRedPacketLog.setSTime(java.sql.Timestamp.valueOf(start.atStartOfDay()));
+            fsCourseRedPacketLog.setETime(java.sql.Timestamp.valueOf(end.atStartOfDay()));
+        }
+
         PageHelper.startPage(fsCourseRedPacketLog.getPageNum(), fsCourseRedPacketLog.getPageSize());
 
         List<FsCourseRedPacketLogListPVO> list = fsCourseRedPacketLogService.selectFsCourseRedPacketLogListVO(fsCourseRedPacketLog);

+ 6 - 1
fs-admin/src/main/java/com/fs/course/controller/FsCourseWatchLogController.java

@@ -287,7 +287,12 @@ public class FsCourseWatchLogController extends BaseController
     }
         startPage();
         List<FsCoureseWatchLogVO> list = fsCourseWatchLogService.selectUserIdList(fsCourseWatchLog);
-        return getDataTable(list);
+        TableDataInfo dataTable = getDataTable(list);
+        // 清除分页后再查全量合计,避免 PageHelper 影响汇总 SQL
+        PageHelper.clearPage();
+        FsCoureseWatchLogVO summary = fsCourseWatchLogService.selectUserIdListSummary(fsCourseWatchLog);
+        dataTable.put("summary", summary);
+        return dataTable;
     }
 
     /**

+ 7 - 0
fs-company/src/main/java/com/fs/company/controller/course/FsCourseRedPacketLogController.java

@@ -86,6 +86,13 @@ public class FsCourseRedPacketLogController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(FsCourseRedPacketLogParam fsCourseRedPacketLog)
     {
+        // P0:无时间范围时默认近 7 天
+        if (fsCourseRedPacketLog.getSTime() == null && fsCourseRedPacketLog.getETime() == null) {
+            java.time.LocalDate end = java.time.LocalDate.now();
+            java.time.LocalDate start = end.minusDays(6);
+            fsCourseRedPacketLog.setSTime(java.sql.Timestamp.valueOf(start.atStartOfDay()));
+            fsCourseRedPacketLog.setETime(java.sql.Timestamp.valueOf(end.atStartOfDay()));
+        }
         startPage();
         if (fsCourseRedPacketLog.getPhoneMk() != null && fsCourseRedPacketLog.getPhoneMk() != "") {
             fsCourseRedPacketLog.setPhone(encryptPhone(fsCourseRedPacketLog.getPhoneMk()));

+ 13 - 0
fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java

@@ -74,6 +74,19 @@ public class FsCourseWatchLogController extends BaseController
         }
         param.setCompanyId( loginUser.getCompany().getCompanyId());
 
+        // P0:无时间范围时默认近 7 天,避免扫全历史
+        if ((param.getSTime() == null || param.getSTime().isEmpty())
+                && (param.getETime() == null || param.getETime().isEmpty())
+                && (param.getUpSTime() == null || param.getUpSTime().isEmpty())
+                && (param.getUpETime() == null || param.getUpETime().isEmpty())
+                && (param.getScheduleStartTime() == null || param.getScheduleStartTime().isEmpty())
+                && (param.getScheduleEndTime() == null || param.getScheduleEndTime().isEmpty())) {
+            java.time.LocalDate end = java.time.LocalDate.now();
+            java.time.LocalDate start = end.minusDays(6);
+            param.setSTime(start.toString());
+            param.setETime(end.toString());
+        }
+
         if (param.getSendType()==1&& param.getPeriodETime()!=null && param.getPeriodSTime()!=null) {
             List<Long> periodIds = userCoursePeriodDaysService.selectFsUserCoursePeriodDaysByTime(param.getPeriodSTime(), param.getPeriodETime());
 

+ 18 - 7
fs-qw-api-msg/src/main/java/com/fs/app/controller/QwMsgController.java

@@ -99,14 +99,25 @@ public class QwMsgController {
                                     log.error("物流功能未开启,roleId:" + qwUser.getFastGptRoleId() + "订单号:" + orderId);
                                     return R.ok();
                                 }
-                                WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
-                                wxWorkUserId2VidDTO.setOpenid(Collections.singletonList(externalContact.getExternalUserId()));
-                                wxWorkUserId2VidDTO.setUuid(qwUser.getUid());
-                                WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> WxWorkVid2UserIdRespDTO = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO,qwUser.getServerId());
-                                List<WxWorkVid2UserIdRespDTO> data = WxWorkVid2UserIdRespDTO.getData();
+                                Long sendId = externalContact.getWxUserId();
+                                if (sendId == null || sendId <= 0) {
+                                    WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
+                                    wxWorkUserId2VidDTO.setOpenid(Collections.singletonList(externalContact.getExternalUserId()));
+                                    wxWorkUserId2VidDTO.setUuid(qwUser.getUid());
+                                    WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> WxWorkVid2UserIdRespDTO = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO,qwUser.getServerId());
+                                    List<WxWorkVid2UserIdRespDTO> data = WxWorkVid2UserIdRespDTO.getData();
+                                    if(data != null && !data.isEmpty()){
+                                        sendId = data.get(0).getUser_id();
+                                        if (sendId != null && externalContact.getId() != null) {
+                                            QwExternalContact upd = new QwExternalContact();
+                                            upd.setId(externalContact.getId());
+                                            upd.setWxUserId(sendId);
+                                            qwExternalContactMapper.updateQwExternalContact(upd);
+                                        }
+                                    }
+                                }
                                 StringBuilder sBuilder = new StringBuilder();
-                                if(data != null && !data.isEmpty()){
-                                    Long sendId = data.get(0).getUser_id();
+                                if(sendId != null){
                                     switch (order.getStatus())
                                     {
                                         case -1:

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

@@ -25,6 +25,7 @@ import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.feishu.service.FeiShuService;
 import com.fs.feishu.util.FeishuDirectDocShardAllocator;
 import com.fs.live.domain.LiveWatchLog;
@@ -166,6 +167,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
 
+    @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+
     @Autowired
     private IQwSopLogsService qwSopLogsService;
 
@@ -1828,6 +1832,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
                     break;
                 case "17":
+                    // 飞书看课链接(旧):固定免授权发送,不再按模板授权开关分支
+                    setting.setFeishuNeedAuth(0);
                     // 飞书看课链接:失败仅记录日志并跳过本条,不阻塞整批 SOP 生成
                     try {
                         Long companyIdLong = Long.parseLong(companyId);
@@ -1836,9 +1842,6 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         String chatIdForFeishu = null;
                         if (isGroupChat) {
                             chatIdForFeishu = externalId;
-                        } else if (FeiShuService.isAuthRequired(setting.getFeishuNeedAuth())) {
-                            externalIdLong = Long.parseLong(externalId);
-                            addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo, 2);
                         } else {
                             externalIdLong = Long.parseLong(externalId);
                         }
@@ -1848,12 +1851,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         String shortLink = feiShuLinkMap.get("link");
 
                         Integer directDocShard = null;
-                        if (feishuShardAllocator != null && !FeiShuService.isAuthRequired(setting.getFeishuNeedAuth())) {
+                        if (feishuShardAllocator != null) {
                             directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
                                     setting.getFeishuAccountId());
                         }
                         String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
-                                shortLink, setting.getFeishuAccountId(), setting.getFeishuNeedAuth(), directDocShard);
+                                shortLink, setting.getFeishuAccountId(), 0, directDocShard);
                         if (StringUtils.isEmpty(feishuLink)) {
                             log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                                     logVo.getSopId(), videoId, externalId);
@@ -2214,7 +2217,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
                 companyUserId, companyId, externalIdStr, 3);
         link.setFeishuAccountId(setting.getFeishuAccountId());
-        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
+        // 飞书旧链接(contentType=17)固定免授权
+        link.setFeishuNeedAuth(0);
         link.setProjectCode(cloudHostProper.getProjectCode());
         if (StringUtils.isNotEmpty(chatId)) {
             link.setChatId(chatId);
@@ -2410,6 +2414,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         watchLog.setUserId(sopLogs.getFsUserId());
         watchLog.setWatchType(watchType);
         watchLog.setCampPeriodTime(convertStringToDate(logsVo.getStartTime(),"yyyy-MM-dd"));
+        // 不在入队热路径查课程补 project,统一由 batchInsertFsCourseWatchLogs 批量补齐,避免 SOP 任务生成变慢
         enqueueWatchLog(watchLog);
     }
 
@@ -2804,6 +2809,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     )
     public void batchInsertFsCourseWatchLogs(List<FsCourseWatchLog> watchLogsToInsert) {
         try {
+            // 批量写入前统一补齐 project(队列路径可能漏写)
+            fsCourseWatchLogProjectFiller.fillBatchIfAbsent(watchLogsToInsert);
             fsCourseWatchLogMapper.insertFsCourseWatchLogBatch(watchLogsToInsert);
             log.info("批量插入 FsCourseWatchLog 完成,共插入 {} 条记录。", watchLogsToInsert.size());
         } catch (Exception e) {
@@ -3448,6 +3455,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             watchLog.setUserId(fsUserId);
             watchLog.setCampPeriodTime(convertStringToDate(startTime, "yyyy-MM-dd"));
 
+            // 同步落库:按 courseId 轻量取 project(有进程内缓存,同课程多客户几乎零开销)
+            Long project = fsCourseWatchLogProjectFiller.getProjectByCourseId(
+                    courseId != null ? courseId.longValue() : null);
+            if (project != null) {
+                watchLog.setProject(project);
+            }
             //存看课记录
             int i = fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
             return watchLog.getLogId();

+ 14 - 35
fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java

@@ -114,41 +114,20 @@ public interface FsCourseRedPacketLogMapper
             "</script>"})
     List<FsCourseRedPacketLogListPVO> selectRedPacketLogListVO(@Param("maps") FsCourseRedPacketLogParam param);
 
-    @Select({"<script> " +
-            "select l.*,v.title,u.nick_name as fsNickName,u.avatar as fsAvatar,u.phone,cu.nick_name company_user_name,c.company_name,qu.qw_user_name,fuc.course_name,u.phone as phoneNumber,cu.dept_id,w.send_type   from fs_course_red_packet_log l  \n" +
-            "left join fs_user_course_video v on v.video_id = l.video_id \n" +
-            "left join fs_user u on u.user_id = l.user_id \n" +
-            "left join fs_user_course fuc on fuc.course_id = l.course_id \n" +
-            "left join company_user cu on cu.user_id=l.company_user_id \n" +
-            "left join company c on c.company_id=cu.company_id \n" +
-            "LEFT JOIN qw_user qu on qu.id= l.qw_user_id  \n" +
-            "LEFT JOIN fs_course_watch_log w on w.log_id = l.watch_log_id  \n" +
-            "where 1=1   " +
-            "<if test = ' maps.userId !=null '> and l.user_id = #{maps.userId} </if>" +
-            "<if test = ' maps.logId !=null '> and l.log_id = #{maps.logId} </if>" +
-            "<if test = ' maps.watchLogId !=null '> and l.watch_log_id = #{maps.watchLogId} </if>" +
-            "<if test = ' maps.companyId !=null '> and l.company_id = #{maps.companyId} </if>" +
-            "<if test = ' maps.companyUserId !=null '> and l.company_user_id = #{maps.companyUserId} </if>" +
-            "<if test = ' maps.companyUserName !=null '> and cu.nick_name  like concat('%', #{maps.companyUserName}, '%') </if>" +
-            "<if test = ' maps.nickName !=null '> and u.nick_name  like concat('%', #{maps.nickName}, '%') </if>" +
-            "<if test = ' maps.courseId !=null '> and l.course_id = #{maps.courseId} </if>" +
-            "<if test = ' maps.videoId !=null '> and l.video_id = #{maps.videoId} </if>" +
-            "<if test = ' maps.periodId !=null '> and l.period_id = #{maps.periodId} </if>" +
-            "<if test = ' maps.status !=null '> and l.status = #{maps.status} </if>" +
-            "<if test = \"maps.phone !=null and maps.phone != '' \"> and u.phone = #{maps.phone} </if>" +
-            "<if test = ' maps.qwUserId !=null '> and l.qw_user_id = #{maps.qwUserId} </if>" +
-            "<if test = ' maps.sendType !=null '> and w.send_type = #{maps.sendType} </if>" +
-            "<if test=\"maps.sTime != null \">  and DATE(l.create_time) &gt;= DATE(#{maps.sTime})</if>\n" +
-            "<if test=\"maps.eTime != null \">  and DATE(l.create_time) &lt;= DATE(#{maps.eTime})</if>\n" +
-            "<if test=\"maps.userIds != null and maps.userIds.size() > 0\">\n" +
-            "                AND l.company_user_id IN\n" +
-            "                <foreach collection=\"maps.userIds\" open=\"(\" close=\")\" separator=\",\" item=\"item\">\n" +
-            "                    ${item}\n" +
-            "                </foreach>\n" +
-            "</if>" +
-            " order by l.log_id desc  "+
-            "</script>"})
-    List<FsCourseRedPacketLogListPVO> selectFsCourseRedPacketLogListVO(@Param("maps")FsCourseRedPacketLogParam fsCourseRedPacketLog);
+    /**
+     * 红包发放记录列表(XML 实现,避免 DATE() 导致无法走索引)
+     */
+    List<FsCourseRedPacketLogListPVO> selectFsCourseRedPacketLogListVO(@Param("maps") FsCourseRedPacketLogParam fsCourseRedPacketLog);
+
+    /**
+     * P0:分页列表仅查主键
+     */
+    List<Long> selectFsCourseRedPacketLogListIds(@Param("maps") FsCourseRedPacketLogParam fsCourseRedPacketLog);
+
+    /**
+     * P0:按主键回表查询详情
+     */
+    List<FsCourseRedPacketLogListPVO> selectFsCourseRedPacketLogListByLogIds(@Param("logIds") List<Long> logIds);
 
     @Select("SELECT company_id, SUM(amount) as money  FROM fs_course_red_packet_log    WHERE status in (0,1) and create_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)   AND create_time < CURDATE()  GROUP BY company_id " )
     List<RedPacketMoneyVO> selectFsCourseRedPacketLogByCompany();

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

@@ -130,6 +130,16 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
 
     List<FsCourseWatchLogListVO> selectFsCourseWatchLogListVO(@Param("maps") FsCourseWatchLogListParam param);
 
+    /**
+     * P0:分页列表仅查主键(供 COUNT / LIMIT)
+     */
+    List<Long> selectFsCourseWatchLogListIds(@Param("maps") FsCourseWatchLogListParam param);
+
+    /**
+     * P0:按主键回表查询详情
+     */
+    List<FsCourseWatchLogListVO> selectFsCourseWatchLogListByLogIds(@Param("logIds") List<Long> logIds);
+
     List<FsCourseWatchLogListVO> selectFsCourseWatchLogListByParam(FsCourseWatchLogListParam param);
 
     @Select("select l.*,v.title,c.course_name from fs_course_watch_log l LEFT JOIN fs_user_course_video v ON v.video_id = l.video_id LEFT JOIN fs_user_course c ON c.course_id = l.course_id WHERE l.qw_external_contact_id =#{id} ORDER BY l.create_time  desc LIMIT 1  ")
@@ -806,6 +816,11 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
 
     List<FsCoureseWatchLogVO> selectUserIdList(FsCourseWatchLogParam fsCourseWatchLog);
 
+    /**
+     * 完播统计:按当前筛选条件汇总全部公司(不分页)
+     */
+    FsCoureseWatchLogVO selectUserIdListSummary(FsCourseWatchLogParam fsCourseWatchLog);
+
     /**
      * 查询会员在指定营期和销售下的项目ID列表
      */

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

@@ -70,6 +70,11 @@ public interface IFsCourseLinkService
 
     R createLinkUrlWc(FsCourseLinkCreateParam param);
 
+    /**
+     * 欢迎语飞书(授权)链接:入库 feishuNeedAuth=1,返回 uuid+appid 领取页 URL(不即时建飞书文档)
+     */
+    R createFeishuAuthLinkUrlWc(FsCourseLinkCreateParam param);
+
     String createLinkUrlWcmini(FsCourseLinkCreateParam param);
 
     R getRealLink(String link);

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

@@ -169,6 +169,11 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
     List<StatisticsTable> selectAppIdList();
     List<FsCoureseWatchLogVO> selectUserIdList(FsCourseWatchLogParam fsCourseWatchLog);
 
+    /**
+     * 完播统计合计(当前筛选条件下全部公司,不分页)
+     */
+    FsCoureseWatchLogVO selectUserIdListSummary(FsCourseWatchLogParam fsCourseWatchLog);
+
     /**
      * 看课统计报表
      * @param param

+ 46 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseLinkServiceImpl.java

@@ -29,6 +29,7 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.utils.FsCoursePlaySourceConfigQuery;
 import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.his.config.FsSysConfig;
 import com.fs.his.utils.ConfigUtil;
 import com.fs.his.utils.HttpUtil;
@@ -101,6 +102,9 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
 
+    @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
 
@@ -351,6 +355,46 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
         return R.error("短链生成失败!");
     }
 
+    /**
+     * 欢迎语飞书(授权):对齐 SOP 飞书 NEW(contentType=18)——
+     * 短链入库 feishuNeedAuth=1,发出 {realLinkDomainName}/?uuid={link}&appid={feishuMpAppId},
+     * 领取时再走 getFeishuCourseNewLink,发送瞬间不建飞书文档。
+     */
+    @Override
+    public R createFeishuAuthLinkUrlWc(FsCourseLinkCreateParam param) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (config == null || StringUtils.isEmpty(config.getRealLinkDomainName())) {
+            return R.error("飞书授权链接生成失败:未配置通用看课域名");
+        }
+        if (StringUtils.isEmpty(config.getFeishuMpAppId())) {
+            return R.error("飞书授权链接生成失败:未配置飞书服务号AppID");
+        }
+        if (param.getVideoId() == null || param.getCourseId() == null) {
+            return R.error("飞书授权链接生成失败:课程或视频ID为空");
+        }
+        Date sendTime = param.getSendTime() != null ? param.getSendTime() : new Date();
+        FsCourseLink link = createFsCourseLink(param.getCorpId(), sendTime, param.getCourseId(), param.getVideoId(),
+                param.getQwUserId(), param.getCompanyUserId(), param.getCompanyId(), param.getQwExternalId(), 0);
+        // 固定需授权,对齐飞书授权看课链路
+        link.setFeishuNeedAuth(1);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        link.setRealLink(realLink + courseJson);
+        link.setUpdateTime(createUpdateTime(param.getDays(), sendTime, config));
+        if (fsCourseLinkMapper.insertFsCourseLink(link) <= 0) {
+            return R.error("飞书授权链接生成失败:短链入库失败");
+        }
+        String domain = config.getRealLinkDomainName().trim();
+        if (!domain.endsWith("/")) {
+            domain = domain + "/";
+        }
+        String url = domain + "?uuid=" + link.getLink() + "&appid=" + config.getFeishuMpAppId().trim();
+        return R.ok("飞书授权链接生成").put("url", url);
+    }
+
     @Override
     public String createLinkUrlWcmini(FsCourseLinkCreateParam param) {
         String json = configService.selectConfigByKey("course.config");
@@ -697,6 +741,8 @@ public class FsCourseLinkServiceImpl implements IFsCourseLinkService
             watchLog.setLogType(3);
             watchLog.setUserId(fsUserId);
             watchLog.setCampPeriodTime(startTime);
+            // 企微看课补齐 project,避免按项目完播统计丢失
+            fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
             //存看课记录
             fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
         }catch (Exception e){

+ 28 - 1
fs-service/src/main/java/com/fs/course/service/impl/FsCourseRedPacketLogServiceImpl.java

@@ -11,6 +11,8 @@ import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.fs.common.core.domain.R;
+import com.github.pagehelper.Page;
+import com.github.pagehelper.PageHelper;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.cache.ICompanyDeptCacheService;
@@ -194,7 +196,32 @@ public class FsCourseRedPacketLogServiceImpl implements IFsCourseRedPacketLogSer
 
     @Override
     public List<FsCourseRedPacketLogListPVO> selectFsCourseRedPacketLogListVO(FsCourseRedPacketLogParam fsCourseRedPacketLog) {
-        List<FsCourseRedPacketLogListPVO> list = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogListVO(fsCourseRedPacketLog);
+        List<FsCourseRedPacketLogListPVO> list;
+        // P0:分页场景先查主键再回表,COUNT 不再扫多表 JOIN
+        Page<?> localPage = PageHelper.getLocalPage();
+        if (localPage != null) {
+            List<Long> logIds = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogListIds(fsCourseRedPacketLog);
+            Page<Long> idPage = (Page<Long>) logIds;
+            long total = idPage.getTotal();
+            int pageNum = idPage.getPageNum();
+            int pageSize = idPage.getPageSize();
+            PageHelper.clearPage();
+            List<FsCourseRedPacketLogListPVO> detailList;
+            if (CollectionUtil.isEmpty(logIds)) {
+                detailList = new ArrayList<>();
+            } else {
+                List<FsCourseRedPacketLogListPVO> unordered = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogListByLogIds(logIds);
+                Map<Long, FsCourseRedPacketLogListPVO> byId = unordered.stream()
+                        .collect(Collectors.toMap(FsCourseRedPacketLogListPVO::getLogId, a -> a, (a, b) -> a));
+                detailList = logIds.stream().map(byId::get).filter(Objects::nonNull).collect(Collectors.toList());
+            }
+            Page<FsCourseRedPacketLogListPVO> resultPage = new Page<>(pageNum, pageSize);
+            resultPage.setTotal(total);
+            resultPage.addAll(detailList);
+            list = resultPage;
+        } else {
+            list = fsCourseRedPacketLogMapper.selectFsCourseRedPacketLogListVO(fsCourseRedPacketLog);
+        }
         // 应用到每个对象
         if(CollectionUtil.isNotEmpty(list)){
             list.forEach(item -> {

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

@@ -15,6 +15,7 @@ import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.DictUtils;
 import com.fs.common.utils.date.DateUtil;
+import com.github.pagehelper.Page;
 import com.github.pagehelper.PageHelper;
 import com.fs.company.cache.ICompanyCacheService;
 import com.fs.company.cache.ICompanyDeptCacheService;
@@ -24,6 +25,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RedisKeyScanner;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
@@ -130,6 +132,9 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private IFsUserCourseVideoCacheService fsUserCourseVideoCacheService;
 
+    @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+
     @Autowired
     private IFsUserCacheService fsUserCacheService;
 
@@ -204,6 +209,8 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
      */
     @Override
     public int insertFsCourseWatchLog(FsCourseWatchLog fsCourseWatchLog) {
+        // 写入前补齐 project,避免企微看课记录 project 为空导致按项目统计丢失
+        fsCourseWatchLogProjectFiller.fillIfAbsent(fsCourseWatchLog);
         fsCourseWatchLog.setCreateTime(DateUtils.getNowDate());
         return fsCourseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
     }
@@ -752,6 +759,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public List<FsCourseWatchLogListVO> selectFsCourseWatchLogListVO(FsCourseWatchLogListParam param) {
         prepareTagIdsForWatchLogList(param);
+        normalizeWatchLogTimeRange(param);
 
         // 因为selectFsCourseWatchLogListVO 这个方法中查询了看课日志记录表 需要限制创建时间必传
         if ((StringUtils.isEmpty(param.getSTime()) || StringUtils.isEmpty(param.getETime())) &&
@@ -772,7 +780,33 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             param.setIsVip(1);
         }
 
-        List<FsCourseWatchLogListVO> fsCourseWatchLogListVOS = fsCourseWatchLogMapper.selectFsCourseWatchLogListVO(param);
+        List<FsCourseWatchLogListVO> fsCourseWatchLogListVOS;
+        // P0:分页场景先查主键再回表,避免多表 JOIN 参与排序与 COUNT
+        Page<?> localPage = PageHelper.getLocalPage();
+        if (localPage != null) {
+            List<Long> logIds = fsCourseWatchLogMapper.selectFsCourseWatchLogListIds(param);
+            Page<Long> idPage = (Page<Long>) logIds;
+            long total = idPage.getTotal();
+            int pageNum = idPage.getPageNum();
+            int pageSize = idPage.getPageSize();
+            PageHelper.clearPage();
+            List<FsCourseWatchLogListVO> detailList;
+            if (CollectionUtil.isEmpty(logIds)) {
+                detailList = new ArrayList<>();
+            } else {
+                List<FsCourseWatchLogListVO> unordered = fsCourseWatchLogMapper.selectFsCourseWatchLogListByLogIds(logIds);
+                Map<Long, FsCourseWatchLogListVO> byId = unordered.stream()
+                        .collect(Collectors.toMap(FsCourseWatchLogListVO::getLogId, Function.identity(), (a, b) -> a));
+                detailList = logIds.stream().map(byId::get).filter(Objects::nonNull).collect(Collectors.toList());
+            }
+            Page<FsCourseWatchLogListVO> resultPage = new Page<>(pageNum, pageSize);
+            resultPage.setTotal(total);
+            resultPage.addAll(detailList);
+            fsCourseWatchLogListVOS = resultPage;
+        } else {
+            fsCourseWatchLogListVOS = fsCourseWatchLogMapper.selectFsCourseWatchLogListVO(param);
+        }
+
         List<FsUserCoursePeriod> fsUserCoursePeriods = userCoursePeriodService.selectFsUserCoursePeriodList(new FsUserCoursePeriod());
 
         // 将 fsUserCoursePeriods 转换为 Map,便于快速查找
@@ -1520,6 +1554,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public List<FsCourseWatchLogListVO> selectFsCourseWatchLogListVOexport(FsCourseWatchLogListParam param) {
         prepareTagIdsForWatchLogList(param);
+        normalizeWatchLogTimeRange(param);
 
         if (param.getSendType() == 1 && param.getPeriodETime() != null && param.getPeriodSTime() != null) {
             List<Long> periodIds = userCoursePeriodDaysService.selectFsUserCoursePeriodDaysByTime(param.getPeriodSTime(), param.getPeriodETime());
@@ -1809,6 +1844,30 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         return fsCourseWatchLogMapper.selectUserIdList(fsCourseWatchLog);
     }
 
+    @Override
+    public FsCoureseWatchLogVO selectUserIdListSummary(FsCourseWatchLogParam fsCourseWatchLog) {
+        FsCoureseWatchLogVO empty = emptyUserIdListSummary();
+        if (fsCourseWatchLog == null) {
+            return empty;
+        }
+        // 与列表共用小节解析;解析失败返回全 0
+        if (!prepareVideoIdsForUserIdList(fsCourseWatchLog)) {
+            return empty;
+        }
+        FsCoureseWatchLogVO summary = fsCourseWatchLogMapper.selectUserIdListSummary(fsCourseWatchLog);
+        return summary != null ? summary : empty;
+    }
+
+    private FsCoureseWatchLogVO emptyUserIdListSummary() {
+        FsCoureseWatchLogVO vo = new FsCoureseWatchLogVO();
+        vo.setCompanyName("合计");
+        vo.setTypeOneAll(0);
+        vo.setTypeTwoAll(0);
+        vo.setTypeAll(0);
+        vo.setAllUserId(0);
+        return vo;
+    }
+
     /**
      * 解析小节过滤口径:
      * 1) 未选小节,或勾选了该课程当前全部小节 → 按 course_id 统计(与「全选」一致,含历史小节日志)
@@ -1891,6 +1950,38 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         return result;
     }
 
+    /**
+     * 看课记录时间参数归一化:
+     * - 仅日期(yyyy-MM-dd)→ 补全为当天 00:00:00 / 23:59:59
+     * - 已带时分秒 → 原样使用
+     * 避免列表 SQL 对已含 23:59:59 的 eTime 再 DATE_ADD 一天,导致查出次日数据。
+     */
+    private void normalizeWatchLogTimeRange(FsCourseWatchLogListParam param) {
+        if (param == null) {
+            return;
+        }
+        param.setSTime(normalizeTimeBound(param.getSTime(), true));
+        param.setETime(normalizeTimeBound(param.getETime(), false));
+        param.setUpSTime(normalizeTimeBound(param.getUpSTime(), true));
+        param.setUpETime(normalizeTimeBound(param.getUpETime(), false));
+        param.setQecSTime(normalizeTimeBound(param.getQecSTime(), true));
+        param.setQecETime(normalizeTimeBound(param.getQecETime(), false));
+    }
+
+    /**
+     * @param start true=起始补 00:00:00;false=结束补 23:59:59
+     */
+    private String normalizeTimeBound(String value, boolean start) {
+        if (StringUtils.isEmpty(value)) {
+            return value;
+        }
+        String trimmed = value.trim();
+        if (trimmed.length() == 10) {
+            return trimmed + (start ? " 00:00:00" : " 23:59:59");
+        }
+        return trimmed;
+    }
+
     /**
      * 解析标签参数,对齐企微客户:支持 tagIdList / tagIds 逗号串 / List
      */

+ 9 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -46,6 +46,7 @@ import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.ICourseProjectSalesBindService;
 import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.param.newfs.*;
 import com.fs.course.service.*;
@@ -180,6 +181,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+    @Autowired
     private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
     @Autowired
     private IFsCourseWatchLogService courseWatchLogService;
@@ -1215,6 +1218,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         log.setLogType(3);
         log.setWatchType(2);
         log.setAppId(param.getAppId());
+        // 企微看课补齐 project,避免按项目完播统计丢失
+        fsCourseWatchLogProjectFiller.fillIfAbsent(log);
         logger.info("【群聊生成看课记录】:{}", param);
         courseWatchLogMapper.insertFsCourseWatchLog(log);
     }
@@ -3754,6 +3759,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
             watchLog.setUserId(fsUserId);
 
+            // 企微看课补齐 project,避免按项目完播统计丢失
+            fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
             //存看课记录
             courseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
 
@@ -4322,6 +4329,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             log.setDuration(0L);
             log.setCreateTime(new Date());
             log.setLogType(3);
+            // 企微看课补齐 project,避免按项目完播统计丢失
+            fsCourseWatchLogProjectFiller.fillIfAbsent(log);
             logger.info("【群聊生成看课记录】:{}", param);
             courseWatchLogMapper.insertFsCourseWatchLog(log);
         } catch (BeansException e) {

+ 125 - 0
fs-service/src/main/java/com/fs/course/utils/FsCourseWatchLogProjectFiller.java

@@ -0,0 +1,125 @@
+package com.fs.course.utils;
+
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCourse;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 看课记录写入前补齐 project(按 courseId / videoId 从课程表带出)。
+ * <p>
+ * 故意只依赖 Mapper,不依赖课程/视频 Cache/Service,避免:
+ * Filler → VideoCache → VideoServiceImpl → Filler 的 Spring 循环依赖。
+ * <p>
+ * 性能:本类自带 courseId→project、videoId→courseId 轻量缓存;
+ * SOP 队列入队路径应延后到批量入库再补齐。
+ */
+@Component
+public class FsCourseWatchLogProjectFiller {
+
+    /** 无 project / 无 course 时的占位,避免空值导致缓存穿透 */
+    private static final Long NULL_SENTINEL = Long.MIN_VALUE;
+
+    /**
+     * courseId -> project 轻量缓存
+     */
+    private static final Cache<Long, Long> COURSE_PROJECT_CACHE = Caffeine.newBuilder()
+            .maximumSize(5000)
+            .expireAfterWrite(10, TimeUnit.MINUTES)
+            .build();
+
+    /**
+     * videoId -> courseId,仅在未带 courseId 时使用
+     */
+    private static final Cache<Long, Long> VIDEO_COURSE_CACHE = Caffeine.newBuilder()
+            .maximumSize(10000)
+            .expireAfterWrite(10, TimeUnit.MINUTES)
+            .build();
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+    /**
+     * 按 courseId 取 project;结果带进程内轻量缓存,适合同一课程多客户循环。
+     */
+    public Long getProjectByCourseId(Long courseId) {
+        if (courseId == null) {
+            return null;
+        }
+        Long cached = COURSE_PROJECT_CACHE.getIfPresent(courseId);
+        if (cached != null) {
+            return NULL_SENTINEL.equals(cached) ? null : cached;
+        }
+        FsUserCourse course = fsUserCourseMapper.selectFsUserCourseByCourseId(courseId);
+        Long project = course != null ? course.getProject() : null;
+        COURSE_PROJECT_CACHE.put(courseId, project != null ? project : NULL_SENTINEL);
+        return project;
+    }
+
+    /**
+     * 若 project 为空,则根据 courseId(或 videoId 反查 courseId)补齐。
+     * 同步单条写入可用;SOP 队列入队请勿调用,改用 {@link #fillBatchIfAbsent}。
+     */
+    public void fillIfAbsent(FsCourseWatchLog log) {
+        if (log == null || log.getProject() != null) {
+            return;
+        }
+        Long courseId = resolveCourseId(log);
+        if (courseId == null) {
+            return;
+        }
+        Long project = getProjectByCourseId(courseId);
+        if (project != null) {
+            log.setProject(project);
+        }
+    }
+
+    /**
+     * 批量补齐;同 courseId 只解析一次,适合刷库前统一处理。
+     */
+    public void fillBatchIfAbsent(List<FsCourseWatchLog> logs) {
+        if (logs == null || logs.isEmpty()) {
+            return;
+        }
+        for (FsCourseWatchLog log : logs) {
+            fillIfAbsent(log);
+        }
+    }
+
+    private Long resolveCourseId(FsCourseWatchLog log) {
+        if (log.getCourseId() != null) {
+            return log.getCourseId();
+        }
+        if (log.getVideoId() == null) {
+            return null;
+        }
+        Long videoId = log.getVideoId();
+        Long cachedCourseId = VIDEO_COURSE_CACHE.getIfPresent(videoId);
+        if (cachedCourseId != null) {
+            if (!NULL_SENTINEL.equals(cachedCourseId)) {
+                log.setCourseId(cachedCourseId);
+                return cachedCourseId;
+            }
+            return null;
+        }
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (video == null || video.getCourseId() == null) {
+            VIDEO_COURSE_CACHE.put(videoId, NULL_SENTINEL);
+            return null;
+        }
+        VIDEO_COURSE_CACHE.put(videoId, video.getCourseId());
+        log.setCourseId(video.getCourseId());
+        return video.getCourseId();
+    }
+}

+ 18 - 7
fs-service/src/main/java/com/fs/erp/service/impl/DfOrderServiceImpl.java

@@ -767,14 +767,25 @@ public class DfOrderServiceImpl implements IErpOrderService {
                                 if (fastGptRole.getLogistics() == 0) {
                                     log.error("物流功能未开启,roleId:" + qwUser.getFastGptRoleId() + "订单号:" + orderId);
                                 }
-                                WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
-                                wxWorkUserId2VidDTO.setOpenid(Collections.singletonList(externalContact.getExternalUserId()));
-                                wxWorkUserId2VidDTO.setUuid(qwUser.getUid());
-                                WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> WxWorkVid2UserIdRespDTO = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO, qwUser.getServerId());
-                                List<WxWorkVid2UserIdRespDTO> data = WxWorkVid2UserIdRespDTO.getData();
+                                Long sendId = externalContact.getWxUserId();
+                                if (sendId == null || sendId <= 0) {
+                                    WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
+                                    wxWorkUserId2VidDTO.setOpenid(Collections.singletonList(externalContact.getExternalUserId()));
+                                    wxWorkUserId2VidDTO.setUuid(qwUser.getUid());
+                                    WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> WxWorkVid2UserIdRespDTO = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO, qwUser.getServerId());
+                                    List<WxWorkVid2UserIdRespDTO> data = WxWorkVid2UserIdRespDTO.getData();
+                                    if (data != null && !data.isEmpty()) {
+                                        sendId = data.get(0).getUser_id();
+                                        if (sendId != null && externalContact.getId() != null) {
+                                            QwExternalContact upd = new QwExternalContact();
+                                            upd.setId(externalContact.getId());
+                                            upd.setWxUserId(sendId);
+                                            externalContactService.updateQwExternalContact(upd);
+                                        }
+                                    }
+                                }
                                 StringBuilder sBuilder = new StringBuilder();
-                                if (data != null && !data.isEmpty()) {
-                                    Long sendId = data.get(0).getUser_id();
+                                if (sendId != null) {
                                     switch (order.getStatus()) {
                                         case -1:
                                             break;

+ 152 - 20
fs-service/src/main/java/com/fs/ipad/IpadSendUtils.java

@@ -39,7 +39,9 @@ import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
@@ -295,6 +297,12 @@ public class IpadSendUtils {
         }catch (Exception e){
             log.error("获取外部联系人ID失败", e);
         }
+        // 对齐群聊 roomid:先查库已有 wx_user_id 则直接用,避免重复 UserId2Vid
+        QwExternalContact contact = findExternalContactForVid(vo);
+        if(contact != null && contact.getWxUserId() != null && contact.getWxUserId() > 0){
+            cacheUserVid(vo.getExId(), contact.getWxUserId());
+            return contact.getWxUserId();
+        }
         WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
         wxWorkUserId2VidDTO.setOpenid(Collections.singletonList(vo.getExId()));
         wxWorkUserId2VidDTO.setCorpid(vo.getCorpId());
@@ -302,16 +310,90 @@ public class IpadSendUtils {
         wxWorkUserId2VidDTO.setUuid(vo.getUuid());
         WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> WxWorkVid2UserIdRespDTO = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO, vo.getServerId());
         List<WxWorkVid2UserIdRespDTO> data = WxWorkVid2UserIdRespDTO.getData();
-        if(data.isEmpty()) {
+        if(data == null || data.isEmpty()) {
             log.error("未找到用户数据,基础数据:{},请求数据:{},返回数据:{}", vo, JSON.toJSONString(wxWorkUserId2VidDTO), JSON.toJSONString(WxWorkVid2UserIdRespDTO));
             throw new BaseException("未找到用户:" + vo.getId());
         }
+        Long vid = data.get(0).getUser_id();
+        // 对齐群聊 roomid:转换后立刻回写库(修改 wx_user_id)
+        saveExternalContactVid(contact, vo.getExId(), vo.getCorpId(), vid);
+        cacheUserVid(vo.getExId(), vid);
+        return vid;
+    }
+
+    /**
+     * 按外部联系人定位客户记录(优先 qw_user_id,其次 corp + external_user_id)
+     */
+    private QwExternalContact findExternalContactForVid(BaseVo vo) {
+        if (StringUtils.isEmpty(vo.getExId())) {
+            return null;
+        }
         try {
-            redisCache.setCacheObject(USER_KEY + vo.getExId(), data.get(0).getUser_id().toString());
-        }catch (Exception e){
+            if (vo.getQwUserId() != null) {
+                QwExternalContact query = new QwExternalContact();
+                query.setExternalUserId(vo.getExId());
+                query.setQwUserId(vo.getQwUserId());
+                if (StringUtils.isNotEmpty(vo.getCorpId())) {
+                    query.setCorpId(vo.getCorpId());
+                }
+                List<QwExternalContact> byQwUser = qwExternalContactMapper.selectQwExternalContactList(query);
+                if (byQwUser != null && !byQwUser.isEmpty()) {
+                    return preferWithWxUserId(byQwUser);
+                }
+            }
+            if (StringUtils.isNotEmpty(vo.getCorpId())) {
+                List<QwExternalContact> list = qwExternalContactMapper
+                        .selectQwExternalContactByExternalUserId(vo.getExId(), vo.getCorpId());
+                if (list != null && !list.isEmpty()) {
+                    return preferWithWxUserId(list);
+                }
+            }
+        } catch (Exception e) {
+            log.error("查询外部联系人 wx_user_id 失败, exId={}, corpId={}", vo.getExId(), vo.getCorpId(), e);
+        }
+        return null;
+    }
+
+    private QwExternalContact preferWithWxUserId(List<QwExternalContact> list) {
+        for (QwExternalContact c : list) {
+            if (c.getWxUserId() != null && c.getWxUserId() > 0) {
+                return c;
+            }
+        }
+        return list.get(0);
+    }
+
+    /**
+     * 回写 PAD VID,对齐群聊 chatIds:setRoomid + updateQwGroupChat
+     */
+    private void saveExternalContactVid(QwExternalContact contact, String externalUserId, String corpId, Long vid) {
+        if (vid == null) {
+            return;
+        }
+        try {
+            if (contact != null && contact.getId() != null) {
+                QwExternalContact update = new QwExternalContact();
+                update.setId(contact.getId());
+                update.setWxUserId(vid);
+                qwExternalContactMapper.updateQwExternalContact(update);
+            } else if (StringUtils.isNotEmpty(externalUserId) && StringUtils.isNotEmpty(corpId)) {
+                qwExternalContactMapper.updateWxUserIdByExternalUserId(externalUserId, corpId, vid);
+            }
+        } catch (Exception e) {
+            log.error("回写外部联系人 wx_user_id 失败, externalUserId={}, corpId={}, vid={}",
+                    externalUserId, corpId, vid, e);
+        }
+    }
+
+    private void cacheUserVid(String exId, Long vid) {
+        if (StringUtils.isEmpty(exId) || vid == null) {
+            return;
+        }
+        try {
+            redisCache.setCacheObject(USER_KEY + exId, vid.toString());
+        } catch (Exception e) {
             log.error("存储获外部联系人ID失败", e);
         }
-        return data.get(0).getUser_id();
     }
 
     /**
@@ -616,26 +698,71 @@ public class IpadSendUtils {
     }
 
     private List<Long> changeUserIds(QwUser qwUser, List<QwExternalContact> joinList) {
-        BaseVo vo = new BaseVo();
-        vo.setId(qwUser.getId());
-        vo.setRoom(false);
-        vo.setUuid(qwUser.getUid());
-        vo.setExIdList(joinList.stream().map(QwExternalContact::getExternalUserId).collect(Collectors.toList()));
-        vo.setServerId(qwUser.getServerId());
-        return userIdList(vo);
-    }
-    private List<Long> userIdList(BaseVo vo){
-        if(vo.isRoom()){
-            return Collections.emptyList();
+        Map<String, Long> vidByOpenId = new HashMap<>();
+        List<String> needConvert = new ArrayList<>();
+        for (QwExternalContact contact : joinList) {
+            if (contact == null || StringUtils.isEmpty(contact.getExternalUserId())) {
+                continue;
+            }
+            if (contact.getWxUserId() != null && contact.getWxUserId() > 0) {
+                vidByOpenId.put(contact.getExternalUserId(), contact.getWxUserId());
+            } else if (!needConvert.contains(contact.getExternalUserId())) {
+                needConvert.add(contact.getExternalUserId());
+            }
+        }
+        if (!needConvert.isEmpty()) {
+            BaseVo vo = new BaseVo();
+            vo.setId(qwUser.getId());
+            vo.setRoom(false);
+            vo.setUuid(qwUser.getUid());
+            vo.setExIdList(needConvert);
+            vo.setServerId(qwUser.getServerId());
+            vo.setCorpId(qwUser.getCorpId());
+            List<WxWorkVid2UserIdRespDTO> converted = convertUserIdList(vo);
+            for (WxWorkVid2UserIdRespDTO item : converted) {
+                if (item == null || item.getUser_id() == null || StringUtils.isEmpty(item.getOpenid())) {
+                    continue;
+                }
+                vidByOpenId.put(item.getOpenid(), item.getUser_id());
+            }
+            // 按主键修改 wx_user_id(对齐群聊 updateQwGroupChat)
+            for (QwExternalContact contact : joinList) {
+                if (contact == null || contact.getId() == null || StringUtils.isEmpty(contact.getExternalUserId())) {
+                    continue;
+                }
+                if (contact.getWxUserId() != null && contact.getWxUserId() > 0) {
+                    continue;
+                }
+                Long vid = vidByOpenId.get(contact.getExternalUserId());
+                if (vid == null) {
+                    continue;
+                }
+                saveExternalContactVid(contact, contact.getExternalUserId(), qwUser.getCorpId(), vid);
+            }
         }
+        List<Long> result = new ArrayList<>();
+        for (QwExternalContact contact : joinList) {
+            if (contact == null || StringUtils.isEmpty(contact.getExternalUserId())) {
+                continue;
+            }
+            Long vid = vidByOpenId.get(contact.getExternalUserId());
+            if (vid == null) {
+                throw new BaseException("未找到用户:" + contact.getExternalUserId());
+            }
+            result.add(vid);
+        }
+        return result;
+    }
+
+    private List<WxWorkVid2UserIdRespDTO> convertUserIdList(BaseVo vo){
         WxWorkUserId2VidDTO wxWorkUserId2VidDTO = new WxWorkUserId2VidDTO();
         wxWorkUserId2VidDTO.setOpenid(vo.getExIdList());
         wxWorkUserId2VidDTO.setCorpid(vo.getCorpId());
         wxWorkUserId2VidDTO.setScorpid(vo.getCorpCode());
         wxWorkUserId2VidDTO.setUuid(vo.getUuid());
         WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> resutl = wxWorkService.UserId2Vid(wxWorkUserId2VidDTO, vo.getServerId());
-        List<?> rawData = resutl.getData(); // 先用一个泛型不确定的列表接收
-        if(rawData.isEmpty()) {
+        List<?> rawData = resutl.getData();
+        if(rawData == null || rawData.isEmpty()) {
             log.error("未找到用户数据,基础数据:{},请求数据:{},返回数据:{}", vo, JSON.toJSONString(wxWorkUserId2VidDTO), JSON.toJSONString(resutl));
             throw new BaseException("未找到用户:" + vo.getId());
         }
@@ -644,15 +771,20 @@ public class IpadSendUtils {
             if (item instanceof WxWorkVid2UserIdRespDTO) {
                 data.add((WxWorkVid2UserIdRespDTO) item);
             } else if (item instanceof JSONObject) {
-                // 如果是JSONObject,则需要将其转换为目标类型
                 WxWorkVid2UserIdRespDTO dto = ((JSONObject) item).toJavaObject(WxWorkVid2UserIdRespDTO.class);
                 data.add(dto);
             } else {
                 log.warn("未知类型: {}", item.getClass());
-                // 根据实际情况处理,例如跳过或抛出更明确的异常
             }
         }
-        return data.stream().map(WxWorkVid2UserIdRespDTO::getUser_id).collect(Collectors.toList());
+        return data;
+    }
+
+    private List<Long> userIdList(BaseVo vo){
+        if(vo.isRoom()){
+            return Collections.emptyList();
+        }
+        return convertUserIdList(vo).stream().map(WxWorkVid2UserIdRespDTO::getUser_id).collect(Collectors.toList());
     }
 
     public void sendTxtAtMsgVo(WxSendTextAtMsgTwoDTO dto, Long serverId){

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

@@ -158,5 +158,10 @@ public class QwExternalContact extends BaseEntity
     // 广告链路唯一id
     private String traceId;
 
+    /**
+     * PAD 协议外部联系人 VID(UserId2Vid 转换结果,对齐群聊 roomid)
+     */
+    private Long wxUserId;
+
 
 }

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

@@ -97,6 +97,18 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
     @Select("SELECT * FROM qw_external_contact WHERE external_user_id = #{externalUserId}  AND corp_id=#{corpId} and user_id=#{qwUserId} limit 1")
     public QwExternalContact selectQwExternalContactByExternalUserIdAndQwUserId(@Param("externalUserId") String externalUserId,@Param("corpId") String corpId,@Param("qwUserId") String qwUserId);
 
+    /**
+     * 回写 PAD 外部联系人 VID(wx_user_id)
+     */
+    int updateWxUserIdById(@Param("id") Long id, @Param("wxUserId") Long wxUserId);
+
+    /**
+     * 按外部联系人 + 主体回写 PAD VID(同一 openid 在同 corp 下 VID 一致)
+     */
+    int updateWxUserIdByExternalUserId(@Param("externalUserId") String externalUserId,
+                                       @Param("corpId") String corpId,
+                                       @Param("wxUserId") Long wxUserId);
+
     @Select("<script>" +
             "SELECT " +
             " COUNT( DISTINCT qec.external_user_id ) AS count " +

+ 5 - 0
fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java

@@ -10,6 +10,7 @@ import com.fs.course.domain.FsCourseRealLink;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
 import com.fs.qw.domain.QwCompany;
@@ -72,6 +73,8 @@ public class AsyncQwAiChatSopService {
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
     @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+    @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
 
     @Autowired
@@ -306,6 +309,8 @@ public class AsyncQwAiChatSopService {
             watchLog.setWatchType(watchType);
             watchLog.setCampPeriodTime(sopUserLogsInfoService.convertStringToDate(startTime, "yyyy-MM-dd HH:mm:ss"));
 
+            // 企微看课补齐 project,避免按项目完播统计丢失
+            fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
             //存看课记录
             fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
             return watchLog.getLogId();

+ 6 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwContactWayServiceImpl.java

@@ -9,6 +9,7 @@ import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.qw.domain.*;
@@ -63,6 +64,9 @@ public class QwContactWayServiceImpl implements IQwContactWayService
 
     @Autowired
     private FsCourseWatchLogMapper  watchLogMapper;
+
+    @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
     /**
      * 查询企微活码
      *
@@ -519,6 +523,8 @@ public class QwContactWayServiceImpl implements IQwContactWayService
         watchLog.setLogType(3);
         watchLog.setUserId(0L);
         watchLog.setWatchType(watchType);
+        // 企微看课补齐 project,避免按项目完播统计丢失
+        fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
         watchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
     }
 

+ 14 - 3
fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java

@@ -29,6 +29,7 @@ import com.fs.course.mapper.FsCourseSopLogsMapper;
 import com.fs.course.mapper.FsCourseSopMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.param.FsCourseListBySidebarParam;
 import com.fs.course.service.IFsCourseLinkService;
@@ -143,6 +144,8 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     @Autowired
     private FsCourseWatchLogMapper  watchLogMapper;
     @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+    @Autowired
     private QwExternalContactBlackLogMapper qwExternalContactBlackLogMapper;
     @Autowired
     QwApiService qwApiService;
@@ -4281,8 +4284,9 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(corpId);
 
         attachments.forEach(att -> {
-            // 生成短链的条件判断
+            // 生成短链的条件判断(普通 H5 / 飞书授权)
             if ("link".equals(att.getMsgtype())
+                    && att.getLink() != null
                     && !StringUtil.strIsNullOrEmpty(att.getLink().getCourseId())
                     && !StringUtil.strIsNullOrEmpty(att.getLink().getVideoId())) {
                 if(CloudHostUtils.hasCloudHostName("木易华康")){
@@ -4300,11 +4304,16 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
                     param.setQwExternalId(qwExternalId);
                     param.setSendTime(new Date());
 
-                    // 调用服务生成短链
-                    R linkUrl = iFsCourseLinkService.createLinkUrlWc(param);
+                    boolean feishuAuth = "feishu_auth".equals(att.getLink().getLinkType());
+                    // 飞书(授权):对齐 SOP 飞书 NEW,发出 uuid+appid;否则普通 H5 短链
+                    R linkUrl = feishuAuth
+                            ? iFsCourseLinkService.createFeishuAuthLinkUrlWc(param)
+                            : iFsCourseLinkService.createLinkUrlWc(param);
 
                     if (linkUrl != null && linkUrl.get("url") != null) {
                         att.getLink().setUrl(linkUrl.get("url").toString());
+                    } else if (feishuAuth) {
+                        logger.error("欢迎语生成飞书授权链接失败:{}", linkUrl != null ? linkUrl.get("msg") : "null");
                     }
                     //生成观看记录
                     addWatchLogIfNeeded(Integer.valueOf(att.getLink().getVideoId()),
@@ -4403,6 +4412,8 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         watchLog.setLogType(3);
         watchLog.setUserId(0L);
         watchLog.setWatchType(watchType);
+        // 企微看课补齐 project,避免按项目完播统计丢失
+        fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
         watchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
     }
 

+ 1 - 1
fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java

@@ -153,7 +153,7 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
         /** 飞书应用账号 id(contentType=17 时使用) */
         private Long feishuAccountId;
 
-        /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+        /** 飞书看课是否需要客户授权:contentType=17 固定为 0(免授权);历史 1 仅作兼容字段 */
         private Integer feishuNeedAuth;
 
         /**

+ 1 - 1
fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java

@@ -180,7 +180,7 @@ public class QwSopTempSetting implements Serializable{
             /** 飞书应用账号 id(contentType=17 时使用) */
             private Long feishuAccountId;
 
-            /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+            /** 飞书看课是否需要客户授权:contentType=17 固定为 0(免授权);历史 1 仅作兼容字段 */
             private Integer feishuNeedAuth;
 
             public boolean isRandomSendEnabled() {

+ 5 - 0
fs-service/src/main/java/com/fs/qwApi/param/SendWelcomeMsgParam.java

@@ -36,6 +36,11 @@ public class SendWelcomeMsgParam {
             private String courseId; // 链接的课程id
             private String videoId; // 链接的课节id
             private Integer expiresDays; // 链接的过期时间
+            /**
+             * 链接子类型:null/h5=普通H5短链;feishu_auth=飞书(授权)
+             * 发送给企微时仍为 msgtype=link
+             */
+            private String linkType;
         }
         @Data
         // 用于小程序附件

+ 24 - 19
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
 import com.fs.feishu.service.FeiShuService;
@@ -157,6 +158,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
 
+    @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+
     @Autowired
     private AsyncSopTestService asyncSopTestService;
 
@@ -776,8 +780,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             case "16":
                                 createVoiceUrl(st, companyUserId, qwSop);
                                 break;
-                            //飞书看课
+                            //飞书看课(固定免授权)
                             case "17":
+                                st.setFeishuNeedAuth(0);
                                 Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                                 Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
                                 Long companyIdLong = qwUser.getCompanyId();
@@ -790,7 +795,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
                                 String shortCode = feishuH5Link.get("link");
                                 String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
-                                        companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                        companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
                                         param.getSopId(), null, externalUserId, vo.getFsUserId(),
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
                                         param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
@@ -1018,8 +1023,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                 }
                                 break;
-                            //飞书看课(发群:chatId 是企微字符串,不能转 Long,与小程序发群一样挂 chatId)
+                            //飞书看课(发群:chatId 是企微字符串,不能转 Long,与小程序发群一样挂 chatId;固定免授权
                             case "17":
+                                st.setFeishuNeedAuth(0);
                                 Integer videoId18Group = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                                 Integer courseId18Group = param.getCourseId() != null ? param.getCourseId().intValue() : null;
                                 Long companyIdLongGroup = Long.valueOf(qwUser.getCompanyId().toString());
@@ -1031,7 +1037,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), null, config, groupChat.getChatId());
                                 String shortCodeGroup = feishuH5LinkGroup.get("link");
                                 String feishuLinkGroup = resolveFeishuSendLinkSafely(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(),
-                                        companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                        companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), 0,
                                         param.getSopId(), null, null, null,
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
                                         param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
@@ -1354,8 +1360,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 throw new RuntimeException(e);
                             }
                             break;
-                        //飞书看课
+                        //飞书看课(固定免授权)
                         case "17":
+                            st.setFeishuNeedAuth(0);
                             Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
                             Long companyIdLong = Long.parseLong(companyId);
@@ -1364,11 +1371,6 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 break;
                             }
-                            if (FeiShuService.isAuthRequired(st.getFeishuNeedAuth())) {
-                                addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
-                                        item.getFsUserId(), qwUserId, companyUserId, companyId,
-                                        item.getExternalId(), param.getStartTime(), createTime, 2);
-                            }
                             String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
@@ -1384,7 +1386,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, qwUserId, companyUserId, companyId, item.getExternalId(), config);
                             String shortCode = feishuH5Link.get("link");
                             String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
-                                    companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                    companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
                                     param.getSopId(), item.getUserLogsId(), item.getExternalId(), item.getFsUserId(),
                                     qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
@@ -2129,8 +2131,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     log.info("处理订阅号文章类型(processSetting),保留字段: articleUrl={}, miniprogramPicUrl={}, miniprogramPage={}",
                             st.getArticleUrl(), st.getMiniprogramPicUrl(), st.getMiniprogramPage());
                     break;
-                //飞书看课
+                //飞书看课(固定免授权)
                 case "17":
+                    st.setFeishuNeedAuth(0);
                     Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                     Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
                     Long companyIdLong = Long.parseLong(companyId);
@@ -2148,15 +2151,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         break;
                     }
-                    if (FeiShuService.isAuthRequired(st.getFeishuNeedAuth())) {
-                        addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
-                                item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
-                                item.getExternalId(), item.getStartTime(), dataTime, 2);
-                    }
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
                     String shortLink = feishuH5Link.get("link");
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
-                            companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                            companyUserIdLong, shortLink, st.getFeishuAccountId(), 0,
                             item.getSopId(), item.getUserLogsId(), externalId, item.getFsUserId(),
                             String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
                             param.getCorpId(), dataTime, item.getStartTime(), feishuShardAllocator);
@@ -2274,7 +2272,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, chatId);
         link.setFeishuAccountId(setting.getFeishuAccountId());
-        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
+        // 飞书旧链接(contentType=17)固定免授权
+        link.setFeishuNeedAuth(0);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(courseMap);
@@ -2488,6 +2487,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             watchLog.setWatchType(watchType);
             watchLog.setCampPeriodTime(convertStringToDate(startTime,"yyyy-MM-dd"));
 
+            // 同步落库:按 courseId 轻量取 project(有进程内缓存)
+            Long project = fsCourseWatchLogProjectFiller.getProjectByCourseId(
+                    courseId != null ? courseId.longValue() : null);
+            if (project != null) {
+                watchLog.setProject(project);
+            }
             //存看课记录
             fsCourseWatchLogMapper.insertOrUpdateFsCourseWatchLog(watchLog);
         }catch (Exception e){

+ 5 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsServiceImpl.java

@@ -11,6 +11,7 @@ import com.fs.common.utils.date.DateUtil;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.crm.domain.CrmMsg;
@@ -117,6 +118,8 @@ public class SopUserLogsServiceImpl implements ISopUserLogsService {
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
     @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+    @Autowired
     private QwUserMapper qwUserMapper;
     @Autowired
     private CrmMsgMapper crmMsgMapper;
@@ -685,6 +688,8 @@ public class SopUserLogsServiceImpl implements ISopUserLogsService {
                                 if (fsCourseWatchLogs.isEmpty()) {
                                     watchLog.setLogType(3);
                                     watchLog.setCreateTime(new Date());
+                                    // 企微看课补齐 project,避免按项目完播统计丢失
+                                    fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
                                     fsCourseWatchLogMapper.insertFsCourseWatchLog(watchLog);
                                 }
 

+ 75 - 0
fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml

@@ -319,4 +319,79 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </select>
 
 
+
+    <!-- P0:红包列表去掉 DATE(),分页先查主键再回表 -->
+    <sql id="RedPacketLogListWhere">
+        <if test="maps.userId !=null"> and l.user_id = #{maps.userId} </if>
+        <if test="maps.logId !=null"> and l.log_id = #{maps.logId} </if>
+        <if test="maps.watchLogId !=null"> and l.watch_log_id = #{maps.watchLogId} </if>
+        <if test="maps.companyId !=null"> and l.company_id = #{maps.companyId} </if>
+        <if test="maps.companyUserId !=null"> and l.company_user_id = #{maps.companyUserId} </if>
+        <if test="maps.companyUserName !=null"> and cu.nick_name like concat('%', #{maps.companyUserName}, '%') </if>
+        <if test="maps.nickName !=null"> and u.nick_name like concat('%', #{maps.nickName}, '%') </if>
+        <if test="maps.courseId !=null"> and l.course_id = #{maps.courseId} </if>
+        <if test="maps.videoId !=null"> and l.video_id = #{maps.videoId} </if>
+        <if test="maps.periodId !=null"> and l.period_id = #{maps.periodId} </if>
+        <if test="maps.status !=null"> and l.status = #{maps.status} </if>
+        <if test="maps.phone !=null and maps.phone != ''"> and u.phone = #{maps.phone} </if>
+        <if test="maps.qwUserId !=null"> and l.qw_user_id = #{maps.qwUserId} </if>
+        <if test="maps.sendType !=null"> and w.send_type = #{maps.sendType} </if>
+        <if test="maps.sTime != null"> and l.create_time &gt;= DATE(#{maps.sTime}) </if>
+        <if test="maps.eTime != null"> and l.create_time &lt; DATE_ADD(DATE(#{maps.eTime}), INTERVAL 1 DAY) </if>
+        <if test="maps.userIds != null and maps.userIds.size() > 0">
+            AND l.company_user_id IN
+            <foreach collection="maps.userIds" open="(" close=")" separator="," item="item">
+                #{item}
+            </foreach>
+        </if>
+    </sql>
+
+    <select id="selectFsCourseRedPacketLogListVO" resultType="com.fs.course.vo.FsCourseRedPacketLogListPVO">
+        select l.*,v.title,u.nick_name as fsNickName,u.avatar as fsAvatar,u.phone,cu.nick_name company_user_name,c.company_name,qu.qw_user_name,fuc.course_name,u.phone as phoneNumber,cu.dept_id,w.send_type
+        from fs_course_red_packet_log l
+        left join fs_user_course_video v on v.video_id = l.video_id
+        left join fs_user u on u.user_id = l.user_id
+        left join fs_user_course fuc on fuc.course_id = l.course_id
+        left join company_user cu on cu.user_id=l.company_user_id
+        left join company c on c.company_id=cu.company_id
+        LEFT JOIN qw_user qu on qu.id= l.qw_user_id
+        LEFT JOIN fs_course_watch_log w on w.log_id = l.watch_log_id
+        where 1=1
+        <include refid="RedPacketLogListWhere"/>
+        order by l.log_id desc
+    </select>
+
+    <select id="selectFsCourseRedPacketLogListIds" resultType="java.lang.Long">
+        select l.log_id
+        from fs_course_red_packet_log l
+        <if test="maps.companyUserName != null">
+            left join company_user cu on cu.user_id=l.company_user_id
+        </if>
+        <if test="(maps.nickName != null) or (maps.phone != null and maps.phone != '')">
+            left join fs_user u on u.user_id = l.user_id
+        </if>
+        <if test="maps.sendType != null">
+            LEFT JOIN fs_course_watch_log w on w.log_id = l.watch_log_id
+        </if>
+        where 1=1
+        <include refid="RedPacketLogListWhere"/>
+        order by l.log_id desc
+    </select>
+
+    <select id="selectFsCourseRedPacketLogListByLogIds" resultType="com.fs.course.vo.FsCourseRedPacketLogListPVO">
+        select l.*,v.title,u.nick_name as fsNickName,u.avatar as fsAvatar,u.phone,cu.nick_name company_user_name,c.company_name,qu.qw_user_name,fuc.course_name,u.phone as phoneNumber,cu.dept_id,w.send_type
+        from fs_course_red_packet_log l
+        left join fs_user_course_video v on v.video_id = l.video_id
+        left join fs_user u on u.user_id = l.user_id
+        left join fs_user_course fuc on fuc.course_id = l.course_id
+        left join company_user cu on cu.user_id=l.company_user_id
+        left join company c on c.company_id=cu.company_id
+        LEFT JOIN qw_user qu on qu.id= l.qw_user_id
+        LEFT JOIN fs_course_watch_log w on w.log_id = l.watch_log_id
+        where l.log_id in
+        <foreach collection="logIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
 </mapper>

+ 242 - 17
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -139,17 +139,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test ='maps.externalUserName !=null and maps.externalUserName!=""'>
                 and qec.name  like concat('%', #{maps.externalUserName}, '%')
             </if>
+            <!-- 时间已在 Service 归一化为 yyyy-MM-dd HH:mm:ss,直接闭区间,避免 DATE_ADD 多算一天 -->
             <if test= 'maps.qecSTime != null '>
-                and DATE(qec.create_time) &gt;= DATE(#{maps.qecSTime})
+                and qec.create_time &gt;= #{maps.qecSTime}
             </if>
             <if test='maps.qecETime != null '>
-                and DATE(qec.create_time) &lt;= DATE(#{maps.qecETime})
+                and qec.create_time &lt;= #{maps.qecETime}
             </if>
             <if test= 'maps.sTime != null '>
-                and l.create_time &gt;= CONCAT(#{maps.sTime}," 00:00:00")
+                and l.create_time &gt;= #{maps.sTime}
             </if>
             <if test='maps.eTime != null '>
-                and l.create_time &lt; CONCAT(#{maps.eTime}, ' 00:00:00')
+                and l.create_time &lt;= #{maps.eTime}
             </if>
             <if test= 'maps.scheduleStartTime != null '>
                 and l.camp_period_time &gt;= #{maps.scheduleStartTime}
@@ -158,10 +159,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 and l.camp_period_time &lt;= #{maps.scheduleEndTime}
             </if>
             <if test= 'maps.upSTime != null '>
-                and DATE(l.update_time) &gt;= DATE(#{maps.upSTime})
+                and l.update_time &gt;= #{maps.upSTime}
             </if>
             <if test='maps.upETime != null '>
-                and l.update_time &lt; date_add(#{maps.upETime}, interval 1 day)
+                and l.update_time &lt;= #{maps.upETime}
             </if>
             <if test="maps.sopIds != null and maps.sopIds.size() > 0">
                 and l.sop_id in
@@ -924,29 +925,30 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test ='maps.externalUserName !=null and maps.externalUserName!=""'>
                 and qec.name  like concat('%', #{maps.externalUserName}, '%')
             </if>
+            <!-- 与列表同一口径:Service 归一化后闭区间比较(对齐导出,不再用 DATE()) -->
             <if test= 'maps.qecSTime != null '>
-                and DATE(qec.create_time) &gt;= DATE(#{maps.qecSTime})
+                and qec.create_time &gt;= #{maps.qecSTime}
             </if>
             <if test='maps.qecETime != null '>
-                and DATE(qec.create_time) &lt;= DATE(#{maps.qecETime})
+                and qec.create_time &lt;= #{maps.qecETime}
             </if>
             <if test= 'maps.sTime != null '>
-                and DATE(l.create_time) &gt;= DATE(#{maps.sTime})
+                and l.create_time &gt;= #{maps.sTime}
             </if>
             <if test='maps.eTime != null '>
-                and DATE(l.create_time) &lt;= DATE(#{maps.eTime})
+                and l.create_time &lt;= #{maps.eTime}
             </if>
             <if test= 'maps.scheduleStartTime != null '>
-                and DATE(l.camp_period_time) &gt;= DATE(#{maps.scheduleStartTime})
+                and l.camp_period_time &gt;= #{maps.scheduleStartTime}
             </if>
             <if test='maps.scheduleEndTime != null '>
-                and DATE(l.camp_period_time) &lt;= DATE(#{maps.scheduleEndTime})
+                and l.camp_period_time &lt;= #{maps.scheduleEndTime}
             </if>
             <if test= 'maps.upSTime != null '>
-                and DATE(l.update_time) &gt;= DATE(#{maps.upSTime})
+                and l.update_time &gt;= #{maps.upSTime}
             </if>
             <if test='maps.upETime != null '>
-                and DATE(l.update_time) &lt;= DATE(#{maps.upETime})
+                and l.update_time &lt;= #{maps.upETime}
             </if>
             <if test="maps.sopIds != null and maps.sopIds.size() > 0">
                 and l.sop_id in
@@ -1171,12 +1173,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         FROM fs_course_watch_log
         WHERE create_time &gt;= #{createTime}
         AND create_time &lt; DATE_ADD(DATE(#{createTime}), INTERVAL 1 DAY)
-        <if test="project != null">
-            AND project = #{project}
-        </if>
+        <!-- 已选课程时只用 course_id:企微完播 project 常为 NULL,再按 project 过滤会漏数 -->
         <if test="courseId != null">
             AND course_id = #{courseId}
         </if>
+        <if test="courseId == null and project != null">
+            AND project = #{project}
+        </if>
         <if test="videoIds != null and videoIds.size() &gt; 0">
             AND video_id IN
             <foreach collection="videoIds" open="(" close=")" separator="," item="vid">
@@ -1190,6 +1193,42 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     </select>
 
+    <!-- 完播统计全量合计:对各公司已聚合人数再 SUM(与列表口径一致,不受分页影响) -->
+    <select id="selectUserIdListSummary" resultType="com.fs.course.vo.FsCoureseWatchLogVO"
+            parameterType="com.fs.course.param.FsCourseWatchLogParam">
+        SELECT
+        '合计' AS companyName,
+        COALESCE(SUM(l.typeOneAll), 0) AS typeOneAll,
+        COALESCE(SUM(l.typeTwoAll), 0) AS typeTwoAll,
+        COALESCE(SUM(l.typeAll), 0) AS typeAll,
+        COALESCE(SUM(l.allUserId), 0) AS allUserId
+        FROM (
+            SELECT
+            company_id,
+            COUNT(DISTINCT CASE WHEN log_type = 2 AND send_type = 1 THEN user_id END) AS typeOneAll,
+            COUNT(DISTINCT CASE WHEN log_type = 2 AND send_type = 2 THEN user_id END) AS typeTwoAll,
+            COUNT(DISTINCT CASE WHEN log_type = 2 THEN user_id END) AS typeAll,
+            COUNT(DISTINCT CASE WHEN log_type != 3 THEN user_id END) AS allUserId
+            FROM fs_course_watch_log
+            WHERE create_time &gt;= #{createTime}
+            AND create_time &lt; DATE_ADD(DATE(#{createTime}), INTERVAL 1 DAY)
+            <if test="courseId != null">
+                AND course_id = #{courseId}
+            </if>
+            <if test="courseId == null and project != null">
+                AND project = #{project}
+            </if>
+            <if test="videoIds != null and videoIds.size() &gt; 0">
+                AND video_id IN
+                <foreach collection="videoIds" open="(" close=")" separator="," item="vid">
+                    #{vid}
+                </foreach>
+            </if>
+            GROUP BY company_id
+        ) l
+        INNER JOIN company c ON c.company_id = l.company_id AND c.is_del = '0'
+    </select>
+
 
     <select id="selectFsCourseWatchLogStatisticsListVO_COUNT"
             resultType="java.lang.Long">
@@ -1363,4 +1402,190 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </choose>
         ORDER BY accessCount desc
     </select>
+
+    <!-- P0:分页先查主键,避免多表 JOIN 参与排序与 COUNT -->
+    <select id="selectFsCourseWatchLogListIds" resultType="java.lang.Long">
+        select l.log_id
+        from fs_course_watch_log l
+        <if test="(maps.companyUserName != null and maps.companyUserName != '') or maps.deptId != null or (maps.cuDeptIdList != null and !maps.cuDeptIdList.isEmpty() and maps.userType != '00') or (maps.userIds != null and !maps.userIds.isEmpty())">
+            left join company_user cu on cu.user_id = l.company_user_id
+        </if>
+        <if test="(maps.nickName != null and maps.nickName != '')">
+            left join fs_user u on u.user_id = l.user_id
+        </if>
+        <if test="(maps.externalUserName != null and maps.externalUserName != '') or maps.qecSTime != null or maps.qecETime != null or (maps.externalStatus != null and maps.externalStatus != '') or (maps.corpId != null and maps.corpId != '') or (maps.tagIds != null and maps.tagIds.size() &gt; 0)">
+            left join qw_external_contact qec on l.qw_external_contact_id = qec.id
+        </if>
+        <if test="maps.qwUserName != null and maps.qwUserName != ''">
+            left join qw_user qu on qu.id = l.qw_user_id
+        </if>
+        <where>
+            <if test ='maps.appid != null'>
+                and l.app_id = #{maps.appid}
+            </if>
+            <if test ='maps.isVip != null and maps.isVip == 0'>
+                and (l.user_id = 0 or l.user_id is null)
+            </if>
+            <if test ='maps.isVip != null and maps.isVip == 1'>
+                and l.user_id != 0 and l.user_id is not null
+            </if>
+            <if test ='maps.sendType !=null'>
+                and l.send_type = #{maps.sendType}
+            </if>
+            <if test ='maps.userId !=null'>
+                and l.user_id = #{maps.userId}
+            </if>
+            <if test ='maps.logId !=null'>
+                and l.log_id = #{maps.logId}
+            </if>
+            <if test ='maps.qwExternalContactId !=null'>
+                and l.qw_external_contact_id = #{maps.qwExternalContactId}
+            </if>
+            <if test ='maps.qwUserId !=null'>
+                and l.qw_user_id = #{maps.qwUserId}
+            </if>
+            <if test ='maps.courseId !=null'>
+                and l.course_id = #{maps.courseId}
+            </if>
+            <if test ='maps.videoId !=null'>
+                and l.video_id = #{maps.videoId}
+            </if>
+            <if test ='maps.logType !=null'>
+                and l.log_type = #{maps.logType}
+            </if>
+            <if test ='maps.companyId !=null'>
+                and l.company_id = #{maps.companyId}
+            </if>
+            <if test ='maps.watchType !=null'>
+                and l.watch_type = #{maps.watchType}
+            </if>
+            <if test ='maps.companyUserId !=null'>
+                and l.company_user_id = #{maps.companyUserId}
+            </if>
+            <if test ='maps.companyUserName !=null and maps.companyUserName!=""'>
+                and cu.nick_name  like concat('%', #{maps.companyUserName}, '%')
+            </if>
+            <if test ='maps.nickName !=null and maps.nickName!=""'>
+                and u.nick_name  like concat('%', #{maps.nickName}, '%')
+            </if>
+            <if test ='maps.externalUserName !=null and maps.externalUserName!=""'>
+                and qec.name  like concat('%', #{maps.externalUserName}, '%')
+            </if>
+            <!-- 时间已在 Service 归一化为 yyyy-MM-dd HH:mm:ss,直接闭区间,避免 DATE_ADD 多算一天 -->
+            <if test= 'maps.qecSTime != null '>
+                and qec.create_time &gt;= #{maps.qecSTime}
+            </if>
+            <if test='maps.qecETime != null '>
+                and qec.create_time &lt;= #{maps.qecETime}
+            </if>
+            <if test= 'maps.sTime != null '>
+                and l.create_time &gt;= #{maps.sTime}
+            </if>
+            <if test='maps.eTime != null '>
+                and l.create_time &lt;= #{maps.eTime}
+            </if>
+            <if test= 'maps.scheduleStartTime != null '>
+                and l.camp_period_time &gt;= #{maps.scheduleStartTime}
+            </if>
+            <if test='maps.scheduleEndTime != null '>
+                and l.camp_period_time &lt;= #{maps.scheduleEndTime}
+            </if>
+            <if test= 'maps.upSTime != null '>
+                and l.update_time &gt;= #{maps.upSTime}
+            </if>
+            <if test='maps.upETime != null '>
+                and l.update_time &lt;= #{maps.upETime}
+            </if>
+            <if test="maps.sopIds != null and maps.sopIds.size() > 0">
+                and l.sop_id in
+                <foreach item="sopId" index="index" collection="maps.sopIds" open="(" separator="," close=")">
+                    #{sopId}
+                </foreach>
+            </if>
+            <if test="maps.sopId != null  and maps.sopId != '' ">
+                and l.sop_id = #{maps.sopId}
+            </if>
+            <if test="maps.externalStatus != null  and maps.externalStatus != '' ">
+                and qec.status = #{maps.externalStatus}
+            </if>
+            <if test="maps.periodId != null">
+                and l.period_id = #{maps.periodId}
+            </if>
+            <if test="maps.periodIds != null and maps.periodIds.size() > 0">
+                and l.period_id in
+                <foreach item="periodId" index="index" collection="maps.periodIds" open="(" separator="," close=")">
+                    #{periodId}
+                </foreach>
+            </if>
+            <if test="maps.qwUserName != null  and maps.qwUserName != '' ">
+                and (
+                qu.qw_user_name = #{maps.qwUserName}
+                OR (qu.qw_user_name LIKE '{%' AND JSON_UNQUOTE(JSON_EXTRACT(qu.qw_user_name, '$.name')) = #{maps.qwUserName})
+                )
+            </if>
+            <if test="maps.deptId != null  and maps.deptId != '' ">
+                and cu.dept_id = #{maps.deptId}
+            </if>
+            <if test='maps.cuDeptIdList != null and !maps.cuDeptIdList.isEmpty() and  maps.userType != "00" '>
+                AND cu.dept_id IN
+                <foreach collection='maps.cuDeptIdList' item='item' open='(' separator=',' close=')'>
+                    #{item}
+                </foreach>
+            </if>
+            <if test="maps.userIds != null and !maps.userIds.isEmpty()">
+                AND cu.user_id IN
+                    <foreach collection='maps.userIds' item='item' open='(' separator=',' close=')'>
+                        #{item}
+                    </foreach>
+            </if>
+            <if test="maps.corpId != null and maps.corpId != ''">
+                and qec.corp_id = #{maps.corpId}
+            </if>
+            <if test="maps.tagIds != null and maps.tagIds.size() &gt; 0">
+                <choose>
+                    <when test="maps.tagFilterType != null and maps.tagFilterType == 2">
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" or ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </when>
+                    <otherwise>
+                        and (
+                        <foreach collection="maps.tagIds" item="item" index="index" separator=" and ">
+                            <![CDATA[find_in_set(#{item}, REPLACE(REPLACE(REPLACE(REPLACE(IFNULL(qec.tag_ids,''), '"', ''), '[', ''), ']', ''), ' ', ''))]]>
+                        </foreach>
+                        )
+                    </otherwise>
+                </choose>
+            </if>
+        </where>
+        order by l.create_time desc, l.log_id desc
+    </select>
+
+    <select id="selectFsCourseWatchLogListByLogIds" resultType="com.fs.course.vo.FsCourseWatchLogListVO">
+        select l.log_id,l.project,l.period_id,l.user_id,uc.course_name,v.title as video_name,qec.avatar as external_user_avatar,qec.status as externalStatus,
+        l.log_type,SEC_TO_TIME(l.duration) as duration,c.company_name,l.camp_period_time,l.finish_time,l.app_id,
+        cu.nick_name as company_user_name ,l.send_type,l.create_time,l.update_time,l.last_heartbeat_time,fcpsc.name,
+        CASE
+            WHEN qu.qw_user_name IS NOT NULL AND qu.qw_user_name LIKE '{%' THEN JSON_UNQUOTE(JSON_EXTRACT(qu.qw_user_name, '$.name'))
+            ELSE qu.qw_user_name
+        END as qw_user_name,
+        qec.name as external_user_name,c.company_id,u.avatar as fsAvatar,u.nick_name as fsNickName,qec.create_time as qec_create_time,
+        u.is_vip isVip, l.project, l.im_msg_send_detail_id,l.reward_type,cu.dept_id, l.watch_type
+         from fs_course_watch_log l
+         left join fs_user_course_video v on v.video_id = l.video_id
+         left join fs_user_course uc on uc.course_id = l.course_id
+         left join fs_user u on u.user_id = l.user_id
+         left join company_user cu on cu.user_id = l.company_user_id
+         left join company c on c.company_id = l.company_id
+         LEFT JOIN qw_user qu on qu.id= l.qw_user_id
+         LEFT JOIN qw_external_contact qec on l.qw_external_contact_id = qec.id
+         LEFT JOIN fs_course_play_source_config fcpsc ON fcpsc.appid = l.app_id
+        where l.log_id in
+        <foreach collection="logIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
 </mapper>

+ 21 - 1
fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml

@@ -45,10 +45,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="registerTime"    column="register_time"    />
         <result property="isReply"    column="is_reply"    />
         <result property="isTransfer"    column="is_transfer"    />
+        <result property="wxUserId"    column="wx_user_id"    />
     </resultMap>
 
     <sql id="selectQwExternalContactVo">
-        select id,qw_user_id,register_time,state,way_id,stage_status,first_time,open_id,is_interact,level, unionid, user_id,transfer_time,loss_time,del_time,transfer_num, external_user_id,transfer_status,status,create_time, name, avatar, type, gender, remark, description, tag_ids, remark_mobiles, remark_corp_name, add_way, oper_userid, corp_id, company_id, company_user_id, customer_id, fs_user_id,is_reply,is_transfer from qw_external_contact
+        select id,qw_user_id,register_time,state,way_id,stage_status,first_time,open_id,is_interact,level, unionid, user_id,transfer_time,loss_time,del_time,transfer_num, external_user_id,transfer_status,status,create_time, name, avatar, type, gender, remark, description, tag_ids, remark_mobiles, remark_corp_name, add_way, oper_userid, corp_id, company_id, company_user_id, customer_id, fs_user_id,is_reply,is_transfer,wx_user_id from qw_external_contact
     </sql>
 
     <select id="selectQwExternalContactList" parameterType="QwExternalContact" resultMap="QwExternalContactResult">
@@ -284,6 +285,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="firstTime != null">first_time,</if>
             <if test="registerTime != null">register_time,</if>
             <if test="isTransfer != null">is_transfer,</if>
+            <if test="wxUserId != null">wx_user_id,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="id != null">#{id},</if>
@@ -324,6 +326,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="firstTime != null">#{firstTime},</if>
             <if test="registerTime != null">#{registerTime},</if>
             <if test="isTransfer != null">#{isTransfer},</if>
+            <if test="wxUserId != null">#{wxUserId},</if>
          </trim>
     </insert>
 
@@ -367,10 +370,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="firstTime != null">first_time = #{firstTime},</if>
             <if test="registerTime != null">register_time = #{registerTime},</if>
             <if test="isTransfer != null">is_transfer = #{isTransfer},</if>
+            <if test="wxUserId != null">wx_user_id = #{wxUserId},</if>
         </trim>
         where id = #{id}
     </update>
 
+    <!-- 按主键修改 PAD VID,对齐群聊 updateQwGroupChat.roomid -->
+    <update id="updateWxUserIdById">
+        UPDATE qw_external_contact
+        SET wx_user_id = #{wxUserId}
+        WHERE id = #{id}
+    </update>
+
+    <!-- 按外部联系人+主体批量补写尚未落库的 VID -->
+    <update id="updateWxUserIdByExternalUserId">
+        UPDATE qw_external_contact
+        SET wx_user_id = #{wxUserId}
+        WHERE external_user_id = #{externalUserId}
+          AND corp_id = #{corpId}
+          AND (wx_user_id IS NULL OR wx_user_id = 0)
+    </update>
+
     <delete id="deleteQwExternalContactById" parameterType="Long">
         delete from qw_external_contact where id = #{id}
     </delete>

+ 46 - 3
fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java

@@ -19,6 +19,7 @@ import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.service.IFsCoursePlaySourceConfigService;
 import com.fs.course.utils.FsCoursePlaySourceConfigQuery;
+import com.fs.course.utils.FsCourseWatchLogProjectFiller;
 import com.fs.common.exception.CustomException;
 import com.fs.feishu.domain.FeishuAccount;
 import com.fs.feishu.model.FeishuLinkParam;
@@ -106,6 +107,8 @@ public class CourseMpLoginController extends AppBaseController {
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
     @Autowired
+    private FsCourseWatchLogProjectFiller fsCourseWatchLogProjectFiller;
+    @Autowired
     private CompanyUserMapper companyUserMapper;
     @Autowired
     private ISysConfigService configService;
@@ -603,7 +606,10 @@ public class CourseMpLoginController extends AppBaseController {
     }
 
     /**
-     * 绑定后补齐 SOP 用户关联、看课记录 userId(对齐 handleExt)
+     * 绑定后补齐 SOP 用户关联、看课记录(对齐 handleQwRoom / handleExt)。
+     * <p>
+     * 飞书 NEW 群链路发课时短链只有 chatId、无单一客户,不会预写看课记录;
+     * 领取时必须按客户创建,否则群里看课无 fs_course_watch_log。
      */
     private void syncClaimBindSideEffects(FsCourseLink courseLink, QwExternalContact contact, FsUser currentUser) {
         Long qwExternalId = contact.getId();
@@ -613,13 +619,26 @@ public class CourseMpLoginController extends AppBaseController {
             logger.warn("[H5领取] 更新SOP用户关联失败 qwExternalId={} userId={} err={}",
                     qwExternalId, currentUser.getUserId(), e.getMessage());
         }
-        if (courseLink.getVideoId() == null || contact.getQwUserId() == null) {
+        if (courseLink.getVideoId() == null) {
+            return;
+        }
+        Long qwUserId = courseLink.getQwUserId() != null ? courseLink.getQwUserId() : contact.getQwUserId();
+        if (qwUserId == null) {
+            logger.warn("[H5领取] 无法补齐看课记录:qwUserId 为空 qwExternalId={} videoId={} linkId={}",
+                    qwExternalId, courseLink.getVideoId(), courseLink.getLinkId());
             return;
         }
         try {
             FsCourseWatchLog watchLog = fsCourseWatchLogMapper.getWatchCourseVideoByExt(
-                    qwExternalId, courseLink.getVideoId(), String.valueOf(contact.getQwUserId()));
+                    qwExternalId, courseLink.getVideoId(), String.valueOf(qwUserId));
             if (watchLog == null) {
+                // 群飞书 NEW / 私聊漏写:领取时创建待看课记录
+                watchLog = buildClaimWatchLog(courseLink, contact, currentUser, qwUserId);
+                fsCourseWatchLogProjectFiller.fillIfAbsent(watchLog);
+                fsCourseWatchLogMapper.insertFsCourseWatchLog(watchLog);
+                logger.info("[H5领取] 已创建看课记录 qwExternalId={} videoId={} userId={} watchType={} logId={}",
+                        qwExternalId, courseLink.getVideoId(), currentUser.getUserId(),
+                        watchLog.getWatchType(), watchLog.getLogId());
                 return;
             }
             if (watchLog.getUserId() == null || watchLog.getUserId().equals(0L)
@@ -634,6 +653,30 @@ public class CourseMpLoginController extends AppBaseController {
         }
     }
 
+    /**
+     * 领取资料时补建看课记录(群链路 watchType=1,私聊=2)
+     */
+    private FsCourseWatchLog buildClaimWatchLog(FsCourseLink courseLink, QwExternalContact contact,
+                                                FsUser currentUser, Long qwUserId) {
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setQwExternalContactId(contact.getId());
+        watchLog.setSendType(2);
+        watchLog.setUserId(currentUser.getUserId());
+        watchLog.setVideoId(courseLink.getVideoId());
+        watchLog.setCourseId(courseLink.getCourseId());
+        watchLog.setQwUserId(qwUserId);
+        watchLog.setCompanyUserId(courseLink.getCompanyUserId());
+        watchLog.setCompanyId(courseLink.getCompanyId());
+        watchLog.setDuration(0L);
+        Date now = new Date();
+        watchLog.setCreateTime(now);
+        watchLog.setUpdateTime(now);
+        watchLog.setLogType(3);
+        // 有 chatId 视为群看课,与 SOP 群发小程序 watchType=1 一致
+        watchLog.setWatchType(StringUtils.isNotBlank(courseLink.getChatId()) ? 1 : 2);
+        return watchLog;
+    }
+
     /**
      * 归属通过后解析跳转地址:优先按 link 缓存的飞书 docx;缺字段才走 H5 兜底
      */