Jelajahi Sumber

飞书看课

xgb 3 minggu lalu
induk
melakukan
8b2eea84cc
46 mengubah file dengan 2326 tambahan dan 45 penghapusan
  1. 48 0
      fs-admin/src/main/java/com/fs/course/task/CourseWatchLogScheduler.java
  2. 59 0
      fs-admin/src/main/java/com/fs/task/FeishuCleanupTask.java
  3. 75 0
      fs-company/src/main/java/com/fs/company/controller/course/FeiShuCourseController.java
  4. 1 1
      fs-company/src/main/java/com/fs/company/controller/course/FsUserCourseCategoryController.java
  5. 4 2
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  6. 59 1
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  7. 6 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java
  8. 6 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  9. 6 0
      fs-qwhook/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java
  10. 8 0
      fs-service/pom.xml
  11. 3 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  12. 15 0
      fs-service/src/main/java/com/fs/course/constant/CourseConstant.java
  13. 94 0
      fs-service/src/main/java/com/fs/course/domain/FeiShuCourseWatchLog.java
  14. 7 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseTrafficLog.java
  15. 1 1
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java
  16. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java
  17. 5 0
      fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java
  18. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  19. 20 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  20. 174 4
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  21. 412 7
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  22. 13 0
      fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java
  23. 18 0
      fs-service/src/main/java/com/fs/feishu/domain/FeishuAccount.java
  24. 22 0
      fs-service/src/main/java/com/fs/feishu/mapper/FeishuAccountMapper.java
  25. 16 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java
  26. 41 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java
  27. 200 0
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  28. 139 0
      fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java
  29. 206 0
      fs-service/src/main/java/com/fs/feishu/service/FeishuDocApiService.java
  30. 99 0
      fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java
  31. 51 0
      fs-service/src/main/java/com/fs/qw/domain/QwAppMiniProgramRelation.java
  32. 24 0
      fs-service/src/main/java/com/fs/sop/service/impl/QwSopTempServiceImpl.java
  33. 188 2
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  34. 4 1
      fs-service/src/main/resources/application-druid-myhk-new.yml
  35. 4 0
      fs-service/src/main/resources/application-druid-myhk-test.yml
  36. 4 1
      fs-service/src/main/resources/application-druid-myhk.yml
  37. 28 0
      fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml
  38. 15 0
      fs-service/src/main/resources/mapper/his/FeishuAccountMapper.xml
  39. 7 5
      fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java
  40. 20 10
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java
  41. 22 3
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseQwController.java
  42. 113 0
      fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java
  43. 7 0
      fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  44. 19 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java
  45. 16 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java
  46. 16 7
      fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

+ 48 - 0
fs-admin/src/main/java/com/fs/course/task/CourseWatchLogScheduler.java

@@ -0,0 +1,48 @@
+package com.fs.course.task;
+
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.service.IFsCourseWatchLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+@Component
+@Slf4j
+public class CourseWatchLogScheduler {
+
+
+    @Autowired
+    RedisCache redisCache;
+
+
+    @Autowired
+    private IFsCourseWatchLogService courseWatchLogService;
+
+
+    /**
+     * 检查飞书看课状态
+     * 每分钟执行一次
+     */
+    @Scheduled(fixedRate = 60000)
+    public void checkFeiShuWatchStatus() {
+        if (!redisCache.setIfAbsent("checkFeiShuWatchStatus", "checkFeiShuWatchStatus", 3, TimeUnit.MINUTES)) {
+            log.warn("检查飞书看课中任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+        try {
+            log.info("检查飞书看课中任务执行>>>>>>>>>>>>");
+            courseWatchLogService.scheduleFeiShuBatchUpdateToDatabase();
+            courseWatchLogService.checkFeiShuWatchStatus();
+            log.info("检查飞书看课中任务执行完成>>>>>>>>>>>>");
+        }catch (Exception e) {
+            log.error("检查飞书看课中任务执行完成 - 定时任务执行失败", e);
+        } finally {
+            redisCache.deleteObject("checkFeiShuWatchStatus");
+        }
+    }
+
+}

+ 59 - 0
fs-admin/src/main/java/com/fs/task/FeishuCleanupTask.java

@@ -0,0 +1,59 @@
+package com.fs.task;
+
+import com.fs.feishu.model.FeishuFileDTO;
+import com.fs.feishu.service.FeishuClientPool;
+import com.fs.feishu.service.FeishuDocApiService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 飞书文档定时清理任务:
+ * 每天凌晨执行,删除至少 24 小时前创建的文档(避免达到飞书文档数量上限)
+ */
+@Component
+@Slf4j
+public class FeishuCleanupTask {
+
+    @Autowired
+    private FeishuClientPool clientPool;
+
+    @Autowired
+    private FeishuDocApiService docApiService;
+
+    /**
+     * 每天凌晨 2 点执行清理,删除至少 24 小时前创建的文档
+     */
+    @Scheduled(cron = "0 0 2 * * ?")
+    public void deleteFilesBeforeToday() {
+        // 计算 24 小时前的时间戳(秒)
+        long twentyFourHoursAgo = System.currentTimeMillis() / 1000 - 24 * 60 * 60;
+
+        // 获取所有可用 Client 的快照
+        List<com.lark.oapi.Client> clients = clientPool.getAllClients();
+        for (com.lark.oapi.Client client : clients) {
+            try {
+                // 分页获取该账号下的所有文档(按创建时间升序)
+                List<FeishuFileDTO> allFiles = docApiService.listAllFiles(client);
+                for (FeishuFileDTO file : allFiles) {
+                    long createdTime = Long.parseLong(file.getCreatedTime());
+                    // 因为升序排列,一旦遇到未满 24 小时的文件,后面的都不会满足条件,可提前退出
+                    if (createdTime >= twentyFourHoursAgo) {
+                        break;
+                    }
+                    // 跳过 wiki 类型,其他类型删除
+                    if (!"wiki".equalsIgnoreCase(file.getType())) {
+                        docApiService.deleteFile(client, file.getToken(), file.getType());
+                        log.info("已删除: {} | type={} | createdTime={}", file.getName(), file.getType(), file.getCreatedTime());
+                    }
+                }
+            } catch (Exception e) {
+                log.error("清理飞书文档失败,当前账号将被跳过: {}", e.getMessage());
+            }
+        }
+        log.info("飞书云文档定时清理完成(删除 24 小时前的文档)");
+    }
+}

+ 75 - 0
fs-company/src/main/java/com/fs/company/controller/course/FeiShuCourseController.java

@@ -0,0 +1,75 @@
+package com.fs.company.controller.course;
+
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.exception.CustomException;
+import com.fs.company.domain.CompanyUser;
+import com.fs.feishu.service.FeiShuService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+
+@Api("销售端-飞书看课接口")
+@RestController
+@RequestMapping("/feishu/course/")
+@Slf4j
+public class FeiShuCourseController extends BaseController {
+
+    @Autowired
+    private FeiShuService feiShuService;
+    @ApiOperation("生成飞书初始链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public AjaxResult getFeiShuCourseLink(
+            @RequestParam(value = "videoId", required = true) Long videoId,
+            @RequestParam(value = "courseId", required = false) Long courseId) {
+
+        // 1. 强制必填参数校验
+        if (videoId == null) {
+            return AjaxResult.error("视频ID不能为空");
+        }
+
+        // 2. 获取当前登录企业用户信息
+        CompanyUser companyUser;
+        try {
+            companyUser = getCurrentCompanyUser();
+        } catch (CustomException e) {
+            log.warn("获取登录用户信息失败:{}", e.getMessage());
+            return AjaxResult.error(e.getMessage());
+        }
+
+        // 3. 组装用户参数
+        Long companyUserId = companyUser.getUserId();
+        Long companyId = companyUser.getCompanyId();
+
+        // 4. 调用业务层生成链接
+        try {
+            //String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId);
+            String feishuLink = null;
+            return AjaxResult.success(feishuLink);
+        } catch (CustomException e) {
+            log.error("生成飞书看课链接业务异常:{}", e.getMessage());
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("生成飞书看课链接系统异常", e);
+            return AjaxResult.error("生成飞书课程链接失败,请稍后重试");
+        }
+    }
+
+    /**
+     * 获取当前登录企业用户
+     * @return CompanyUser
+     */
+    public static CompanyUser getCurrentCompanyUser() {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        if (loginUser == null || loginUser.getUser() == null) {
+            throw new CustomException("登录信息已过期,请重新登录");
+        }
+        return loginUser.getUser();
+    }
+}

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/course/FsUserCourseCategoryController.java

@@ -109,7 +109,7 @@ public class FsUserCourseCategoryController extends BaseController
     @GetMapping("/getCatePidList")
     public R getCatePidList()
     {
-        List<OptionsVO> list = fsUserCourseCategoryService.selectFsUserCourseCategoryPidList(null);
+        List<OptionsVO> list = fsUserCourseCategoryService.selectFsUserCourseCategoryPidList("");
         return R.ok().put("data", list);
     }
 

+ 4 - 2
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -715,8 +715,10 @@ public class IpadSendServer {
                     sendMiniProgram(vo, content, miniMap);
                     break;
                 case "18":
-                    // 发送直播卡片
-                    sendLiveMiniProgram(vo, content, miniMap);
+//                    // 发送直播卡片
+//                    sendLiveMiniProgram(vo, content, miniMap);
+                    // 链接
+                    sendLink(vo, content);
                     break;
                 case "19":
                     // 发送直播短链

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

@@ -22,6 +22,7 @@ import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.service.IFsCourseLinkService;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.utils.ConfigUtil;
@@ -83,7 +84,8 @@ import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
 public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     private static final String APP_LINK_PREFIX = "/appcourse/pages/course/learning?course=";
-    private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
+    static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
     private static final String miniappRealLink = "/pages_course/video.html?course=";
     private static final String appRealLink = "/pages/courseAnswer/index?link=";
@@ -223,6 +225,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private ConfigUtil configUtil;
 
+    @Autowired
+    private FeiShuService feiShuService;
+
     @PostConstruct
     public void init() {
         loadCourseConfig();
@@ -1938,6 +1943,27 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         log.error("浏览器看课模板解析失败:" + e);
                     }
 
+                    break;
+                case "18" :
+                    //飞书看课链接
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                    Long externalIdLong = Long.parseLong(externalId);
+
+                    //1.添加待看课记录
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo,3);
+
+                    //2.创建飞书课程短链
+                    Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
+                            qwUserId, companyUserId, companyId, externalIdLong, cachedCourseConfig);
+                    //课程短码
+                    String shortLink = feiShuLinkMap.get("link");
+
+                    //3.调用生成飞书注册授权链接
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId,companyIdLong,courseId,companyUserIdLong,shortLink);
+                    if (StringUtils.isNotEmpty(feishuLink)) {
+                        setting.setLinkUrl(feishuLink);
+                    }
                     break;
                 case "21"://短信看课
                     if (sopLogs.getFsUserId() != null && !Long.valueOf(0L).equals(sopLogs.getFsUserId())) {
@@ -3815,4 +3841,36 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             log.error("缓存用户计数失败,userId: {}", userId, e);
         }
     }
+
+    /**
+     * 生成课程短链
+     * */
+    private Map<String, String> createFeiShuLinkByMiniApp(QwSopTempSetting.Content.Setting setting, String corpId, Date sendTime,
+                                                          Long courseId, Long videoId, String qwUserId,
+                                                          String companyUserId, String companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId.toString(), 3);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
 }

+ 6 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java

@@ -230,4 +230,10 @@ public class ApisFsUserCourseVideoController extends BaseController {
         return fsUserCourseVideoService.createRoomMiniLink(param);
     }
 
+    @GetMapping("/createRoomLinkFeishu")
+    @ApiOperation("创建发群课程链接--飞书")
+    public R createRoomLinkByFeishu(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
+    }
+
 }

+ 6 - 0
fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -484,4 +484,10 @@ public class FsUserCourseVideoController {
             return fsUserCourseVideoService.createMiniLink(param);
         }
     }
+
+    @GetMapping("/createRoomLinkFeishu")
+    @ApiOperation("创建发群课程链接--飞书")
+    public R createRoomLinkByFeishu(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
+    }
 }

+ 6 - 0
fs-qwhook/src/main/java/com/fs/app/controller/ApisFsUserCourseVideoController.java

@@ -181,4 +181,10 @@ public class ApisFsUserCourseVideoController extends BaseController {
         return fsUserCourseVideoService.createRoomMiniLink(param);
     }
 
+    @GetMapping("/createRoomLinkFeishu")
+    @ApiOperation("创建发群课程链接--飞书")
+    public R createRoomLinkByFeishu(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createCartLinkByFeiShu(param);//生成飞书群发课程链接
+    }
+
 }

+ 8 - 0
fs-service/pom.xml

@@ -303,6 +303,14 @@
             <version>1.3.1</version>
         </dependency>
 
+        <!-- 飞书SDK -->
+        <dependency>
+            <groupId>com.larksuite.oapi</groupId>
+            <artifactId>oapi-sdk</artifactId>
+            <version>2.5.3</version>
+            <scope>compile</scope>
+        </dependency>
+
 
     </dependencies>
 

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

@@ -19,6 +19,9 @@ public class CourseConfig implements Serializable {
     private Integer appAnswerIntegral; //app答题积分
     private Integer defaultLine;//默认看课线路
     private String realLinkDomainName;//真链域名
+    private String feishuCourseDomain;//飞书看课域名
+    private String feishuAuthDomain;//飞书授权域名
+    private Boolean enableFeishuNewLink;//是否启用飞书新跳转
     private String salesEaseCourseDomain; //销售易看课域名
     private String realLinkH5DomainName;//H5通用看课域名
     private String realLinkH5LiveName;//H5通用直播域名

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

@@ -0,0 +1,15 @@
+package com.fs.course.constant;
+
+public class CourseConstant {
+
+    public static final String FEI_SHU_WATCH_HEART = "h5feishu:watch:heartbeat:";
+    public static final String FEI_SHU_WATCH_DURATION = "h5feishu:watch:duration:";
+
+    public static String getFeiShuWatchHeartKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format(FEI_SHU_WATCH_HEART + "%s:%s:%s:%s", userId, videoId, companyUserId);
+    }
+
+    public static String getFeiShuWatchDurationKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format(FEI_SHU_WATCH_DURATION + "%s:%s:%s:%s", userId, videoId, companyUserId);
+    }
+}

+ 94 - 0
fs-service/src/main/java/com/fs/course/domain/FeiShuCourseWatchLog.java

@@ -0,0 +1,94 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("feishu_course_watch_log")
+public class FeiShuCourseWatchLog {
+
+    /** 日志Id */
+    @TableId
+    private Long logId;
+
+    /** 用户id */
+    private Long userId;
+
+    /** 小节id */
+    private Long videoId;
+
+    /** 记录类型 1看课中 2完课 3待看课 4看课中断 */
+    private Integer logType;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+
+    /** 企微外部联系人id */
+    private Long qwExternalContactId;
+
+    /** 播放时长 */
+    private Integer duration;
+
+    /** 分享人企微userId */
+    private String qwUserId;
+
+    /** 销售id */
+    private Long companyUserId;
+
+    /** 公司id */
+    private Long companyId;
+
+    /** 课程id */
+    private Long courseId;
+
+    /** 归属发送方式:1 个微  2 企微 */
+    private Integer sendType;
+
+    /** 奖励类型 1:红包 2积分 */
+    private Integer rewardType;
+
+    /** 最后心跳时间 */
+    private LocalDateTime lastHeartbeatTime;
+
+    /** sop任务id */
+    private String sopId;
+
+    /** 完课时间 */
+    private LocalDateTime finishTime;
+
+    /** 是否发送完课消息 */
+    private Integer sendFinishMsg;
+
+    /** sop任务中的营期 */
+    private LocalDateTime campPeriodTime;
+
+    /** 天数 */
+    private Integer day;
+
+    /** 项目 */
+    private Long project;
+
+    /**  */
+    private String createBy;
+
+    /** */
+    private LocalDateTime updateBy;
+
+    /** 训绥营期id */
+    private Long periodId;
+
+    /** 项目 */
+    private Integer projectId;
+
+    /** im发送消息详情id */
+    private Long imMsgSendDetailId;
+
+    /** 看课类型 */
+    private Integer watchType;
+}

+ 7 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseTrafficLog.java

@@ -66,6 +66,13 @@ public class FsCourseTrafficLog extends BaseEntity
      */
     private Long periodId;
 
+    /**
+     * 小程序AppId
+     */
+    private String appId;
+
+    private Integer typeFlag;
+
 
 
 //    @JsonFormat(pattern = "yyyy-MM-dd")

+ 1 - 1
fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java

@@ -91,6 +91,6 @@ public class FsCourseWatchLog extends BaseEntity
     /** im发送消息详情id */
     private Long imMsgSendDetailId;
 
-    private Integer watchType;//看课方式:1 app  2 小程序
+    private Integer watchType;//看课方式:1 app  2 小程序 3 飞书
 
 }

+ 21 - 0
fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java

@@ -0,0 +1,21 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FeiShuCourseWatchLog;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+
+public interface FeiShuCourseWatchLogMapper extends BaseMapper<FeiShuCourseWatchLog> {
+
+    /**
+     * 根据视频ID和用户身份标识查询课程观看记录
+     */
+    @Select("select * from feishu_course_watch_log where video_id = #{videoId} and user_id = #{identifier} and company_user_id = #{companyUserId}")
+    FeiShuCourseWatchLog getWatchLogByFsUser(@Param("videoId") Long videoId, @Param("identifier") Long identifier, @Param("companyUserId") Long companyUserId);
+
+    /**
+     * 修改看课记录
+     */
+    void updateByVideoIdAndUserIdAndCompanyUserIdAndPeriodId(FeiShuCourseWatchLog feishuCourseWatchLog);
+}

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

@@ -39,4 +39,9 @@ public class FsCourseSendRewardUParam implements Serializable
 
     private String code;
 
+    /**
+     * 是否是飞书
+     */
+    private Boolean isFeiShu=false;
+
 }

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

@@ -193,4 +193,14 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      * @return 用户详情列表
      */
     List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailExportList(CourseStatisticsUserDetailParam param);
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    void scheduleFeiShuBatchUpdateToDatabase();
+
+    /**
+     * 检查飞书看课状态
+     */
+    void checkFeiShuWatchStatus();
 }

+ 20 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -18,6 +18,7 @@ import com.fs.his.domain.FsUser;
 import com.fs.his.vo.OptionsVO;
 import com.fs.qw.param.FsUserCourseRedPageParam;
 
+import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
 
@@ -180,6 +181,8 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
 
     R createMiniLink(FsCourseLinkMiniParam param);
 
+    R createCartLinkByFeiShu(FsCourseLinkMiniParam param);
+
     R createCartLink(FsCourseLinkMiniParam param);
 
     /**
@@ -268,8 +271,25 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
      */
     List<OptionsVO> selectVideoOptionsByCourseId(Long courseId);
 
+    /**
+     * 获取飞书课程详情
+     */
+    R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId,Long identifier);
+
     /**
      * 获取销售易看课详情
      */
     ResponseResult<FsUserCourseVideoLinkDetailsVO> getXiaoShouYiCourseVideoDetails(FsUserCourseVideoLinkParam param);
+
+    /**
+     * 更新飞书看课时长
+     */
+    void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long identifier, Long duration);
+
+    /**
+     * 飞书看课流量统计
+     */
+    void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, String uuid, BigDecimal bufferRate);
+
+
 }

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

@@ -22,6 +22,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RedisKeyScanner;
+import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
@@ -67,6 +68,9 @@ import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import com.hc.openapi.tool.util.StringUtils;
 import org.apache.commons.collections4.CollectionUtils;
+import org.apache.ibatis.session.ExecutorType;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -187,6 +191,9 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private FsCourseAnswerLogsMapper fsCourseAnswerLogsMapper;
 
+    @Autowired
+    private SqlSessionFactory sqlSessionFactory;
+
     /**
      * 查询短链课程看课记录
      *
@@ -1766,7 +1773,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         Long companyId = param.getCompanyId() != null ? param.getCompanyId() : null;
 
         // 总体数据
-        
+
         // 1. 查询视频时长(只返回duration字段)
         FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
         vo.setVideoDuration(fsUserCourseVideo != null ? fsUserCourseVideo.getDuration() : 0L);
@@ -1777,11 +1784,11 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         // 2. 统计累计观看人数(对userId去重)
         Long totalWatchCount = fsCourseWatchLogMapper.countDistinctWatchUsers(videoId, periodId,companyId);
         vo.setTotalWatchCount(totalWatchCount != null ? totalWatchCount : 0L);
-        
+
         // 3. 统计累计完课人数(duration >= 1200秒,即20分钟,对userId去重)
         Long totalCompleteCount = fsCourseWatchLogMapper.countDistinctCompleteUsers(videoId, periodId,companyId);
         vo.setTotalCompleteCount(totalCompleteCount != null ? totalCompleteCount : 0L);
-        
+
         // 4. 计算到课完课率 = 累计完课人数 / 累计观看人数
         BigDecimal completeRate = BigDecimal.ZERO;
         if (vo.getTotalWatchCount() != null && vo.getTotalWatchCount() > 0) {
@@ -1906,7 +1913,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             vo.setProductList(productList);
         }
 
-        
+
         return vo;
     }
 
@@ -1938,4 +1945,167 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         try { return Long.parseLong(String.valueOf(v)); } catch (NumberFormatException e) { return null; }
     }
 
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    @Override
+    public void scheduleFeiShuBatchUpdateToDatabase() {
+        log.info("开始更新飞书看课时长,检查完课>>>>>>");
+        //读取所有的key
+        Collection<String> keys = redisCache.keys(CourseConstant.FEI_SHU_WATCH_DURATION + "*");
+        //读取看课配置
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        List<FeiShuCourseWatchLog> logs = new ArrayList<>();
+        for (String key : keys) {
+            //取key中数据
+            Long userId = null;
+            Long videoId = null;
+            Long companyUserId = null;
+            try {
+                String[] parts = key.split(":");
+                userId = Long.parseLong(parts[3]);
+                videoId = Long.parseLong(parts[4]);
+                companyUserId = Long.parseLong(parts[5]);
+            } catch (Exception e) {
+                log.error("飞书key中id为null:{}", key);
+                continue;
+            }
+            Long duration = redisCache.getCacheObject(key);
+            if (duration == null) {
+                log.error("飞书key中数据为null:{}", key);
+                continue;  // 如果 Redis 中没有记录,跳过
+            }
+
+            FeiShuCourseWatchLog watchLog = new FeiShuCourseWatchLog();
+            watchLog.setUserId(userId);
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setDuration(duration.intValue());
+
+            //取对应视频的时长
+            Long videoDuration = 0L;
+            try {
+                videoDuration = getVideoDuration(videoId);
+            } catch (Exception e) {
+                log.error("飞书视频时长识别错误:{}", key);
+                continue;
+            }
+
+            if (videoDuration != null && videoDuration != 0) {
+                boolean complete = false;
+                // 判断百分比
+                if (config.getCompletionMode() == 1 && config.getAnswerRate() != null) {
+                    long percentage = (duration * 100 / videoDuration);
+                    complete = percentage >= config.getAnswerRate();
+                }
+                // 判断分钟数
+                if (config.getCompletionMode() == 2 && config.getMinutesNum() != null) {
+                    int i = config.getMinutesNum() * 60;
+                    complete = duration >= i;
+                }
+                //判断是否完课
+                if (complete) {
+                    watchLog.setLogType(2); // 设置状态为"已完成"
+                    watchLog.setFinishTime(LocalDateTime.now());
+                    String heartbeatKey = CourseConstant.getFeiShuWatchHeartKey(userId, videoId, companyUserId);
+                    // 完课删除心跳记录
+                    redisCache.deleteObject(heartbeatKey);
+                    // 完课删除看课时长记录
+                    redisCache.deleteObject(key);
+                }
+            }
+
+            //集合中增加
+            logs.add(watchLog);
+        }
+
+        // 批量更新飞书看课记录
+        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(logs)) {
+            batchUpdateFeiShuCourseWatchLog(logs);
+        }
+    }
+
+    /**
+     * 检查飞书看课状态
+     */
+    @Override
+    public void checkFeiShuWatchStatus() {
+        log.info("开始更新飞书看课中断记录>>>>>");
+        // 从 Redis 中获取所有正在看课的用户记录
+        Collection<String> keys = redisCache.keys(CourseConstant.FEI_SHU_WATCH_HEART + "*");
+        LocalDateTime now = LocalDateTime.now();
+        List<FeiShuCourseWatchLog> logs = new ArrayList<>();
+
+        for (String key : keys) {
+            FeiShuCourseWatchLog watchLog = new FeiShuCourseWatchLog();
+            //取key中数据
+            Long userId = null;
+            Long videoId = null;
+            Long companyUserId = null;
+            Long periodId = null;
+            try {
+                String[] parts = key.split(":");
+                userId = Long.parseLong(parts[3]);
+                videoId = Long.parseLong(parts[4]);
+                companyUserId = Long.parseLong(parts[5]);
+                periodId = Long.parseLong(parts[6]);
+            } catch (Exception e) {
+                log.error("飞书key中id为null:{}", key);
+                continue;
+            }
+            // 获取最后心跳时间
+            String lastHeartbeatStr = redisCache.getCacheObject(key);
+            if (StringUtils.isEmpty(lastHeartbeatStr)) {
+                redisCache.deleteObject(key);
+                continue; // 如果 Redis 中没有记录,跳过
+            }
+            LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+            Duration duration = Duration.between(lastHeartbeatTime, now);
+
+            watchLog.setUserId(userId);
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setPeriodId(periodId);
+            watchLog.setLastHeartbeatTime(lastHeartbeatTime);
+            // 如果超过一分钟没有心跳,标记为"观看中断"
+            if (duration.getSeconds() >= 60) {
+                watchLog.setLogType(4);
+                // 从 Redis 中删除该记录
+                redisCache.deleteObject(key);
+            } else {
+                watchLog.setLogType(1);
+            }
+            logs.add(watchLog);
+        }
+
+        // 批量更新飞书看课记录
+        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(logs)) {
+            batchUpdateFeiShuCourseWatchLog(logs);
+        }
+    }
+
+    /**
+     * 批量更新飞书看课记录
+     */
+    private void batchUpdateFeiShuCourseWatchLog(List<FeiShuCourseWatchLog> logs) {
+        try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
+            FeiShuCourseWatchLogMapper mapper = session.getMapper(FeiShuCourseWatchLogMapper.class);
+            int batchSize = 1000;
+            // 分批处理
+            for (int i = 0; i < logs.size(); i++) {
+                mapper.updateByVideoIdAndUserIdAndCompanyUserIdAndPeriodId(logs.get(i));
+                if ((i + 1) % batchSize == 0) {
+                    session.flushStatements();
+                }
+            }
+            session.flushStatements();
+            session.commit();
+        } catch (Exception e) {
+            log.error("批量更新飞书看课记录更新失败:{}", e.getMessage(), e);
+        }
+    }
+
 }

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

@@ -33,9 +33,11 @@ import com.fs.company.mapper.CompanyMapper;
 import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyService;
 import com.fs.config.cloud.CloudHostProper;
+import com.fs.core.config.WxOpenProperties;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RandomRedPacketConfig;
 import com.fs.course.config.RandomRedPacketRule;
+import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePackageDTO;
 import com.fs.course.mapper.*;
@@ -51,6 +53,7 @@ import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
 import com.fs.enums.ExceptionCodeEnum;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.config.AppConfig;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserIntegralLogs;
@@ -102,6 +105,7 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -265,6 +269,29 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Value("${cloud_host.company_name}")
     private String companyName;
 
+
+    @Autowired
+    private WxOpenProperties openProperties;
+    @Autowired
+    private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
+
+
+
+    @Autowired
+    private FsUserCourseMapper courseMapper;
+
+    @Autowired
+    private FeiShuService feiShuService;
+
+    private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(20000);
+
+
+    private static final String gjminiappLink = "/pages/course/learning?course=";
+
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
+
+
+
     /**
      * 查询课堂视频
      *
@@ -1637,13 +1664,15 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             return R.error("未完课");
         }
 
-        FsCourseAnswerLogs rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
-        if("泽林文化".equals(signProjectName)){
-            rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideoWithPeriodId(param.getVideoId(), param.getUserId(), param.getQwUserId(),param.getPeriodId());
-        }
-        if (rightLog == null) {
-            logger.error("未答题:{}", param.getUserId());
-            return R.error("未答题");
+        if(!param.getIsFeiShu()){
+            FsCourseAnswerLogs rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
+            if("泽林文化".equals(signProjectName)){
+                rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideoWithPeriodId(param.getVideoId(), param.getUserId(), param.getQwUserId(),param.getPeriodId());
+            }
+            if (rightLog == null) {
+                logger.error("未答题:{}", param.getUserId());
+                return R.error("未答题");
+            }
         }
 
         if (log.getRewardType() != null) {
@@ -3258,6 +3287,61 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     }
 
+
+    @Override
+    public R createCartLinkByFeiShu(FsCourseLinkMiniParam param) {
+
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(), param.getQwUserId().trim());
+
+        if (qwUser==null||qwUser.getCompanyId()==null||qwUser.getCompanyUserId()==null){
+            return R.error("员工未绑定 销售公司 或 销售 请先绑定");
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(param.getCorpId());
+
+        if (qwCompany == null ) {
+            return  R.error().put("msg","企业不存在,请联系管理员");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+
+        if (config == null) {
+            return R.error().put("msg","课程默认配置为空,请联系管理员");
+        }
+
+        Long courseId = param.getCourseId();
+        //qw_user表的主键id
+        Long qwUserId = qwUser.getId();
+        Date createTime = DateUtils.getNowDate();
+        Long externalUserId = param.getExternalUserId();
+        Long videoId = param.getVideoId();
+        Long companyId = qwUser.getCompanyId();
+        Long companyUserId = qwUser.getCompanyUserId();
+
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(),param.getFsUserId(),qwUser , param.getExternalUserId(),3);
+
+        //2.创建课程短链
+        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config);
+        //获取生成的课程短链码
+        String shortCode = feishuH5Link.get("link");
+
+        //3.调用生成飞书注册授权链接
+        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId,shortCode);
+
+
+        //生成卡片链接
+        JSONObject news = new JSONObject(true); // true 表示保持字段顺序
+        news.put("linkTitle", param.getTitle());
+        news.put("linkDescribe", param.getTitle());
+        news.put("linkImageUrl", config.getSidebarImageUrl());
+        news.put("linkUrl", feishuLink);
+
+        return R.ok().put("data",news);
+
+    }
+
+
     //插入观看记录
     private void addWatchLogIfNeeded(Long videoId, Long courseId,
                                      Long fsUserId, QwUser qwUser, Long externalId,Integer watchType) {
@@ -4660,5 +4744,326 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
         return ResponseResult.ok(vo);
     }
+
+    /**
+     * 获取飞书课程详情
+     */
+    @Override
+    public R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId,  Long identifier) {
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        //判断该销售是否存在
+        if (companyUser == null) {
+            return R.error(405, "当前销售不存在");
+        }
+
+        // 获取视频详情
+        FsUserCourseVideoDetailsVO courseVideoDetails = getFeiShuVideoDetails(videoId);
+
+        // 查看为最新课程
+        FsUserCourseAddCompanyUserParam param = new FsUserCourseAddCompanyUserParam();
+        param.setCourseId(courseVideoDetails.getCourseId());
+        param.setVideoId(videoId);
+        param.setCompanyUserId(companyUserId);
+        Map<String, Object> userCoursePeriodValid = isUserCoursePeriodValid(param);
+        if (!((boolean)userCoursePeriodValid.get("isWithinRangeSafe"))) {
+            return R.error(504, "请观看最新的课程项目");
+        }
+
+        // 获取课程所属项目id
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(param.getCourseId());
+        Long courseProject = fsUserCourse.getProject();
+        if (Objects.isNull(courseProject)) {
+            return R.error(504, "课程配置错误,项目归属为空,课程ID: " + param.getCourseId());
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 课程logo
+        config.setCourseLogo(fsUserCourse.getImgUrl());
+
+
+        long duration = 0L;
+        long tipsTime = 0L;
+        long tipsTime2 = 0L;
+        int isFinish = 0;
+        FsUserCourseVideoLinkDetailsVO vo = new FsUserCourseVideoLinkDetailsVO();
+        vo.setCourseVideoDetails(courseVideoDetails);
+        vo.setCourseConfig(config);
+        vo.setIsFinish(isFinish);
+        vo.setPlayDuration(duration);
+
+        // 获取看课记录
+        FeiShuCourseWatchLog watchLog = feishuCourseWatchLogMapper.getWatchLogByFsUser(videoId, identifier, companyUserId);
+        if (watchLog == null) {
+            watchLog = new FeiShuCourseWatchLog();
+            watchLog.setUserId(identifier);
+            watchLog.setCourseId(courseVideoDetails.getCourseId());
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyId(companyUser.getCompanyId());
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setSendType(1);
+            watchLog.setDuration(0);
+            watchLog.setCreateTime(LocalDateTime.now());
+            watchLog.setLogType(1);
+            watchLog.setProject(courseProject);
+            watchLog.setPeriodId(courseProject);
+            feishuCourseWatchLogMapper.insert(watchLog);
+
+            String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId);
+            redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), 5, TimeUnit.MINUTES);
+        }
+
+        // 从Redis中获取用户目前的观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId);
+        Long durationCurrent = redisCache.getCacheObject(redisKey);
+        if (durationCurrent != null) {
+            duration = durationCurrent;
+        } else {
+            duration = watchLog.getDuration();
+            redisCache.setCacheObject(redisKey, duration, 2, TimeUnit.HOURS);
+        }
+
+        //判断是否完课
+        if (watchLog.getLogType() == 2) {
+            isFinish = 1;
+        }
+
+        vo.setTipsTime(tipsTime);
+        vo.setTipsTime2(tipsTime2);
+        vo.setIsFinish(isFinish);
+        vo.setPlayDuration(duration);
+
+        return R.ok().put("data", vo);
+    }
+
+
+    /**
+     * 获取视频详情
+     */
+    private FsUserCourseVideoDetailsVO getFeiShuVideoDetails(Long videoId) {
+        FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        FsUserCourseVideoDetailsVO fsUserCourseVideoDetailsVO = new FsUserCourseVideoDetailsVO();
+        BeanUtils.copyProperties(fsUserCourseVideo, fsUserCourseVideoDetailsVO);
+
+        //这里 改成取线路一值,返回给前端。VideoUrl 是原视频(用来算流量的),不要去改,lineOne是转码后的视频
+        fsUserCourseVideoDetailsVO.setVideoUrl(fsUserCourseVideo.getLineOne());
+
+        // 获取课程相关的题库
+        String questionBankId = fsUserCourseVideo.getQuestionBankId();
+        List<FsUserVideoQuestionVO> questionList = Collections.emptyList();
+        if (StringUtils.isNotEmpty(questionBankId)) {
+            String[] questionBankIds = questionBankId.split(",");
+            List<FsCourseQuestionBank> fsCourseQuestionBanks = courseQuestionBankMapper.selectFsCourseQuestionBankByIdVO(questionBankIds);
+            questionList = fsCourseQuestionBanks.stream().map(v -> {
+                FsUserVideoQuestionVO fsUserVideoQuestionVO = new FsUserVideoQuestionVO();
+                BeanUtils.copyProperties(v, fsUserVideoQuestionVO);
+                return fsUserVideoQuestionVO;
+            }).collect(Collectors.toList());
+        }
+
+        fsUserCourseVideoDetailsVO.setQuestionBankList(questionList);
+        return fsUserCourseVideoDetailsVO;
+    }
+    /**
+     * 更新飞书看课时长
+     */
+    @Override
+    public void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long identifier, Long duration) {
+        // 获取视频总时长
+        Long videoDuration = getAutoLookVideoDuration(videoId);
+        if (duration > videoDuration + 10) {
+            return;
+        }
+
+        // 获取用户观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId);
+        Long userLookDuration = redisCache.getCacheObject(redisKey);
+
+        // 更新观看时长
+        if (duration > (userLookDuration != null ? userLookDuration : 0) + 90) {
+            return;
+        }
+        redisCache.setCacheObject(redisKey, duration, 2, TimeUnit.HOURS);
+
+        // 更新心跳
+        String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId);
+        redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().toString(), 5, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 飞书看课流量统计
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, String uuid, BigDecimal bufferRate) {
+        if (StringUtils.isBlank(uuid)) {
+            return;
+        }
+
+        FsCourseTrafficLog trafficLog = new FsCourseTrafficLog();
+        trafficLog.setCreateTime(new Date());
+        trafficLog.setTypeFlag(2);
+        trafficLog.setUuId(uuid);
+        trafficLog.setUserId(identifier);
+        trafficLog.setVideoId(videoId);
+        trafficLog.setCompanyUserId(companyUserId);
+
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (video == null) {
+            return;
+        }
+
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        if (companyUser == null) {
+            return;
+        }
+
+        Company company = companyMapper.selectCompanyById(companyUser.getCompanyId());
+        if (company == null) {
+            return;
+        }
+
+        trafficLog.setCompanyId(company.getCompanyId());
+        trafficLog.setCourseId(video.getCourseId());
+
+
+        // 计算流量
+        BigDecimal result = bufferRate.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
+        BigDecimal longAsBigDecimal = BigDecimal.valueOf(video.getFileSize());
+        long roundedResult = result.multiply(longAsBigDecimal).setScale(0, RoundingMode.HALF_UP).longValue();
+        trafficLog.setInternetTraffic(roundedResult);
+
+        // 获取课程所属项目id
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+        if (fsUserCourse != null) {
+            trafficLog.setProject(fsUserCourse.getProject());
+        }
+
+        // 插入或更新
+        fsCourseTrafficLogMapper.insertOrUpdateTrafficLog(trafficLog);
+        asyncDeductTraffic(company, trafficLog);
+    }
+
+
+    /**
+     * 生成课程链接
+     * */
+    private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
+                                                   Integer courseId, Integer videoId, String qwUserId,
+                                                   Long companyUserId, Long companyId, Long externalId, CourseConfig config) {
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+        LocalDate tomorrow = LocalDate.now().plusDays(1);
+        LocalDateTime expireDateTime = LocalDateTime.of(tomorrow, LocalTime.of(23, 59, 59));
+        Date expireDate = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+
+        link.setUpdateTime(expireDate);
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());//真实课程链接
+        result.put("link", link.getLink());//课程短链
+        return result;
+    }
+
+    private Map<String, String> createH5GjLinkByMiniApp(String corpId, Date sendTime,
+                                                        Integer courseId, Integer videoId, String qwUserId,
+                                                        Long companyUserId, Long companyId, Long externalId, CourseConfig config) {
+
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+
+        String realLinkFull = gjminiappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        //存短链-
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
+
+    public FsCourseLink createFsCourseLink(String corpId, Date sendTime, Integer courseId, Integer videoId, Long qwUserId,
+                                           Long companyUserId, Long companyId, Long externalId, Integer type, String chatId) {
+        // 手动创建 FsCourseLink 对象,避免使用 BeanUtils.copyProperties
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(companyId);
+        link.setQwUserId(qwUserId);
+        link.setCompanyUserId(companyUserId);
+        link.setVideoId(videoId.longValue());
+        link.setCorpId(corpId);
+        link.setCourseId(courseId.longValue());
+        link.setChatId(chatId);
+        link.setQwExternalId(externalId);
+        link.setLinkType(type); //小程序
+        link.setIsRoom(1);
+        link.setUNo(UUID.randomUUID().toString());
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)) {
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        } else {
+            link.setLink(randomString);
+        }
+
+        link.setCreateTime(sendTime);
+        link.setProjectCode(cloudHostProper.getProjectCode());
+
+        return link;
+    }
+
+    private void addWatchLogIfNeeded(Long fsUserId, Long videoId, Long courseId,
+                                     Date sendTime, String qwUserId, Long companyUserId,
+                                     Long companyId, Long externalId) {
+        FsCourseWatchLog watchLog = new FsCourseWatchLog();
+        watchLog.setVideoId(videoId);
+        watchLog.setQwExternalContactId(externalId);
+        watchLog.setSendType(2);
+        watchLog.setQwUserId(Long.parseLong(qwUserId));
+        watchLog.setDuration(0L);
+        watchLog.setCourseId(courseId);
+        watchLog.setCompanyUserId(companyUserId);
+        watchLog.setCompanyId(companyId);
+        watchLog.setCreateTime(sendTime);
+        watchLog.setUpdateTime(new Date());
+        watchLog.setLogType(3);
+        watchLog.setUserId(fsUserId);
+        enqueueWatchLog(watchLog);
+    }
+
+    /**
+     * 将 FsCourseWatchLog 放入队列
+     */
+    private void enqueueWatchLog(FsCourseWatchLog watchLog) {
+        try {
+            boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
+            if (!offered) {
+                log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
+                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.error("插入 FsCourseWatchLog 队列时被中断: {}", e.getMessage(), e);
+        }
+    }
+
+
+
 }
 

+ 13 - 0
fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java

@@ -0,0 +1,13 @@
+package com.fs.feishu.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Data
+@Configuration
+@ConfigurationProperties("feishu")
+public class FeiShuConfig {
+    private String appId;
+    private String appSecret;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/feishu/domain/FeishuAccount.java

@@ -0,0 +1,18 @@
+package com.fs.feishu.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class FeishuAccount {
+    private Long id;
+    private String appId;
+    private String appSecret;
+    private Integer status;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+    private String errorMsg;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/feishu/mapper/FeishuAccountMapper.java

@@ -0,0 +1,22 @@
+package com.fs.feishu.mapper;
+
+import com.fs.feishu.domain.FeishuAccount;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface FeishuAccountMapper {
+    /**
+     * 查询所有启用的账号
+     */
+    List<FeishuAccount> selectEnabledAccounts();
+
+    /**
+     * 更新账号状态和错误信息
+     */
+    int updateStatusAndErrorMsg(@Param("id") Long id,
+                                @Param("status") Integer status,
+                                @Param("errorMsg") String errorMsg);
+}

+ 16 - 0
fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java

@@ -0,0 +1,16 @@
+package com.fs.feishu.model;
+
+import lombok.Data;
+
+@Data
+public class FeishuFileDTO {
+
+    private String name;
+    private String token;
+    private String type;
+    private String url;
+    private String ownerId;
+    private String parentToken;
+    private String createdTime;
+    private String modifiedTime;
+}

+ 41 - 0
fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java

@@ -0,0 +1,41 @@
+package com.fs.feishu.model;
+
+import lombok.Data;
+
+
+import java.io.Serializable;
+
+/**
+ * 飞书链接参数
+ */
+@Data
+public class FeishuLinkParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司用户ID */
+    private Long companyUserId;
+
+    /** 课程ID */
+    private Long courseId;
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 期数ID */
+    private Long periodId;
+
+    /** 项目ID */
+    private Long projectId;
+
+    private  Long userId;
+
+    private  Long id;
+
+    //飞书看课短链码
+    private  String link;
+
+}

+ 200 - 0
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -0,0 +1,200 @@
+package com.fs.feishu.service;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.CustomException;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsCourseLink;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsCourseLinkMapper;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.system.service.ISysConfigService;
+import com.lark.oapi.Client;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 飞书业务编排层:
+ * 负责课程链接和注册链接的完整业务流程,包括缓存、URL 构建、调用底层 API 服务等。
+ */
+@Component
+@Slf4j
+public class FeiShuService {
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private FsUserCourseVideoMapper videoMapper;
+    @Autowired
+    private FsCourseLinkMapper courseLinkMapper;
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FeishuClientPool clientPool;
+    @Autowired
+    private FeishuDocApiService docApiService;
+
+    /**
+     * 001 生成飞书初始链接(注册授权链接)
+     * 每次 shortCode 唯一
+     */
+    public String getFeishuRegisterLink(Long videoId, Long companyId, Long courseId,
+                                        Long companyUserId, String shortLink) {
+        // 检查功能开关
+        CourseConfig config = getCourseConfig();
+        if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
+            throw new CustomException("未开启飞书看课");
+        }
+
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + videoId);
+        }
+
+        int maxRetries = clientPool.getAvailableCount();
+        for (int i = 0; i < maxRetries; i++) {
+            Client client = clientPool.getClient();
+            try {
+                String documentId = docApiService.createDocument(client, userCourseVideo.getTitle() + "-注册");
+                String authUrl = buildAuthLink(companyId, companyUserId, courseId, videoId, shortLink);
+                docApiService.createTextLinkBlock(client, documentId, authUrl);
+                docApiService.changeDocumentPermissions(client, documentId);
+                return "https://www.feishu.cn/docx/" + documentId;
+            } catch (CustomException e) {
+                // 如果是账号禁用异常,继续尝试下一个账号
+                if (e.getMessage().contains("不可用") || e.getMessage().contains("已被自动禁用")) {
+                    log.warn("账号不可用,尝试下一个");
+                    continue;
+                }
+                throw e;
+            } catch (Exception e) {
+                throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
+            }
+        }
+        throw new CustomException("所有飞书账号均不可用,请联系管理员");
+    }
+
+    /**
+     * 002 生成飞书课程链接
+     * 相同参数可能重复,使用 Redis 缓存避免重复创建文档
+     */
+    public String getFeishuCourseNewLink(FeishuLinkParam param) {
+        if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
+            throw new CustomException("参数不能为空");
+        }
+
+        String cacheKey = String.format("feishu:course_link:%d:%d:%d:%d:%d:%s",
+                param.getCompanyId(), param.getCompanyUserId(),
+                param.getCourseId(), param.getVideoId(),
+                param.getUserId(), param.getLink());
+
+        // 缓存命中直接返回
+        String cached = redisCache.getCacheObject(cacheKey);
+        if (StringUtils.isNotBlank(cached)) {
+            return cached;
+        }
+
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + param.getVideoId());
+        }
+
+        int maxRetries = clientPool.getAvailableCount();
+        for (int i = 0; i < maxRetries; i++) {
+            Client client = clientPool.getClient();
+            try {
+                String documentId = docApiService.createDocument(client, userCourseVideo.getTitle());
+                String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(),
+                        param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink());
+                docApiService.createIframeBlock(client, documentId, url);
+                docApiService.changeDocumentPermissions(client, documentId);
+
+                String resultUrl = "https://www.feishu.cn/docx/" + documentId;
+                redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
+                return resultUrl;
+            } catch (CustomException e) {
+                if (e.getMessage().contains("不可用") || e.getMessage().contains("已被自动禁用")) {
+                    log.warn("账号不可用,尝试下一个");
+                    continue;
+                }
+                throw e;
+            } catch (Exception e) {
+                throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
+            }
+        }
+        throw new CustomException("所有飞书账号均不可用,请联系管理员");
+    }
+
+    // ==================== URL 构建(纯业务逻辑,无外部调用) ====================
+
+    private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId,
+                                      Long videoId, Long userId, String link) {
+        CourseConfig config = getCourseConfig();
+
+        Map<String, Object> courseParam = new LinkedHashMap<>();
+        courseParam.put("companyUserId", companyUserId);
+        courseParam.put("videoId", videoId);
+        courseParam.put("companyId", companyId);
+        courseParam.put("courseId", courseId);
+        courseParam.put("link", link);
+
+        FsCourseLink fsCourseLink = courseLinkMapper.selectFsCourseLinkByLink(link);
+        if (fsCourseLink == null) {
+            throw new CustomException("课程链接已过期");
+        }
+        courseParam.put("corpId", fsCourseLink.getCorpId());
+        courseParam.put("linkType", fsCourseLink.getLinkType());
+        courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
+        courseParam.put("qwUserId", fsCourseLink.getQwUserId());
+
+        try {
+            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
+            String fullUrl = config.getFeishuCourseDomain() +
+                    "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" + userId;
+            return URLEncoder.encode(fullUrl, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new CustomException("看课URL拼接失败", e);
+        }
+    }
+
+    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId,
+                                 Long videoId, String shortLink) {
+        CourseConfig config = getCourseConfig();
+
+        Map<String, Object> authParam = new LinkedHashMap<>();
+        authParam.put("companyUserId", companyUserId);
+        authParam.put("videoId", videoId);
+        authParam.put("companyId", companyId);
+        authParam.put("courseId", courseId);
+        authParam.put("link", shortLink);
+
+        try {
+            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
+            String fullUrl = config.getFeishuAuthDomain() +
+                    "/feishuCourse/pages/wxauth?course=" + encodedJson;
+            return URLEncoder.encode(fullUrl, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new CustomException("授权URL拼接失败", e);
+        }
+    }
+
+    /**
+     * 读取课程配置并检查非空
+     */
+    private CourseConfig getCourseConfig() {
+        String json = configService.selectConfigByKey("course.config");
+        if (StringUtils.isBlank(json)) {
+            throw new CustomException("飞书看课配置为空");
+        }
+        return JSONUtil.toBean(json, CourseConfig.class);
+    }
+}

+ 139 - 0
fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java

@@ -0,0 +1,139 @@
+package com.fs.feishu.service;
+
+
+import com.fs.common.exception.CustomException;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.mapper.FeishuAccountMapper;
+import com.lark.oapi.Client;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 飞书账号池管理:
+ * 1. 启动时从数据库加载所有启用账号,构建 Client 列表
+ * 2. 使用 Redis 循环队列实现跨实例的轮询负载均衡
+ * 3. 提供禁用异常账号的能力,同步更新数据库、内存和 Redis 索引
+ */
+@Component
+@Slf4j
+public class FeishuClientPool {
+
+    @Autowired
+    private FeishuAccountMapper feishuAccountMapper;
+
+
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    private volatile List<Client> clients;
+    private volatile List<FeishuAccount> accountList;
+    private static final String POOL_KEY = "feishu:account_pool";
+
+    @PostConstruct
+    public void init() {
+        refresh();
+    }
+
+    public synchronized void refresh() {
+        List<FeishuAccount> accounts = feishuAccountMapper.selectEnabledAccounts();
+        if (accounts.isEmpty()) {
+            throw new CustomException("没有可用的飞书应用账号");
+        }
+
+        List<Client> newClients = new ArrayList<>(accounts.size());
+        for (FeishuAccount acc : accounts) {
+            Client client = Client.newBuilder(acc.getAppId(), acc.getAppSecret())
+                    .logReqAtDebug(true)
+                    .build();
+            newClients.add(client);
+        }
+
+        this.accountList = accounts;
+        this.clients = newClients;
+        resetPoolIndexes(newClients.size());
+        log.info("飞书账号池刷新完成,当前可用账号数: {}", newClients.size());
+    }
+
+    private void resetPoolIndexes(int size) {
+        stringRedisTemplate.delete(POOL_KEY);
+        if (size == 0) {
+            return;
+        }
+        List<String> indexes = new ArrayList<>(size);
+        for (int i = 0; i < size; i++) {
+            indexes.add(String.valueOf(i));
+        }
+        // 使用 StringRedisTemplate 的 rightPushAll,保证纯字符串存储
+        stringRedisTemplate.opsForList().rightPushAll(POOL_KEY, indexes);
+        log.info("Redis 账号索引池已重置,数量: {}", size);
+    }
+
+    public Client getClient() {
+        while (true) {
+            // 1. 从 Redis 循环队列取出索引(原子操作)
+            String indexStr = stringRedisTemplate.opsForList()
+                    .rightPopAndLeftPush(POOL_KEY, POOL_KEY);
+            if (indexStr == null) {
+                log.warn("账号池为空,尝试重新初始化");
+                resetPoolIndexes(clients.size());
+                continue;
+            }
+            int index = Integer.parseInt(indexStr);
+
+            // 2. 获取当前 clients 快照(volatile 读)
+            List<Client> currentClients = this.clients;
+
+            // 3. 如果索引越界(说明列表已被其他线程缩小),丢弃该索引并重试
+            if (index >= currentClients.size()) {
+                log.warn("索引 {} 已失效(当前账号数 {}),重试", index, currentClients.size());
+                continue;
+            }
+            return currentClients.get(index);
+        }
+    }
+
+    /**
+     * 禁用指定的 Client,并移除出账号池
+     *
+     * @param failedClient 需要禁用的 Client
+     * @param errorMsg     错误原因,会写入数据库 error_msg 字段
+     */
+    public synchronized void disableClient(Client failedClient, String errorMsg) {
+        for (int i = 0; i < clients.size(); i++) {
+            if (clients.get(i) == failedClient) {
+                FeishuAccount account = accountList.get(i);
+                // 更新数据库状态为禁用(status=0),并记录错误信息
+                feishuAccountMapper.updateStatusAndErrorMsg(account.getId(), 0, errorMsg);
+                // 从内存中移除
+                clients.remove(i);
+                accountList.remove(i);
+                // 重建索引池,保证索引仍然连续
+                resetPoolIndexes(clients.size());
+                log.warn("飞书账号[{}]已被自动禁用,原因: {}", account.getAppId(), errorMsg);
+                throw new CustomException(String.format("飞书账号[%s]不可用: %s", account.getAppId(), errorMsg));
+            }
+        }
+        // 如果未找到,理论上不会执行到这里
+        throw new CustomException("无法找到对应的飞书账号");
+    }
+
+    /**
+     * 获取当前可用账号数量的快照(用于重试上限)
+     */
+    public int getAvailableCount() {
+        return clients.size();
+    }
+
+    /**
+     * 获取所有可用 Client 的快照(用于定时任务等遍历场景)
+     */
+    public List<Client> getAllClients() {
+        return new ArrayList<>(clients);
+    }
+}

+ 206 - 0
fs-service/src/main/java/com/fs/feishu/service/FeishuDocApiService.java

@@ -0,0 +1,206 @@
+package com.fs.feishu.service;
+
+import com.fs.common.exception.CustomException;
+import com.fs.feishu.model.FeishuFileDTO;
+import com.lark.oapi.Client;
+import com.lark.oapi.service.docx.v1.model.*;
+import com.lark.oapi.service.drive.v1.model.DeleteFileReq;
+import com.lark.oapi.service.drive.v1.model.DeleteFileResp;
+import com.lark.oapi.service.drive.v1.model.ListFileReq;
+import com.lark.oapi.service.drive.v1.model.ListFileResp;
+import com.lark.oapi.service.drive.v2.model.PatchPermissionPublicReq;
+import com.lark.oapi.service.drive.v2.model.PatchPermissionPublicResp;
+import com.lark.oapi.service.drive.v2.model.PermissionPublic;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 飞书文档 API 适配层:
+ * 封装所有对飞书 OpenAPI 的底层调用,不关心账号分配。
+ * 当 API 返回账号级错误时,会通知账号池禁用该 Client,并抛出异常。
+ */
+@Service
+@Slf4j
+public class FeishuDocApiService {
+
+    @Autowired
+    private FeishuClientPool clientPool;
+
+    /**
+     * 判断错误码是否属于需要禁用应用账号的严重错误
+     */
+    private boolean isAccountError(int code) {
+        // 99991663: 应用已被管理员停用
+        // 99991664: 应用已被管理员删除
+        // 10012   : 应用未开通所需权限
+        return code == 99991663 || code == 99991664 || code == 10012;
+    }
+
+    /**
+     * 检查 API 响应,若为账号级错误则禁用当前 Client 并抛出异常,否则只抛出业务异常
+     */
+    private void checkResponse(Client client, int code, String msg) {
+        if (code == 0) {
+            return;
+        }
+        if (isAccountError(code)) {
+            // 禁用该账号,禁用方法内部会抛出异常,流程不会继续
+            clientPool.disableClient(client, msg);
+        }
+        // 非账号级别错误,直接抛出业务异常
+        throw new CustomException(String.format("飞书 API 错误: code=%d, msg=%s", code, msg));
+    }
+
+    /**
+     * 创建云文档
+     */
+    public String createDocument(Client client, String title) throws Exception {
+        CreateDocumentReq req = CreateDocumentReq.newBuilder()
+                .createDocumentReqBody(CreateDocumentReqBody.newBuilder().title(title).build())
+                .build();
+        CreateDocumentResp resp = client.docx().v1().document().create(req);
+        checkResponse(client, resp.getCode(), resp.getMsg());
+        return resp.getData().getDocument().getDocumentId();
+    }
+
+    /**
+     * 在文档中插入 iframe 块
+     */
+    public void createIframeBlock(Client client, String documentId, String url) throws Exception {
+        CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
+                .documentId(documentId)
+                .blockId(documentId)
+                .documentRevisionId(-1)
+                .createDocumentBlockChildrenReqBody(CreateDocumentBlockChildrenReqBody.newBuilder()
+                        .children(new Block[]{
+                                Block.newBuilder()
+                                        .blockType(26)
+                                        .iframe(Iframe.newBuilder()
+                                                .component(IframeComponent.newBuilder()
+                                                        .iframeType(1)
+                                                        .url(url)
+                                                        .build())
+                                                .build())
+                                        .build()
+                        })
+                        .index(0)
+                        .build())
+                .build();
+        CreateDocumentBlockChildrenResp resp = client.docx().v1().documentBlockChildren().create(req);
+        checkResponse(client, resp.getCode(), resp.getMsg());
+    }
+
+    /**
+     * 在文档中插入 text + link 块(用于“观看课程”超链接)
+     */
+    public void createTextLinkBlock(Client client, String documentId, String url) throws Exception {
+        CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
+                .documentId(documentId)
+                .blockId(documentId)
+                .documentRevisionId(-1)
+                .createDocumentBlockChildrenReqBody(
+                        CreateDocumentBlockChildrenReqBody.newBuilder()
+                                .index(0)
+                                .children(new Block[]{
+                                        Block.newBuilder()
+                                                .blockType(2)
+                                                .text(Text.newBuilder()
+                                                        .elements(new TextElement[]{
+                                                                TextElement.newBuilder()
+                                                                        .textRun(TextRun.newBuilder()
+                                                                                .content("观看课程")
+                                                                                .textElementStyle(TextElementStyle.newBuilder()
+                                                                                        .link(Link.newBuilder()
+                                                                                                .url(url)
+                                                                                                .build())
+                                                                                        .build())
+                                                                                .build())
+                                                                        .build()
+                                                        })
+                                                        .build())
+                                                .build()
+                                })
+                                .build())
+                .build();
+        CreateDocumentBlockChildrenResp resp = client.docx().v1().documentBlockChildren().create(req);
+        checkResponse(client, resp.getCode(), resp.getMsg());
+    }
+
+    /**
+     * 修改文档权限为互联网公开可读
+     */
+    public void changeDocumentPermissions(Client client, String documentId) throws Exception {
+        PatchPermissionPublicReq req = PatchPermissionPublicReq.newBuilder()
+                .token(documentId)
+                .type("docx")
+                .permissionPublic(PermissionPublic.newBuilder()
+                        .externalAccessEntity("open")
+                        .securityEntity("anyone_can_view")
+                        .commentEntity("anyone_can_edit")
+                        .shareEntity("anyone")
+                        .manageCollaboratorEntity("collaborator_full_access")
+                        .linkShareEntity("anyone_readable")
+                        .copyEntity("only_full_access")
+                        .build())
+                .build();
+        PatchPermissionPublicResp resp = client.drive().v2().permissionPublic().patch(req);
+        checkResponse(client, resp.getCode(), resp.getMsg());
+    }
+
+    /**
+     * 分页获取所有云文档(按创建时间升序排列,便于清理)
+     */
+    public List<FeishuFileDTO> listAllFiles(Client client) throws Exception {
+        List<FeishuFileDTO> allFiles = new ArrayList<>();
+        String pageToken = null;
+        int pageSize = 200;
+
+        do {
+            ListFileReq.Builder builder = ListFileReq.newBuilder()
+                    .pageSize(pageSize)
+                    .orderBy("CreatedTime")
+                    .direction("ASC");
+            if (StringUtils.isNotBlank(pageToken)) {
+                builder.pageToken(pageToken);
+            }
+            ListFileResp resp = client.drive().v1().file().list(builder.build());
+            checkResponse(client, resp.getCode(), resp.getMsg());
+
+            if (resp.getData() != null && resp.getData().getFiles() != null) {
+                for (com.lark.oapi.service.drive.v1.model.File file : resp.getData().getFiles()) {
+                    FeishuFileDTO dto = new FeishuFileDTO();
+                    dto.setName(file.getName());
+                    dto.setToken(file.getToken());
+                    dto.setType(file.getType());
+                    dto.setUrl(file.getUrl());
+                    dto.setOwnerId(file.getOwnerId());
+                    dto.setParentToken(file.getParentToken());
+                    dto.setCreatedTime(file.getCreatedTime());
+                    dto.setModifiedTime(file.getModifiedTime());
+                    allFiles.add(dto);
+                }
+            }
+            pageToken = resp.getData().getNextPageToken();
+        } while (StringUtils.isNotBlank(pageToken));
+
+        return allFiles;
+    }
+
+    /**
+     * 根据 token 和类型删除指定文件
+     */
+    public boolean deleteFile(Client client, String fileToken, String type) throws Exception {
+        DeleteFileReq req = DeleteFileReq.newBuilder()
+                .fileToken(fileToken)
+                .type(type)
+                .build();
+        DeleteFileResp resp = client.drive().v1().file().delete(req);
+        checkResponse(client, resp.getCode(), resp.getMsg());
+        return true;
+    }
+}

+ 99 - 0
fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java

@@ -0,0 +1,99 @@
+package com.fs.feishu.util;
+
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+
+public class SecureTokenUtil {
+
+    private static final String ALGORITHM = "AES";
+    private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
+    private static final String SECRET_KEY = "9f3a7c21b8e4d6a5c2f1097e3b4a6d8f"; // 16字节密钥
+
+    /**
+     * 生成安全令牌
+     */
+    public static String generateToken(Long companyUserId, Long videoId,  Long timestamp) {
+        try {
+            String dataBuilder = companyUserId + ":" + videoId + ":" + timestamp;
+            return encryptData(dataBuilder);
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌生成失败", e);
+        }
+    }
+
+    /**
+     * 生成安全令牌
+     */
+    public static String generateTokenUser(Long companyUserId, Long videoId, Long periodId, Long timestamp,Long userId) {
+        try {
+            String dataBuilder = companyUserId + ":" + videoId + ":" + periodId + ":"+userId+":" + timestamp;
+            return encryptData(dataBuilder);
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌生成失败", e);
+        }
+    }
+
+    /**
+     * 加密数据
+     */
+    private static String encryptData(String data) throws Exception {
+        SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
+        byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted);
+    }
+
+    /**
+     * 解析安全令牌(兼容4字段和5字段)
+     */
+    public static Map<String, Object> parseToken(String token) {
+        try {
+            String decryptedData = decryptData(token);
+            String[] parts = decryptedData.split(":");
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("companyUserId", Long.parseLong(parts[0]));
+            result.put("videoId", Long.parseLong(parts[1]));
+            result.put("periodId", Long.parseLong(parts[2]));
+            if (parts.length == 5) {
+                result.put("userId", Long.parseLong(parts[3]));
+                result.put("timestamp", Long.parseLong(parts[4]));
+            } else {
+                result.put("timestamp", Long.parseLong(parts[3]));
+            }
+
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException("飞书令牌解析失败", e);
+        }
+    }
+
+    /**
+     * 解密数据
+     */
+    private static String decryptData(String encryptedData) throws Exception {
+        SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
+        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+        cipher.init(Cipher.DECRYPT_MODE, keySpec);
+        byte[] decrypted = cipher.doFinal(Base64.getUrlDecoder().decode(encryptedData));
+        return new String(decrypted, StandardCharsets.UTF_8);
+    }
+
+    /**
+     * 验证令牌时效性
+     */
+    public static boolean isTokenValid(Long timestamp) {
+        if (timestamp == null) {
+            return false;
+        }
+        long currentTime = System.currentTimeMillis() / 1000;
+        return (currentTime - timestamp) <= 60 * 60 * 24 * 2;
+    }
+}

+ 51 - 0
fs-service/src/main/java/com/fs/qw/domain/QwAppMiniProgramRelation.java

@@ -0,0 +1,51 @@
+package com.fs.qw.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 应用-小程序关系对象 qw_app_mini_program_relation
+ *
+ * @author fs
+ * @date 2026-01-13
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class QwAppMiniProgramRelation extends BaseEntity{
+
+    /** 主键ID */
+    private Long id;
+
+    /** 小程序ID */
+    @Excel(name = "小程序ID")
+    private String miniAppId;
+
+    /** 小程序名称 */
+    @Excel(name = "小程序名称")
+    private String miniAppName;
+
+    /** 应用ID */
+    @Excel(name = "应用ID")
+    private String agentId;
+
+    /** 企业CorpID */
+    @Excel(name = "企业CorpID")
+    private String corpId;
+
+    /** 服务商应用的Secret */
+    @Excel(name = "服务商应用的Secret")
+    private String openSecret;
+
+    /** 企业名称 */
+    @Excel(name = "企业名称")
+    private String corpName;
+
+    /** 状态: 1-启用, 0-禁用 */
+    @Excel(name = "状态: 1-启用, 0-禁用")
+    private Long status;
+
+
+}

+ 24 - 0
fs-service/src/main/java/com/fs/sop/service/impl/QwSopTempServiceImpl.java

@@ -10,6 +10,7 @@ import com.fs.common.BeanCopyUtils;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.core.domain.R;
 import com.fs.common.enums.DataSourceType;
+import com.fs.common.exception.CustomException;
 import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.PubFun;
 import com.fs.common.utils.StringUtils;
@@ -232,6 +233,7 @@ public class QwSopTempServiceImpl implements IQwSopTempService {
     public Map<String, Object> addOrUpdateSetting(QwSopTempDay day) {
         List<QwSopTempRules> list = day.getList();
         List<QwSopTempContent> collect = list.stream().flatMap(e -> e.getSettingList().stream()).collect(Collectors.toList());
+        checkFeishuSopRule(list);
         List<QwSopTempContent> voiceList = collect.stream().filter(e -> e.getContentType() == 7).collect(Collectors.toList());
 //        if(!voiceList.isEmpty()){
 ////            day.setVoice(1);
@@ -305,6 +307,28 @@ public class QwSopTempServiceImpl implements IQwSopTempService {
         return map;
     }
 
+    /**
+     * 校验飞书看课规则:当内容类别为18时,其所属规则的消息类别必须为课程(type==2)
+     */
+    public void checkFeishuSopRule(List<QwSopTempRules> rulesList) {
+        if (rulesList == null || rulesList.isEmpty()) {
+            return;
+        }
+        for (QwSopTempRules rule : rulesList) {
+            List<QwSopTempContent> settingList = rule.getSettingList();
+            if (settingList == null || settingList.isEmpty()) {
+                continue;
+            }
+            boolean hasFeishu = settingList.stream()
+                    .anyMatch(content -> content.getContentType() != null && content.getContentType() == 18);
+            if (hasFeishu) {
+                if (rule.getType() == null || rule.getType() != 2) {
+                    throw new CustomException("当内容类别为[飞书看课]时,必须选课程");
+                }
+            }
+        }
+    }
+
     private void processAndReplaceContent(List<QwSopTempRules> tempSettings) {
         List<FastGptChatReplaceWords> words = fastGptChatReplaceWordsMapper.selectAllFastGptChatReplaceWords();
         //循环天

+ 188 - 2
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.live.domain.LiveWatchLog;
@@ -111,6 +112,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 //    private static final String miniappRealLink = "/pages/index/index?course=";
     private static final String gjminiappLink = "/pages/course/learning?course=";
 
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
+
     @Autowired
     private ISysConfigService configService;
 
@@ -210,6 +213,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private XiaoShouYiMaterialService xiaoShouYiMaterialService;
 
+    @Autowired
+    private FeiShuService feiShuService;
 
 
     @Override
@@ -871,6 +876,34 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     log.error("浏览器看课模板解析失败:" + e);
                                 }
                                 break;
+                            //飞书看课
+                            case "18":
+                                // 参数转换
+                                Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                Long companyIdLong = qwUser.getCompanyId();
+                                Long externalUserId = vo.getId();
+                                Long companyUserIdLong = Long.parseLong(companyUserId);
+                                if (videoId == null) {
+                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                    break;
+                                }
+
+                                //2.创建课程短链
+                                Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId, videoId, qwUser.getId(), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
+                                //获取生成的课程短链码
+                                String shortCode = feishuH5Link.get("link");
+
+                                //3.调用生成飞书注册授权链接
+                                String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortCode);
+
+                                if (StringUtils.isNotEmpty(feishuLink)) {
+
+                                    st.setLinkUrl(feishuLink);
+                                } else {
+                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                                }
+                                break;
                             //跳转app看课
                             case "23":
                                 try {
@@ -1182,6 +1215,33 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 sopLogs.setAppSendStatus(0);
                                 break;
+                            //飞书看课
+                            case "18":
+                                // 参数转换
+                                Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                Long companyIdLong = Long.valueOf(qwUser.getCompanyId().toString());
+                                Long externalUserId = Long.valueOf(groupChat.getChatId());
+                                Long companyUserIdLong = Long.valueOf(qwUser.getCompanyUserId().toString());
+                                if (videoId == null) {
+                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                    break;
+                                }
+                                //2.创建课程短链
+                                Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId, videoId, qwUser.getId(), String.valueOf(companyUserIdLong), String.valueOf(companyIdLong), externalUserId, config);
+                                //获取生成的课程短链码
+                                String shortCode = feishuH5Link.get("link");
+
+                                //3.调用生成飞书注册授权链接
+                                String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortCode);
+
+                                if (StringUtils.isNotEmpty(feishuLink)) {
+
+                                    st.setLinkUrl(feishuLink);
+                                } else {
+                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                                }
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -1620,6 +1680,52 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("浏览器看课模板解析失败:" + e);
                             }
 
+                            break;
+                        //飞书看课
+                        case "18":
+                            // 参数转换
+                            Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                            Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                            Long companyIdLong = Long.parseLong(companyId);
+                            Long companyUserIdLong = Long.parseLong(companyUserId);
+                            if (videoId == null) {
+                                log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                break;
+                            }
+
+                            //1.添加待看课记录
+                            addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime,3);
+
+                            // 替换占位符(销售称呼、客户称呼等)
+                            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())) {
+                                st.setLinkTitle(st.getLinkTitle()
+                                        .replaceAll("#销售称呼#", txt18)
+                                        .replaceAll("#客户称呼#", customerTitle));
+                            }
+                            if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                                st.setLinkDescribe(st.getLinkDescribe()
+                                        .replaceAll("#销售称呼#", txt18)
+                                        .replaceAll("#客户称呼#", customerTitle));
+                            }
+
+                            //2.创建课程短链
+                            Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId, videoId, qwUser.getId(), companyUserId, companyId, item.getExternalId(), config);
+                            //获取生成的课程短链码
+                            String shortCode = feishuH5Link.get("link");
+
+                            //3.调用生成飞书注册授权链接
+                            String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortCode);
+
+                            if (StringUtils.isNotEmpty(feishuLink)) {
+
+                                st.setLinkUrl(feishuLink);
+                            } else {
+                                log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                            }
                             break;
                         case "21":
                             if (sopLogs.getFsUserId() != null && !Long.valueOf(0L).equals(sopLogs.getFsUserId())) {
@@ -2510,9 +2616,65 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("浏览器看课模板解析失败:" + e);
                     }
                     break;
-                //直播h5跳转卡片
+                //飞书看课
                 case "18":
-                    //直播h5跳转短链
+                    // 参数转换
+                    Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                    Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+
+                    Long externalId = item.getExternalId();
+                    if (videoId == null) {
+                        log.error("飞书链接生成失败:视频ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (courseId == null) {
+                        log.error("飞书链接生成失败:课程ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (externalId == null) {
+                        log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (qwUser.getId() == null) {
+                        log.error("飞书链接生成失败:用户ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+
+                    //1.添加待看课记录
+                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime,3);
+
+                    //2.创建课程短链
+                    Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId, videoId, qwUser.getId(), companyUserId, companyId, externalId, config);
+                    //获取生成的课程短链码
+                    String shortLink = feishuH5Link.get("link");
+
+                    //3.调用生成飞书注册授权链接
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortLink);
+
+                    if (StringUtils.isNotEmpty(feishuLink)) {
+                        // 替换占位符
+                        String txt = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                        String customerTitle = contact == null ? "同学" :
+                                (StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus());
+                        if (StringUtils.isNotEmpty(st.getLinkTitle())) {
+                            st.setLinkTitle(st.getLinkTitle()
+                                    .replaceAll("#销售称呼#", txt)
+                                    .replaceAll("#客户称呼#", customerTitle));
+                        }
+                        if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                            st.setLinkDescribe(st.getLinkDescribe()
+                                    .replaceAll("#销售称呼#", txt)
+                                    .replaceAll("#客户称呼#", customerTitle));
+                        }
+                        st.setLinkUrl(feishuLink);
+                    } else {
+                        log.error("生成飞书注册链接失败,sopId={}", item.getSopId());
+                    }
+                    break;
                 case "19":
                     sopLogs.setSendType(Integer.valueOf(st.getContentType()));
                     Long qwUserId = qwUser.getId();
@@ -2635,6 +2797,30 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return  list;
     }
 
+    /**
+     * 生成课程链接
+     * */
+    private Map<String, String> createFeishuH5Link(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                                   Integer courseId, Integer videoId, Long qwUserId,
+                                                   String companyUserId, String companyId, Long externalId, CourseConfig config) {
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+        link.setUpdateTime(createUpdateTime(setting, sendTime, config));
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());//真实课程链接
+        result.put("link", link.getLink());//课程短链
+        return result;
+    }
+
     /**
      * 创建看课短链
      * @param setting

+ 4 - 1
fs-service/src/main/resources/application-druid-myhk-new.yml

@@ -164,4 +164,7 @@ im:
     type: OPENIM
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
-
+# 飞书
+feishu:
+    appId: "cli_aad59acbe6791bd3"
+    appSecret: "DVONRDbq5aEy6FKACKv7HdGb5rIgCfFJ"

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

@@ -159,5 +159,9 @@ im:
     type: OPENIM
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
+# 飞书
+feishu:
+    appId: "cli_aad59acbe6791bd3"
+    appSecret: "DVONRDbq5aEy6FKACKv7HdGb5rIgCfFJ"
 
 

+ 4 - 1
fs-service/src/main/resources/application-druid-myhk.yml

@@ -175,4 +175,7 @@ xiaoshouyi:
     scope: all
     oauth-type: standard
     access-type: offline
-
+# 飞书
+feishu:
+    appId: "cli_aad59acbe6791bd3"
+    appSecret: "DVONRDbq5aEy6FKACKv7HdGb5rIgCfFJ"

+ 28 - 0
fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.course.mapper.FeiShuCourseWatchLogMapper">
+
+    <update id="updateByVideoIdAndUserIdAndCompanyUserIdAndPeriodId" parameterType="com.fs.course.domain.FeiShuCourseWatchLog">
+        UPDATE feishu_course_watch_log
+        <set>
+            <if test="logType != null">
+                log_type = #{logType},
+            </if>
+            <if test="duration != null">
+                duration = #{duration},
+            </if>
+            <if test="lastHeartbeatTime != null">
+                last_heartbeat_time = #{lastHeartbeatTime},
+            </if>
+            <if test="finishTime != null">
+                finish_time = #{finishTime},
+            </if>
+        </set>
+        WHERE video_id = #{videoId}
+        AND user_id = #{userId}
+        AND company_user_id = #{companyUserId}
+        AND period_id = #{periodId}
+    </update>
+</mapper>

+ 15 - 0
fs-service/src/main/resources/mapper/his/FeishuAccountMapper.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.feishu.mapper.FeishuAccountMapper">
+    <select id="selectEnabledAccounts" resultType="com.fs.feishu.domain.FeishuAccount">
+        SELECT id, app_id, app_secret, status, create_time, update_time,error_msg
+        FROM feishu_account
+        WHERE status = 1
+    </select>
+
+    <update id="updateStatusAndErrorMsg">
+        UPDATE feishu_account SET status = #{status}, error_msg = #{errorMsg}
+        WHERE id = #{id}
+    </update>
+</mapper>

+ 7 - 5
fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java

@@ -32,14 +32,16 @@ public class AppBaseController {
 	{
 		String headValue =  ServletUtils.getRequest().getHeader("APPToken");
 		if (StringUtils.isNotEmpty(headValue)){
-			Claims claims=jwtUtils.getClaimByToken(headValue);
-			if(claims!=null){
+			Claims claims = jwtUtils.getClaimByToken(headValue);
+			if(claims != null){
 				String userId = claims.getSubject().toString();
 				return userId;
 			}
-			else{
-				return null;
-			}
+		}
+		// 如果APPToken不存在或解析失败,从x-skip-auth获取[目前是飞书看课入口登录的用户id]
+		String skipAuth = ServletUtils.getRequest().getHeader("x-skip-auth");
+		if (StringUtils.isNotEmpty(skipAuth)) {
+			return skipAuth;
 		}
 		return null;
 	}

+ 20 - 10
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -4,7 +4,6 @@ package com.fs.app.controller.course;
 
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.json.JSONUtil;
-import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fs.app.annotation.UserOperationLog;
 import com.fs.app.controller.AppBaseController;
 import com.fs.common.annotation.Log;
@@ -13,15 +12,12 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.app.annotation.Login;
-import com.fs.common.core.domain.model.LoginUser;
-import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.CloudHostUtils;
-import com.fs.common.utils.SecurityUtils;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.domain.FsUserCourseVideo;
-import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.param.*;
@@ -32,6 +28,8 @@ import com.fs.course.param.newfs.FsUserCourseVideoUParam;
 import com.fs.course.service.*;
 import com.fs.course.vo.FsUserCourseVideoH5VO;
 import com.fs.course.vo.newfs.FsUserCourseVideoLinkDetailsVO;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.enums.FsUserOperationEnum;
 import com.fs.im.dto.OpenImResponseDTO;
@@ -82,7 +80,8 @@ public class CourseFsUserController extends AppBaseController {
     @Autowired
     private RedisCache redisCache;
 
-
+    @Autowired
+    private FeiShuService feiShuService;
 
     @Login
     @ApiOperation("判断是否添加客服(是否关联销售)")
@@ -132,7 +131,7 @@ public class CourseFsUserController extends AppBaseController {
             if (fsCourseWatchLog == null) {
                 return R.error("");
             }
-            
+
             // 如果已经完课,剩余时间为0
             if (fsCourseWatchLog != null && fsCourseWatchLog.getLogType() != null && fsCourseWatchLog.getLogType() == 2) {
                 remainTime = 0;
@@ -146,15 +145,15 @@ public class CourseFsUserController extends AppBaseController {
                 } else if (fsCourseWatchLog != null && fsCourseWatchLog.getDuration() != null) {
                     watchedDuration = fsCourseWatchLog.getDuration();
                 }
-                
+
                 // 获取视频总时长(参照scheduleUpdateDurationToDatabase中的getFsUserVideoDuration方法)
                 Long videoDuration = getFsUserVideoDuration(param.getVideoId().longValue());
-                
+
                 if (videoDuration != null && videoDuration > 0 && config.getAnswerRate() != null) {
                     // 参照scheduleUpdateDurationToDatabase中的完课逻辑:percentage >= config.getAnswerRate()
                     // 计算需要观看的时长(秒)
                     long requiredDuration = videoDuration * config.getAnswerRate() / 100;
-                    
+
                     // 计算剩余时间(秒)
                     long remainTimeSeconds = requiredDuration - watchedDuration;
                     remainTime = (int) Math.max(0, remainTimeSeconds);
@@ -273,4 +272,15 @@ public class CourseFsUserController extends AppBaseController {
         return courseLinkService.getLinkInfo(param.getLogId());
     }
 
+    @ApiOperation("获取飞书课程链接")
+    @PostMapping("/getFeiShuCourseNewLink")
+    public R getFeiShuCourseNewLink(@RequestBody FeishuLinkParam param) {
+        if (StringUtils.isBlank(param.getLink())){
+            return R.error("飞书课程短链不能为空");
+        }
+        String feishuCourseNewLink = feiShuService.getFeishuCourseNewLink(param);
+        return R.ok().put("data", feishuCourseNewLink);
+    }
+
+
 }

+ 22 - 3
fs-user-app/src/main/java/com/fs/app/controller/course/CourseQwController.java

@@ -11,6 +11,7 @@ import com.fs.app.annotation.UserOperationLog;
 import com.fs.app.controller.AppBaseController;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.core.utils.OrderCodeUtils;
@@ -134,7 +135,14 @@ public class CourseQwController extends AppBaseController {
     @UserOperationLog(operationType = FsUserOperationEnum.STUDY)
     public R getCourseVideoDetails(FsUserCourseVideoFinishUParam param)
     {
-        param.setUserId(Long.parseLong(getUserId()));
+        logger.info("h5课程详情加问答入参:param:{}",param);
+        try {
+            param.setUserId(Long.parseLong(getUserId()));
+        }catch (NumberFormatException e){
+            if (param.getUserId()==null){
+                return R.error("用户未登录");
+            }
+        }
         String json = configService.selectConfigByKey("course.config");
         CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
         FsUserCourseVideoH5DVO course = courseService.selectFsUserCourseVideoH5DVOByVideoId(param.getVideoId());
@@ -356,8 +364,19 @@ public class CourseQwController extends AppBaseController {
 
     @Login
     @PostMapping("/getErrMsg")
-    public void getErrMsg(@RequestParam("msg") String msg) {
-        Long userId = Long.parseLong(getUserId());
+    public void getErrMsg(@RequestParam("msg") String msg,
+                          @RequestHeader Map<String, String> headers) {
+        Long userId;
+        try {
+            userId =Long.parseLong(getUserId());
+        }catch (NumberFormatException e){
+            //如果登录状态异常就从请求头获取userId
+            String skipAuth = headers.get("x-skip-auth");
+            if (skipAuth == null){
+                throw new CustomException("用户未登录");
+            }
+            userId = Long.parseLong(skipAuth);
+        }
         logger.error("zyp \n【看课中途报错】:{}","userId:"+userId+msg);
     }
 

+ 113 - 0
fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java

@@ -0,0 +1,113 @@
+package com.fs.app.controller.course;
+
+import cn.hutool.core.util.IdUtil;
+import com.fs.app.param.FeiShuGetInternetTrafficParam;
+import com.fs.app.param.FeiShuUpdateWatchDurationParam;
+import com.fs.feishu.util.SecureTokenUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.vo.FsUserCourseVideoH5VO;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@Api("会员-飞书看课接口")
+@RestController
+@RequestMapping("/app/course/feishu")
+public class FeiShuCourseController {
+
+    @Autowired
+    private IFsUserCourseService courseService;
+    @Autowired
+    private IFsUserCourseVideoService courseVideoService;
+
+    @ApiOperation("飞书获取唯一标识")
+    @GetMapping("/getUniqueIdentifier")
+    public R getUniqueIdentifier(@RequestParam("token") String token) {
+        return R.ok().put("data", IdUtil.getSnowflake(0, 0).nextIdStr());
+    }
+
+    @ApiOperation("飞书课程简介")
+    @GetMapping("/getH5CourseByVideoId")
+    public R getCourseByVideoId(@RequestParam("token") String token) {
+        Map<String, Object> params = accessCourse(token);
+        Long videoId = (Long) params.get("videoId");
+        FsUserCourseVideoH5VO course = courseService.selectFsUserCourseVideoH5VOByVideoId(videoId);
+        return R.ok().put("data", course);
+    }
+
+    @ApiOperation("飞书课程详情")
+    @GetMapping("/videoDetails")
+    public R getCourseVideoDetails(@RequestParam("token") String token,
+                                   @RequestHeader(value = "Identifier", required = false) Long identifier) {
+        Map<String, Object> params = accessCourse(token);
+        Long companyUserId = (Long) params.get("companyUserId");
+        Long videoId = (Long) params.get("videoId");
+
+        if (StringUtils.isNull(identifier)) {
+            return R.error("非法操作!");
+        }
+        return courseVideoService.getFeiShuLinkCourseVideoDetails(companyUserId, videoId, identifier);
+    }
+
+    @ApiOperation("更新看课时长")
+    @PostMapping("/updateWatchDuration")
+    public void updateWatchDuration(@Validated @RequestBody FeiShuUpdateWatchDurationParam param,
+                                    @RequestHeader(value = "Identifier", required = false) Long identifier) {
+        Map<String, Object> params = accessCourse(param.getToken());
+        Long companyUserId = (Long) params.get("companyUserId");
+        Long videoId = (Long) params.get("videoId");
+        if (StringUtils.isNull(identifier)) {
+            return;
+        }
+
+        courseVideoService.feiShuUpdateWatchDurationWx(companyUserId, videoId, identifier, param.getDuration());
+    }
+
+    @ApiOperation("获取缓冲流量")
+    @PostMapping("/getInternetTraffic")
+    public void getInternetTraffic(@Validated @RequestBody FeiShuGetInternetTrafficParam param,
+                                   @RequestHeader(value = "Identifier", required = false) Long identifier) {
+        Map<String, Object> params = accessCourse(param.getToken());
+        Long companyUserId = (Long) params.get("companyUserId");
+        Long videoId = (Long) params.get("videoId");
+        if (StringUtils.isNull(identifier)) {
+            return;
+        }
+        courseVideoService.getFeiShuInternetTraffic(identifier, companyUserId, videoId, param.getUuId(), param.getBufferRate());
+    }
+
+    /**
+     * 安全获取课程信息
+     */
+    private Map<String, Object> accessCourse(String token) {
+        try {
+            // 解析令牌
+            Map<String, Object> params = SecureTokenUtil.parseToken(token);
+            Long companyUserId = (Long) params.get("companyUserId");
+            Long videoId = (Long) params.get("videoId");
+            Long timestamp = (Long) params.get("timestamp");
+
+            // 验证令牌时效性
+            if (!SecureTokenUtil.isTokenValid(timestamp)) {
+                throw new CustomException("链接已过期,请重新获取");
+            }
+
+            // 验证参数有效性
+            if (companyUserId == null || videoId == null) {
+                throw new CustomException("无效的访问参数");
+            }
+
+            return params;
+        } catch (Exception e) {
+            throw new CustomException("课程访问失败: " + e.getMessage(), e);
+        }
+    }
+}

+ 7 - 0
fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -26,6 +26,7 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     @Autowired
     RedisCache redisCache;
     public static final String USER_KEY = "userId";
+    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
@@ -40,6 +41,12 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
+        // 请求头存在跳过认证标识,直接放行
+        String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
+        if (StringUtils.isNotEmpty(skipAuth)) {
+            return true;
+        }
+
         //获取用户凭证
         String token = request.getHeader(jwtUtils.getHeader());
         if(StringUtils.isBlank(token)){

+ 19 - 0
fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java

@@ -0,0 +1,19 @@
+package com.fs.app.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+@Data
+public class FeiShuGetInternetTrafficParam {
+
+    @NotBlank(message = "token不能为空")
+    private String token;
+
+    private String uuId;
+
+    @NotNull(message = "bufferRate不能为空")
+    private BigDecimal bufferRate;
+}

+ 16 - 0
fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java

@@ -0,0 +1,16 @@
+package com.fs.app.param;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+@Data
+public class FeiShuUpdateWatchDurationParam {
+
+    @NotBlank(message = "token不能为空")
+    private String token;
+
+    @NotNull(message = "duration不能为空")
+    private Long duration;
+}

+ 16 - 7
fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

@@ -51,6 +51,7 @@ public class UserOperationLogAspect {
 
     private final ObjectMapper objectMapper = new ObjectMapper();
     private static final ThreadLocal<FsUserOperationLog> LOG_HOLDER = new ThreadLocal<>();
+    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Pointcut("@annotation(com.fs.app.annotation.UserOperationLog)")
     public void logPointcut() {}
@@ -115,15 +116,23 @@ public class UserOperationLogAspect {
             operationLog.setOperationType(annotation.operationType().getLabel());
 
             //用户
-            Long userId =null;
-            try {
-                String appToken = ServletUtils.getRequest().getHeader("APPToken");
-                if (StringUtils.isNotBlank(appToken)){
-                    userId = Long.valueOf(jwtUtils.getClaimByToken(appToken).getSubject().toString());
+            Long userId = null;
+            // 优先从 X-Skip-Auth 请求头取 userId(跳过token校验场景)
+            String skipAuth = ServletUtils.getRequest().getHeader(SKIP_AUTH_HEADER);
+            if (StringUtils.isNotEmpty(skipAuth)) {
+                try {
+                    userId = Long.valueOf(skipAuth);
+                } catch (NumberFormatException ne) {
+                    log.info("X-Skip-Auth 请求头不是有效的userId: {}", skipAuth);
+                }
+            } else {
+                try {
+                    userId = Long.valueOf(jwtUtils.getClaimByToken(ServletUtils.getRequest().getHeader("APPToken")).getSubject().toString());
+                } catch (Exception ie) {
+                    log.info("获取用户id失败");
                 }
-            } catch (Exception ie){
-                log.info("获取用户id失败");
             }
+
             if (userId == null) {
                 LOG_HOLDER.set(operationLog);
                 return;