瀏覽代碼

1、飞书看课

yys 2 天之前
父節點
當前提交
77d5e6823d
共有 43 個文件被更改,包括 2138 次插入15 次删除
  1. 32 0
      fs-admin/src/main/java/com/fs/task/AuthLinkTokenCleanTask.java
  2. 31 0
      fs-admin/src/main/java/com/fs/task/FeiShuFileCleanTask.java
  3. 49 0
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  4. 7 0
      fs-company-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  5. 24 0
      fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  6. 6 0
      fs-service/pom.xml
  7. 3 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  8. 15 0
      fs-service/src/main/java/com/fs/course/constant/CourseConstant.java
  9. 94 0
      fs-service/src/main/java/com/fs/course/domain/FeiShuCourseWatchLog.java
  10. 1 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseTrafficLog.java
  11. 55 0
      fs-service/src/main/java/com/fs/course/domain/WatchLogBak.java
  12. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java
  13. 19 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  14. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodDaysMapper.java
  15. 4 1
      fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java
  16. 3 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoFinishUParam.java
  17. 19 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  18. 17 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  19. 303 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  20. 258 1
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  21. 13 0
      fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java
  22. 26 0
      fs-service/src/main/java/com/fs/feishu/domain/FsAuthLinkToken.java
  23. 21 0
      fs-service/src/main/java/com/fs/feishu/mapper/FsAuthLinkTokenMapper.java
  24. 16 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java
  25. 38 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java
  26. 435 0
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  27. 34 0
      fs-service/src/main/java/com/fs/feishu/service/IFsAuthLinkTokenService.java
  28. 107 0
      fs-service/src/main/java/com/fs/feishu/service/impl/FsAuthLinkTokenServiceImpl.java
  29. 103 0
      fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java
  30. 2 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  31. 4 0
      fs-service/src/main/resources/application-config-dev.yml
  32. 4 0
      fs-service/src/main/resources/application-druid-zkzh-test.yml
  33. 4 0
      fs-service/src/main/resources/application-druid-zkzh.yml
  34. 28 0
      fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml
  35. 73 1
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  36. 39 0
      fs-service/src/main/resources/mapper/feishu/FsAuthLinkTokenMapper.xml
  37. 4 0
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  38. 21 4
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java
  39. 129 0
      fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java
  40. 20 1
      fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  41. 19 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java
  42. 16 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java
  43. 15 7
      fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

+ 32 - 0
fs-admin/src/main/java/com/fs/task/AuthLinkTokenCleanTask.java

@@ -0,0 +1,32 @@
+package com.fs.task;
+
+import com.fs.feishu.service.IFsAuthLinkTokenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 飞书授权链接token清理定时任务
+ * 每天凌晨3点清理 fs_auth_link_token 表中已过期的记录
+ */
+@Component("authLinkTokenCleanTask")
+@Slf4j
+public class AuthLinkTokenCleanTask {
+
+    @Autowired
+    private IFsAuthLinkTokenService authLinkTokenService;
+
+    /**
+     * 每天凌晨3点执行,清理已过期的token记录
+     */
+//    @Scheduled(cron = "0 0 3 * * ?")
+    public void cleanExpiredTokens() {
+        try {
+            log.info("授权token清理 - 定时任务开始");
+            int count = authLinkTokenService.cleanExpiredTokens();
+            log.info("授权token清理 - 定时任务完成 | 删除过期记录={}", count);
+        } catch (Exception e) {
+            log.error("授权token清理 - 定时任务执行失败", e);
+        }
+    }
+}

+ 31 - 0
fs-admin/src/main/java/com/fs/task/FeiShuFileCleanTask.java

@@ -0,0 +1,31 @@
+package com.fs.task;
+
+import com.fs.feishu.service.FeiShuService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 飞书云文档清理定时任务
+ * 每天凌晨2点删除今天0点之前创建的飞书云文档
+ */
+@Component("feiShuFileCleanTask")
+@Slf4j
+public class FeiShuFileCleanTask {
+
+    @Autowired
+    private FeiShuService feiShuService;
+
+    /**
+     * 每天凌晨2点执行,清理今天之前创建的飞书云文档
+     */
+//    @Scheduled(cron = "0 0 2 * * ?")
+    public void deleteFilesBeforeToday() {
+        try {
+            log.info("飞书云文档清理 - 定时任务开始");
+            feiShuService.deleteFilesBeforeToday();
+        } catch (Exception e) {
+            log.error("飞书云文档清理 - 定时任务执行失败", e);
+        }
+    }
+}

+ 49 - 0
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -31,6 +31,8 @@ import com.fs.course.utils.WechatErrorUtil;
 import com.fs.course.vo.FsCourseWatchLogListVO;
 import com.fs.course.vo.FsUserCourseParticipationRecordVO;
 import com.fs.course.vo.newfs.*;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.im.domain.FsImMsgSendLog;
 import com.fs.im.dto.OpenImResponseDTO;
 import com.fs.im.service.IFsImMsgSendDetailService;
@@ -38,6 +40,7 @@ import com.fs.im.service.IFsImMsgSendLogService;
 import com.fs.im.service.OpenIMService;
 import com.fs.im.vo.FsImMsgSendLogVO;
 import com.fs.live.service.ILiveService;
+import com.fs.system.service.ISysConfigService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
@@ -535,4 +538,50 @@ public class FsUserCourseVideoController extends AppBaseController {
             return handleWechatError(e);
         }
     }
+
+    @Autowired
+    private FeiShuService feiShuService;
+    @Autowired
+    private ISysConfigService configService;
+
+    @Login
+    @ApiOperation("获取飞书看课链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public R getFeiShuCourseLink(@RequestParam Long companyUserId,
+                                 @RequestParam Long videoId,
+                                 @RequestParam Long periodId,
+                                 @RequestParam(required = false) Long companyId,
+                                 @RequestParam(required = false) Long courseId,
+                                 @RequestParam(required = false) Long projectId,
+                                 @RequestParam(required = false) Long id) {
+        // 读取配置判断是否启用飞书新跳转
+        Boolean enableFeishuNewLink = null;
+        try {
+            String json = configService.selectConfigByKey("course.config");
+            if (StringUtils.isNotBlank(json)) {
+                com.fs.course.config.CourseConfig config = cn.hutool.json.JSONUtil.toBean(json, com.fs.course.config.CourseConfig.class);
+                enableFeishuNewLink = config.getEnableFeishuNewLink();
+            }
+        } catch (Exception e) {
+            log.warn("读取飞书新跳转配置失败,使用老方法", e);
+        }
+
+        if (Boolean.TRUE.equals(enableFeishuNewLink)) {
+            FeishuLinkParam param = new FeishuLinkParam();
+            param.setCompanyId(companyId);
+            param.setCompanyUserId(companyUserId);
+            param.setCourseId(courseId);
+            param.setVideoId(videoId);
+            param.setPeriodId(periodId);
+            param.setProjectId(projectId);
+            param.setId(id);
+            String link = feiShuService.getFeishuRegisterLink(param);
+            log.info("链接{}", link);
+            return R.ok().put("data", link);
+        }
+        // 默认使用老方法
+        String oldLink = feiShuService.getFeishuCourseLink(companyUserId, videoId, periodId);
+        log.info("老链接{}", oldLink);
+        return R.ok().put("data", oldLink);
+    }
 }

+ 7 - 0
fs-company-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)){

+ 24 - 0
fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java

@@ -15,6 +15,7 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import java.util.Calendar;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @Component
@@ -191,6 +192,29 @@ public class CourseWatchLogScheduler {
     }
 
 
+    /**
+     * 检查飞书看课状态
+     * 每分钟执行一次
+     */
+    @Scheduled(fixedRate = 60000)
+    public void checkFeiShuWatchStatus() {
+        if (!redisCache.setIfAbsent("checkFeiShuWatchStatus", "checkFeiShuWatchStatus", 1, TimeUnit.MINUTES)) {
+            log.warn("检查飞书看课中任务执行 - 上一个任务尚未完成,跳过此次执行");
+            return;
+        }
+        try {
+            log.info("检查飞书看课中任务执行>>>>>>>>>>>>");
+            courseWatchLogService.scheduleFeiShuBatchUpdateToDatabase();
+            courseWatchLogService.checkFeiShuWatchStatus();
+            log.info("检查飞书看课中任务执行完成>>>>>>>>>>>>");
+        }catch (Exception e) {
+            log.error("检查飞书看课中任务执行完成 - 定时任务执行失败", e);
+        } finally {
+            redisCache.deleteObject("checkFeiShuWatchStatus");
+        }
+
+    }
+
 
 
 

+ 6 - 0
fs-service/pom.xml

@@ -303,6 +303,12 @@
             <version>1.3.1</version>
         </dependency>
 
+        <!-- 飞书SDK -->
+        <dependency>
+            <groupId>com.larksuite.oapi</groupId>
+            <artifactId>oapi-sdk</artifactId>
+            <version>2.5.3</version>
+        </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 feishuLinkDomainName;//飞书看课域名
+    private String feishuNewLinkDomainName;//新飞书看课域名
+    private Boolean enableFeishuNewLink;//是否启用飞书新跳转
     private String authDomainName;//网页授权域名
     private String mpAppId;//看课公众号APPID
     private String registerDomainName;//注册域名

+ 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, Long periodId) {
+        return String.format(FEI_SHU_WATCH_HEART + "%s:%s:%s:%s", userId, videoId, companyUserId, periodId);
+    }
+
+    public static String getFeiShuWatchDurationKey(Long userId, Long videoId, Long companyUserId, Long periodId) {
+        return String.format(FEI_SHU_WATCH_DURATION + "%s:%s:%s:%s", userId, videoId, companyUserId, periodId);
+    }
+}

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

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

@@ -67,6 +67,7 @@ public class FsCourseTrafficLog extends BaseEntity
     private Long periodId;
 
 
+    private Integer typeFlag;
 
 //    @JsonFormat(pattern = "yyyy-MM-dd")
 //    private Date time;

+ 55 - 0
fs-service/src/main/java/com/fs/course/domain/WatchLogBak.java

@@ -0,0 +1,55 @@
+package com.fs.course.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.Date;
+
+@Data
+public class WatchLogBak {
+
+    @TableId(type = IdType.AUTO)
+    private Long backupId;
+
+    // 原始数据字段
+    private Long originalLogId;
+    private Long userId;
+    private Long videoId;
+    private Integer logType;
+    private Date createTime;
+    private Date updateTime;
+    private Long qwExternalContactId;
+    private Long duration;
+    private Long qwUserId;
+    private Long companyUserId;
+    private Long companyId;
+    private Long courseId;
+    private Integer sendType;
+    private Integer rewardType;
+    private LocalDateTime lastHeartbeatTime;
+    private String sopId;
+    private Date finishTime;
+    private Integer sendFinishMsg;
+    private Date campPeriodTime;
+    private Long day;
+    private Long project;
+    private String createBy;
+    private String updateBy;
+    private Long periodId;
+    private Integer projectId;
+    private Long imMsgSendDetailId;
+    private Integer watchType;
+
+    // 删除相关字段
+    private Date deleteTime;
+    private Long  deleteBy;
+    private Integer deleteMethod = 1; // 1手动删除 2自动清理
+    private String originalData; // JSON格式的完整原始数据
+
+    // 备份相关字段
+    private String backupOperator;
+    private String backupBatchNo;
+    private String remark;
+}

+ 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} and period_id = #{periodId}")
+    FeiShuCourseWatchLog getWatchLogByFsUser(@Param("videoId") Long videoId, @Param("identifier") Long identifier, @Param("companyUserId") Long companyUserId, @Param("periodId") Long periodId);
+
+    /**
+     * 修改看课记录
+     */
+    void updateByVideoIdAndUserIdAndCompanyUserIdAndPeriodId(FeiShuCourseWatchLog feishuCourseWatchLog);
+}

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

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.param.CompanyStatisticsParam;
 import com.fs.company.vo.CompanyRedPacketStatisticsVO;
 import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.WatchLogBak;
 import com.fs.course.dto.WatchLogDTO;
 import com.fs.course.param.*;
 import com.fs.course.vo.*;
@@ -69,6 +70,16 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
      */
     public int insertFsCourseWatchLog(FsCourseWatchLog fsCourseWatchLog);
 
+    /**
+     * 新增短链课程看课记录备份
+     *
+     * @param watchLogBak 短链课程看课记录备份
+     * @return 结果
+     */
+    int insertBackup(WatchLogBak watchLogBak);
+
+
+
     public int insertOrUpdateFsCourseWatchLog(FsCourseWatchLog fsCourseWatchLog);
 
     /**
@@ -811,4 +822,12 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
     List<AppSalesCourseStatisticsVO> selectAppSalesCourseStatisticsVO(FsCourseWatchLogStatisticsListParam param);
 
     List<CompanyRedPacketStatisticsVO> selectCourseFinishCount(CompanyStatisticsParam param);
+
+    /**
+     * 查询用户当天的看课记录
+     * @param userId
+     * @param projectId
+     */
+    @Select("select * from fs_course_watch_log where user_id = #{userId} and project = #{projectId} and create_time >= #{startTime}")
+    FsCourseWatchLog selectByUserIdAndProjectId(@Param("userId") Long userId, @Param("projectId") Long projectId, @Param("startTime") Date startTime);
 }

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

@@ -143,4 +143,10 @@ public interface FsUserCoursePeriodDaysMapper extends BaseMapper<FsUserCoursePer
      * @return
      */
     FsUserCoursePeriodDays selectFsUserCoursePeriodDaysByPeriodDays(@Param("courseId")Long courseId, @Param("periodId")Long periodId, @Param("videoId")Long videoId);
+
+    /**
+     * 查询营期视频
+     */
+    @Select("select * from fs_user_course_period_days where period_id = #{periodId} and video_id = #{videoId}")
+    FsUserCoursePeriodDays selectByPeriodAndVideoId(@Param("periodId") Long periodId, @Param("videoId") Long videoId);
 }

+ 4 - 1
fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java

@@ -38,5 +38,8 @@ public class FsCourseSendRewardUParam implements Serializable
     private String appId; //前端传来的小程序的appid
     private Integer rewardType; //奖励类型 1红包 2积分 3随机转盘 4保底转盘
     private String code;
-
+    /**
+     * 是否是飞书
+     */
+    private Boolean isFeiShu=false;
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoFinishUParam.java

@@ -29,4 +29,7 @@ public class FsUserCourseVideoFinishUParam implements Serializable {
      * 是否公开课
      */
     private Integer isPublic;
+
+    private String appId; // 小程序AppId
+    private Integer typeFlag; //0 小程序 1 app 2飞书看课
 }

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

@@ -1,6 +1,7 @@
 package com.fs.course.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.param.*;
 import com.fs.course.vo.*;
@@ -187,4 +188,22 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
 
     // 在 IFsCourseWatchLogService.java 中添加
     List<AppSalesCourseStatisticsVO> selectAppSalesCourseStatisticsVO(FsCourseWatchLogStatisticsListParam param);
+
+
+    /**
+     * 清除用户当天的看课记录
+     * @param userId
+     * @param projectId
+     */
+    R clearUserWatchLog(Long userId, Long projectId);
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    void scheduleFeiShuBatchUpdateToDatabase();
+
+    /**
+     * 检查飞书看课状态
+     */
+    void checkFeiShuWatchStatus();
 }

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

@@ -21,6 +21,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;
 
@@ -308,4 +309,20 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
 
 
     List<FsUserCourseVideoDTO> selectCourseVideoCountByCourseIds(List<Long> courseIds);
+
+
+    /**
+     * 获取飞书课程详情
+     */
+    R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId, Long periodId, Long identifier);
+
+    /**
+     * 更新飞书看课时长
+     */
+    void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long periodId, Long identifier, Long duration);
+
+    /**
+     * 飞书看课流量统计
+     */
+    void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, Long periodId, String uuid, BigDecimal bufferRate);
 }

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

@@ -9,6 +9,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.base.BusinessException;
 import com.fs.common.utils.DateUtils;
@@ -22,6 +23,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.*;
@@ -64,6 +66,9 @@ import com.github.pagehelper.PageHelper;
 import com.google.gson.Gson;
 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;
@@ -2689,5 +2694,303 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 && startCal.get(Calendar.DAY_OF_YEAR) == endCal.get(Calendar.DAY_OF_YEAR);
     }
 
+    public static Date removeTime(Date date) {
+        if (date == null) {
+            return null;
+        }
+
+        try {
+            // 双重保障:先格式化成字符串,再解析回来
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            String dateStr = sdf.format(date);
+            Date result = sdf.parse(dateStr);
+
+            // 再用Calendar确保清除
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(result);
+            cal.set(Calendar.HOUR_OF_DAY, 0);
+            cal.set(Calendar.MINUTE, 0);
+            cal.set(Calendar.SECOND, 0);
+            cal.set(Calendar.MILLISECOND, 0);
+
+            return cal.getTime();
+        } catch (Exception e) {
+            // 备用方案
+            return new java.sql.Date(date.getTime());
+        }
+    }
+
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R clearUserWatchLog(Long userId, Long projectId) {
+        Date startTime = removeTime(new Date());
+        FsCourseWatchLog fsCourseWatchLog = fsCourseWatchLogMapper.selectByUserIdAndProjectId(userId, projectId, startTime);
+        if (fsCourseWatchLog != null) {
+            int i = fsCourseWatchLogMapper.deleteById(fsCourseWatchLog.getLogId());
+            if (i < 0) {
+                return R.error("清除失败");
+            }else {
+                //备份看课记录
+                WatchLogBak watchLogBak = convertToBackupWithDeleteInfo(fsCourseWatchLog, userId, 1);
+                int j = fsCourseWatchLogMapper.insertBackup(watchLogBak);
+                if (j < 0) {
+                    return R.error("备份失败");
+                }
+            }
+        }else {
+            return R.error("没有需要清除的记录");
+        }
+        return R.ok("清除成功");
+    }
+
+    public WatchLogBak convertToBackupWithDeleteInfo(FsCourseWatchLog original, Long deleteBy,
+                                                     Integer deleteMethod) {
+        WatchLogBak backup = convertToBackup(original);
+        if (backup != null) {
+            backup.setDeleteBy(deleteBy);
+            if (deleteMethod != null) {
+                backup.setDeleteMethod(deleteMethod);
+            }
+        }
+        return backup;
+    }
+
+    public WatchLogBak convertToBackup(FsCourseWatchLog original) {
+        if (original == null) {
+            return null;
+        }
+
+        WatchLogBak backup = new WatchLogBak();
+
+        // 复制原始数据字段
+        backup.setOriginalLogId(original.getLogId());
+        backup.setUserId(original.getUserId());
+        backup.setVideoId(original.getVideoId());
+        backup.setLogType(original.getLogType());
+        backup.setCreateTime(original.getCreateTime());
+        backup.setUpdateTime(original.getUpdateTime());
+        backup.setQwExternalContactId(original.getQwExternalContactId());
+        backup.setDuration(original.getDuration());
+        backup.setQwUserId(original.getQwUserId());
+        backup.setCompanyUserId(original.getCompanyUserId());
+        backup.setCompanyId(original.getCompanyId());
+        backup.setCourseId(original.getCourseId());
+        backup.setSendType(original.getSendType());
+        backup.setRewardType(original.getRewardType());
+        backup.setLastHeartbeatTime(original.getLastHeartbeatTime());
+        backup.setSopId(original.getSopId());
+        backup.setFinishTime(original.getFinishTime());
+        backup.setSendFinishMsg(original.getSendFinishMsg());
+        backup.setCampPeriodTime(original.getCampPeriodTime());
+        backup.setDay(original.getDay());
+        backup.setProject(original.getProject());
+        backup.setCreateBy(original.getCreateBy());
+        backup.setUpdateBy(original.getUpdateBy());
+        backup.setPeriodId(original.getPeriodId());
+        backup.setImMsgSendDetailId(original.getImMsgSendDetailId());
+        backup.setWatchType(original.getWatchType());
+
+        // 设置删除信息(调用时传入)
+        backup.setDeleteTime(new Date());
+
+        // 保存完整的原始数据为JSON
+        backup.setOriginalData(convertToJson(original));
+
+        return backup;
+    }
+
+    private String convertToJson(FsCourseWatchLog original) {
+        try {
+            // 使用 FastJSON 进行序列化,它对复杂对象有更好的处理
+            String jsonString = JSON.toJSONString(original);
+
+            // 验证 JSON 格式是否有效
+            if (isValidJson(jsonString)) {
+                return jsonString;
+            }
+        } catch (Exception e) {
+            log.error("转换JSON失败: {}, 原始对象ID: {}", e.getMessage(), original.getLogId());
+        }
+        return null;
+    }
+
+    private boolean isValidJson(String jsonString) {
+        try {
+            JSON.parse(jsonString);
+            return true;
+        } catch (Exception e) {
+            log.error("无效的JSON格式: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    @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;
+            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;
+            }
+            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.setPeriodId(periodId);
+            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, periodId);
+                    // 完课删除心跳记录
+                    redisCache.deleteObject(heartbeatKey);
+                    // 完课删除看课时长记录
+                    redisCache.deleteObject(key);
+                }
+            }
+
+            //集合中增加
+            logs.add(watchLog);
+        }
+
+        // 批量更新飞书看课记录
+        if (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 (CollectionUtils.isNotEmpty(logs)) {
+            batchUpdateFeiShuCourseWatchLog(logs);
+        }
+    }
+
+    @Autowired
+    private SqlSessionFactory sqlSessionFactory;
+
+    /**
+     * 批量更新飞书看课记录
+     */
+    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);
+        }
+    }
 
 }

+ 258 - 1
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -41,6 +41,7 @@ import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RandomRedPacketConfig;
 import com.fs.course.config.RandomRedPacketRule;
 import com.fs.course.config.RedPacketConfig;
+import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePackageDTO;
 import com.fs.course.dto.FsUserCourseVideoDTO;
@@ -146,6 +147,9 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     private OpenIMService openIMService;
     @Autowired
     private FsCouponMapper fsCouponMapper;
+
+    @Autowired
+    private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
     @Autowired
     private FsUserCouponMapper fsUserCouponMapper;
     @Autowired
@@ -1759,7 +1763,13 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         if (log.getLogType() != 2) {
             return R.error("未完课");
         }
-
+        if(!param.getIsFeiShu()){
+            FsCourseAnswerLogs rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
+            if (rightLog == null) {
+                logger.error("未答题:{}", param.getUserId());
+                return R.error("未答题");
+            }
+        }
         FsCourseAnswerLogs rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
         if (rightLog == null) {
             logger.error("未答题:{}", param.getUserId());
@@ -2924,6 +2934,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         return map;
     }
 
+
+
     // 其他辅助方法保持不变
     private FsUserCoursePeriodDays getPeriodDaysInfo(FsUserCourseAddCompanyUserParam param) {
         FsUserCoursePeriodDays query = new FsUserCoursePeriodDays();
@@ -5826,5 +5838,250 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                 return "微信支付服务异常,请稍后重试";
         }
     }
+
+
+    /**
+     * 获取飞书课程详情
+     */
+    @Override
+    public R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId, Long periodId, Long identifier) {
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        //判断该销售是否存在
+        if (companyUser == null) {
+            return R.error(405, "当前销售不存在");
+        }
+
+        // 获取视频详情
+        FsUserCourseVideoDetailsVO courseVideoDetails = getFeiShuVideoDetails(videoId);
+
+        // 查看为最新课程
+        FsUserCourseAddCompanyUserParam param = new FsUserCourseAddCompanyUserParam();
+        param.setPeriodId(periodId);
+        param.setCourseId(courseVideoDetails.getCourseId());
+        param.setVideoId(videoId);
+        param.setCompanyUserId(companyUserId);
+        Map<String, Object> userCoursePeriodValid = isUserCoursePeriodValid(param);
+        if (!((boolean)userCoursePeriodValid.get("isWithinRangeSafe"))) {
+            return R.error(BizResponseEnum.WATCH_LATEST_COURSE.getCode(), BizResponseEnum.WATCH_LATEST_COURSE.getMsg());
+        }
+        // 获取课程所属项目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
+        FsUserCoursePeriod fsUserCoursePeriod = fsUserCoursePeriodMapper.selectFsUserCoursePeriodById(periodId);
+        if (fsUserCoursePeriod != null) {
+            config.setCourseLogo(fsUserCoursePeriod.getCourseLogo());
+        }
+
+        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, periodId);
+        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);
+            watchLog.setPeriodId(periodId);
+            feishuCourseWatchLogMapper.insert(watchLog);
+
+            String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId, periodId);
+            redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), 5, TimeUnit.MINUTES);
+        }
+
+        // 从Redis中获取用户目前的观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId, periodId);
+        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);
+
+        //判断营期的课程状态是否是进行中
+        FsUserCoursePeriodDays days = fsUserCoursePeriodDaysMapper.selectByPeriodAndVideoId(periodId, videoId);
+
+        // 查询销售设置的看课时间
+        LocalDateTime companyUserStartDateTime = null;
+        LocalDateTime companyUserEndDateTime = null;
+
+        List<CompanyUserTimeQueryParam> queryList = new ArrayList<>();
+        CompanyUserTimeQueryParam query = new CompanyUserTimeQueryParam();
+        query.setPeriodId(periodId);
+        query.setCourseId(courseVideoDetails.getCourseId());
+        query.setVideoId(videoId);
+        query.setCompanyUserId(companyUserId);
+        queryList.add(query);
+        List<FsUserCourseCompanyUserTime> fsUserCourseCompanyUserTimes = companyUserTimeMapper.batchSelectByParams(queryList);
+
+        if (CollectionUtils.isNotEmpty(fsUserCourseCompanyUserTimes)) {
+            FsUserCourseCompanyUserTime fsUserCourseCompanyUserTime = fsUserCourseCompanyUserTimes.get(0);
+            Date cuStartDateTime = fsUserCourseCompanyUserTime.getStartDateTime();
+            Date cuEndDateTime = fsUserCourseCompanyUserTime.getEndDateTime();
+
+            if (cuStartDateTime != null) {
+                Instant instant = cuStartDateTime.toInstant();
+                ZoneId zoneId = ZoneId.systemDefault();
+                companyUserStartDateTime = instant.atZone(zoneId).toLocalDateTime();
+            }
+
+            if (cuEndDateTime != null) {
+                Instant instant = cuEndDateTime.toInstant();
+                ZoneId zoneId = ZoneId.systemDefault();
+                companyUserEndDateTime = instant.atZone(zoneId).toLocalDateTime();
+            }
+        }
+        vo.setStartDateTime(companyUserStartDateTime != null ? companyUserStartDateTime : days.getStartDateTime());
+        vo.setEndDateTime(companyUserEndDateTime != null ? companyUserEndDateTime : days.getEndDateTime());
+        vo.setRang(DateUtil.isWithinRangeSafe(LocalDateTime.now(),
+                companyUserStartDateTime != null ? companyUserStartDateTime : days.getStartDateTime(),
+                companyUserEndDateTime != null ? companyUserEndDateTime : days.getEndDateTime())
+                && days.getStatus() == 1);
+
+        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 periodId, Long identifier, Long duration) {
+        // 获取视频总时长
+        Long videoDuration = getAutoLookVideoDuration(videoId);
+        if (duration > videoDuration + 10) {
+            return;
+        }
+
+        // 获取用户观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId, periodId);
+        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, periodId);
+        redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().toString(), 5, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 飞书看课流量统计
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, Long periodId, 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);
+        trafficLog.setPeriodId(periodId);
+
+        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);
+    }
 }
 

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

+ 26 - 0
fs-service/src/main/java/com/fs/feishu/domain/FsAuthLinkToken.java

@@ -0,0 +1,26 @@
+package com.fs.feishu.domain;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 飞书授权链接短token映射
+ * 用于替代明文JSON参数,避免微信管控
+ */
+@Data
+public class FsAuthLinkToken implements Serializable {
+
+    /** 短token(主键) */
+    private String token;
+
+    /** 授权参数JSON */
+    private String paramsJson;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 过期时间 */
+    private Date expireTime;
+}

+ 21 - 0
fs-service/src/main/java/com/fs/feishu/mapper/FsAuthLinkTokenMapper.java

@@ -0,0 +1,21 @@
+package com.fs.feishu.mapper;
+
+import com.fs.feishu.domain.FsAuthLinkToken;
+
+/**
+ * 飞书授权链接token Mapper
+ */
+public interface FsAuthLinkTokenMapper {
+
+    /** 新增token记录 */
+    int insertFsAuthLinkToken(FsAuthLinkToken fsAuthLinkToken);
+
+    /** 根据token查询 */
+    FsAuthLinkToken selectFsAuthLinkTokenByToken(String token);
+
+    /** 根据token删除 */
+    int deleteFsAuthLinkTokenByToken(String token);
+
+    /** 删除已过期的token记录 */
+    int deleteExpiredTokens();
+}

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

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

@@ -0,0 +1,38 @@
+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;
+
+}

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

@@ -0,0 +1,435 @@
+package com.fs.feishu.service;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.feishu.config.FeiShuConfig;
+import com.fs.feishu.model.FeishuFileDTO;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.util.SecureTokenUtil;
+import com.fs.common.exception.CustomException;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.system.service.ISysConfigService;
+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.PermissionPublic;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import javax.annotation.PostConstruct;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.*;
+
+import org.springframework.stereotype.Component;
+
+@Component
+@Slf4j
+public class FeiShuService {
+
+    private final String COURSE_PATH = "/feishu/pages_course/videovip?token=%s";
+
+    @Autowired
+    private FeiShuConfig feishuConfig;
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private FsUserCourseVideoMapper videoMapper;
+    @Autowired
+    private IFsAuthLinkTokenService authLinkTokenService;
+
+    private Client client;
+
+    @PostConstruct
+    public void init() {
+        this.client = Client.newBuilder(feishuConfig.getAppId(), feishuConfig.getAppSecret()).logReqAtDebug(true).build();
+    }
+
+    /**
+     * 复制飞书看课链接
+     */
+    public String getFeishuCourseLink(Long companyUserId, Long videoId, Long periodId) {
+        if (companyUserId == null || videoId == null) {
+            throw new CustomException("用户ID和视频ID不能为空");
+        }
+        
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + videoId);
+        }
+
+        try {
+            // 创建云文档
+            String documentId = createDocument(userCourseVideo.getTitle());
+
+            // 拼接看课url
+            String url = buildCourseLink(companyUserId, videoId, periodId);
+
+            // 创建iframe块
+            createIframeBlock(documentId, url);
+
+            // 更新文档权限
+            changeDocumentPermissions(documentId);
+
+            // 返回飞书看课链接
+            return "https://www.feishu.cn/docx/" + documentId;
+        } catch (Exception e) {
+            throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 复制飞书看课新链接
+     */
+    public String getFeishuCourseNewLink(FeishuLinkParam param) {
+        if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
+            throw new CustomException("参数不能为空");
+        }
+
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + param.getVideoId());
+        }
+        try {
+            // 创建云文档
+            String documentId = createDocument(userCourseVideo.getTitle());
+
+            // 拼接新看课url
+            String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(), param.getCourseId(), param.getVideoId(), param.getPeriodId(), param.getProjectId(),param.getUserId(),param.getId());
+
+            // 创建iframe块
+            createIframeBlock(documentId, url);
+
+            // 更新文档权限
+            changeDocumentPermissions(documentId);
+
+            // 返回飞书看课链接
+            return "https://www.feishu.cn/docx/" + documentId;
+        } catch (Exception e) {
+            throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
+        }
+    }
+
+
+    /**
+     * 复制飞书注册链接
+     */
+    public String getFeishuRegisterLink(FeishuLinkParam param) {
+        if (param == null || param.getCompanyUserId() == null || param.getCompanyId() == null) {
+            throw new CustomException("参数不能为空");
+        }
+
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + param.getVideoId());
+        }
+
+        try {
+            // 创建云文档
+            String documentId = createDocument(userCourseVideo.getTitle() + "-注册");
+
+            // 拼接授权url
+            String authUrl = buildAuthLink(param.getCompanyId(), param.getCompanyUserId(),
+                    param.getCourseId(), param.getVideoId(), param.getPeriodId(), param.getProjectId(),param.getId());
+
+            // 创建关注公众号 text + link block
+            createTextLinkBlock(documentId, authUrl);
+            // 更新文档权限
+            changeDocumentPermissions(documentId);
+
+            // 返回飞书看课链接
+            return "https://www.feishu.cn/docx/" + documentId;
+        } catch (Exception e) {
+            throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
+        }
+    }
+
+
+    /**
+     * 创建云文档
+     */
+    private String createDocument(String title) throws Exception {
+        CreateDocumentReq req = CreateDocumentReq.newBuilder()
+                .createDocumentReqBody(CreateDocumentReqBody.newBuilder().title(title).build())
+                .build();
+        CreateDocumentResp resp = client.docx().v1().document().create(req);
+
+        CreateDocumentRespBody data = resp.getData();
+        return data.getDocument().getDocumentId();
+    }
+
+    /**
+     * 拼接看课url
+     */
+    private String buildCourseLink(Long companyUserId, Long videoId, Long periodId) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 生成安全令牌
+        Long timestamp = System.currentTimeMillis() / 1000;
+        String token = SecureTokenUtil.generateToken(companyUserId, videoId, periodId, timestamp);
+
+        return config.getFeishuLinkDomainName() + String.format(COURSE_PATH, token);
+    }
+
+    /**
+     * 拼接看课url(课程参数JSON方式,适配飞书iframe,双重URL编码)
+     */
+    private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long periodId, Long projectId,Long userId,Long id) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 构造课程参数 JSON
+        Map<String, Object> courseParam = new LinkedHashMap<>();
+        courseParam.put("periodId", periodId);
+        courseParam.put("companyUserId", companyUserId);
+        courseParam.put("videoId", videoId);
+        courseParam.put("projectId", projectId);
+        courseParam.put("companyId", companyId);
+        courseParam.put("courseId", courseId);
+        courseParam.put("id", id);
+        String courseJson = JSONUtil.toJsonStr(courseParam);
+        try {
+            // 第一次编码:对JSON进行URL编码
+            String encodedJson = URLEncoder.encode(courseJson, "UTF-8");
+            // 拼接完整URL(userId与course参数同级)
+            String baseUrl = config.getFeishuNewLinkDomainName();
+            String fullUrl = baseUrl + "/pages_course/videovip?course=" + encodedJson + "&userId=" +userId;
+            // 第二次编码:对整个URL进行URL编码(适配飞书iframe创建要求)
+            return URLEncoder.encode(fullUrl, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new CustomException("看课URL拼接失败", e);
+        }
+    }
+
+    /**
+     * 拼接授权url(生成短token,参数存Redis+数据库,适配飞书iframe,双重URL编码)
+     */
+    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long periodId, Long projectId,Long id) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 授权参数:生成短token,参数存Redis+数据库
+        Map<String, Object> authParam = new LinkedHashMap<>();
+        authParam.put("periodId", periodId);
+        authParam.put("companyUserId", companyUserId);
+        authParam.put("videoId", videoId);
+        authParam.put("projectId", projectId);
+        authParam.put("companyId", companyId);
+        authParam.put("courseId", courseId);
+        authParam.put("id", id);
+        String token = authLinkTokenService.saveAuthParams(authParam);
+
+        try {
+            String baseUrl = config.getFeishuNewLinkDomainName();
+            String AUTH_PATH ="/pages/wxauth";
+            String fullUrl = baseUrl + AUTH_PATH + "?t=" + token;
+            return URLEncoder.encode(fullUrl, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new CustomException("授权URL拼接失败", e);
+        }
+    }
+
+    /**
+     * 创建iframe块
+     */
+    private void createIframeBlock(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();
+
+        // 发起请求
+        client.docx().v1().documentBlockChildren().create(req);
+    }
+
+    /**
+     * 创建 text + link block
+     */
+    private void createTextLinkBlock(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();
+
+        client.docx().v1().documentBlockChildren().create(req);
+    }
+
+    /**
+     * 获取飞书云文档列表
+     */
+    public List<FeishuFileDTO> listFiles(int pageSize) throws Exception {
+
+        ListFileReq req = ListFileReq.newBuilder()
+                .pageSize(pageSize)
+                .orderBy("EditedTime")
+                .direction("DESC")
+                .build();
+
+        ListFileResp resp = client.drive().v1().file().list(req);
+
+        if (!resp.success()) {
+            throw new RuntimeException(
+                    "Feishu error code:" + resp.getCode()
+                            + ", msg:" + resp.getMsg()
+                            + ", reqId:" + resp.getRequestId()
+            );
+        }
+
+        List<FeishuFileDTO> result = new ArrayList<>();
+
+        if (resp.getData() != null && resp.getData().getFiles() != null) {
+            com.lark.oapi.service.drive.v1.model.File[] files = resp.getData().getFiles();
+            Arrays.stream(files).forEach(file -> {
+                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());
+
+                result.add(dto);
+            });
+        }
+
+        return result;
+    }
+
+    /**
+     * 修改文档权限
+     */
+    private void changeDocumentPermissions(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();
+
+        // 发起请求
+        client.drive().v2().permissionPublic().patch(req);
+    }
+
+    /**
+     * 根据 token 删除飞书文档
+     *
+     * @param fileToken 文档token
+     * @param type 文档类型(docx / sheet / bitable / file 等)
+     */
+    public boolean deleteFile(String fileToken, String type) throws Exception {
+
+        DeleteFileReq req = DeleteFileReq.newBuilder()
+                .fileToken(fileToken)
+                .type(type)
+                .build();
+
+        DeleteFileResp resp = client.drive().v1().file().delete(req);
+
+        if (!resp.success()) {
+            throw new RuntimeException(
+                    "Feishu delete failed, code=" + resp.getCode()
+                            + ", msg=" + resp.getMsg()
+                            + ", reqId=" + resp.getRequestId()
+            );
+        }
+
+        return true;
+    }
+
+    /**
+     * 删除两天之前创建的飞书云文档
+     * 1. 查询云文档列表
+     * 2. 遍历比较创建时间(飞书返回秒级时间戳)
+     * 3. 创建时间在两天 之前的执行删除
+     */
+    public void deleteFilesBeforeToday() throws Exception {
+        // 今天 00:00:00 的时间戳(秒)
+        Calendar twoDaysAgo  = Calendar.getInstance();
+        twoDaysAgo .set(Calendar.HOUR_OF_DAY, 0);
+        twoDaysAgo .set(Calendar.MINUTE, 0);
+        twoDaysAgo .set(Calendar.SECOND, 0);
+        twoDaysAgo .set(Calendar.MILLISECOND, 0);
+        // 减去两天
+        twoDaysAgo.add(Calendar.DAY_OF_MONTH, -2);
+        long todayStartSec = twoDaysAgo .getTimeInMillis() / 1000;
+
+        List<FeishuFileDTO> files = listFiles(200);
+        int total = files.size();
+        int deleted = 0;
+        for (FeishuFileDTO file : files) {
+            try {
+                // 飞书返回的 createdTime 为秒级时间戳
+                long createdTime = Long.parseLong(file.getCreatedTime());
+                if (createdTime < todayStartSec) {
+                    String type = file.getType();
+                    // wiki 类型无法直接删除,跳过
+                    if ("wiki".equalsIgnoreCase(type)) {
+                        continue;
+                    }
+                    boolean ok = deleteFile(file.getToken(), type);
+                    if (ok) {
+                        deleted++;
+                        log.info("已删除: {} | type={} | createdTime={}", file.getName(), type, file.getCreatedTime());
+                    }
+                }
+            } catch (Exception e) {
+                log.error("删除失败: {} | token={} | err={}", file.getName(), file.getToken(), e.getMessage());
+            }
+        }
+        log.info("飞书云文档清理完成 | 列表总数={} | 删除成功={}", total, deleted);
+    }
+}

+ 34 - 0
fs-service/src/main/java/com/fs/feishu/service/IFsAuthLinkTokenService.java

@@ -0,0 +1,34 @@
+package com.fs.feishu.service;
+
+import java.util.Map;
+
+/**
+ * 飞书授权链接token Service
+ * Redis + 数据库双写,查询优先 Redis,miss 回源数据库并回填
+ */
+public interface IFsAuthLinkTokenService {
+
+    /**
+     * 生成短token并保存授权参数(Redis + 数据库双写)
+     *
+     * @param params 授权参数
+     * @return 短token
+     */
+    String saveAuthParams(Map<String, Object> params);
+
+    /**
+     * 根据token查询授权参数
+     * 优先查 Redis,miss 则查数据库并回填 Redis
+     *
+     * @param token 短token
+     * @return 授权参数,token无效或过期返回 null
+     */
+    Map<String, Object> getAuthParamsByToken(String token);
+
+    /**
+     * 清理已过期的token记录
+     *
+     * @return 删除条数
+     */
+    int cleanExpiredTokens();
+}

+ 107 - 0
fs-service/src/main/java/com/fs/feishu/service/impl/FsAuthLinkTokenServiceImpl.java

@@ -0,0 +1,107 @@
+package com.fs.feishu.service.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.feishu.domain.FsAuthLinkToken;
+import com.fs.feishu.mapper.FsAuthLinkTokenMapper;
+import com.fs.feishu.service.IFsAuthLinkTokenService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.security.SecureRandom;
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 飞书授权链接token Service实现
+ * Redis主存(1天过期)+ 数据库持久化双写
+ */
+@Slf4j
+@Service
+public class FsAuthLinkTokenServiceImpl implements IFsAuthLinkTokenService {
+
+    private static final String REDIS_KEY_PREFIX = "feishu:auth:token:";
+    private static final int TOKEN_LENGTH = 8;
+    private static final int EXPIRE_HOURS = 24;
+    private static final String TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
+
+    @Autowired
+    private FsAuthLinkTokenMapper fsAuthLinkTokenMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Override
+    public String saveAuthParams(Map<String, Object> params) {
+        String token = generateToken();
+        String paramsJson = JSONUtil.toJsonStr(params);
+
+        // Redis 主存(1天过期)
+        String redisKey = REDIS_KEY_PREFIX + token;
+        redisCache.setCacheObject(redisKey, paramsJson, EXPIRE_HOURS, TimeUnit.HOURS);
+
+        // 数据库持久化
+        Date now = new Date();
+        FsAuthLinkToken record = new FsAuthLinkToken();
+        record.setToken(token);
+        record.setParamsJson(paramsJson);
+        record.setCreateTime(now);
+        record.setExpireTime(new Date(now.getTime() + (long) EXPIRE_HOURS * 3600 * 1000));
+        try {
+            fsAuthLinkTokenMapper.insertFsAuthLinkToken(record);
+        } catch (Exception e) {
+            log.error("授权token入库失败,token={},仅Redis可用", token, e);
+        }
+
+        return token;
+    }
+
+    @Override
+    public Map<String, Object> getAuthParamsByToken(String token) {
+        if (token == null || token.isEmpty()) {
+            return null;
+        }
+
+        // 优先查 Redis
+        String redisKey = REDIS_KEY_PREFIX + token;
+        String paramsJson = redisCache.getCacheObject(redisKey);
+        if (paramsJson == null) {
+            // Redis miss,回源数据库
+            FsAuthLinkToken record = fsAuthLinkTokenMapper.selectFsAuthLinkTokenByToken(token);
+            if (record == null) {
+                return null;
+            }
+            // 校验数据库过期时间
+            if (record.getExpireTime() != null && record.getExpireTime().before(new Date())) {
+                return null;
+            }
+            paramsJson = record.getParamsJson();
+            // 回填 Redis(剩余有效时长,秒为单位)
+            long remainMs = record.getExpireTime() != null
+                    ? record.getExpireTime().getTime() - System.currentTimeMillis() : 0;
+            int remainSeconds = (int) (remainMs / 1000);
+            if (remainSeconds > 0) {
+                redisCache.setCacheObject(redisKey, paramsJson, remainSeconds, TimeUnit.SECONDS);
+            }
+        }
+
+        return JSONUtil.toBean(paramsJson, Map.class);
+    }
+
+    @Override
+    public int cleanExpiredTokens() {
+        return fsAuthLinkTokenMapper.deleteExpiredTokens();
+    }
+
+    /** 生成8位随机token(小写字母+数字) */
+    private String generateToken() {
+        SecureRandom random = new SecureRandom();
+        StringBuilder sb = new StringBuilder(TOKEN_LENGTH);
+        for (int i = 0; i < TOKEN_LENGTH; i++) {
+            sb.append(TOKEN_CHARS.charAt(random.nextInt(TOKEN_CHARS.length())));
+        }
+        return sb.toString();
+    }
+}

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

@@ -0,0 +1,103 @@
+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 periodId, Long timestamp) {
+        try {
+            String dataBuilder = companyUserId + ":" + videoId + ":" + periodId + ":" + 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;
+    }
+
+    public static void main(String[] args) {
+        System.out.println(generateToken(10378L, 136L,38L, System.currentTimeMillis() / 1000));
+    }
+}

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

@@ -46,6 +46,8 @@ public interface FsUserMapper
      */
     public FsUser selectFsUserByUserId(Long userId);
 
+    public FsUser selectFsUserByUserIdNotDel(Long userId);
+
     /**
      * 查询用户列表
      *

+ 4 - 0
fs-service/src/main/resources/application-config-dev.yml

@@ -119,3 +119,7 @@ jst:
 #  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
   authorization_code: 666666
   shop_code: "18461733"
+# 飞书
+feishu:
+  appId: "cli_a92ff75ce0785bc0"
+  appSecret: "DCQPW11pTY48zSdwAnqPwhJfihe0kP8G"

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

@@ -154,3 +154,7 @@ isNewWxMerchant: false
 # 如果不配置,默认 auto-startup 为 true
 quartz:
     auto-startup: false
+# 飞书
+feishu:
+    appId: "cli_aadee027e379dbd3"
+    appSecret: "32GdR07B6vd2z37ha0tCKegQgEYQFeu3"

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

@@ -153,3 +153,7 @@ isNewWxMerchant: true
 quartz:
     auto-startup: true
 
+# 飞书
+feishu:
+    appId: "cli_aadee027e379dbd3"
+    appSecret: "32GdR07B6vd2z37ha0tCKegQgEYQFeu3"

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

+ 73 - 1
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -1789,5 +1789,77 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         group by l.company_user_id
     </select>
 
-
+    <insert id="insertBackup">
+        INSERT INTO fs_course_watch_log_bak (
+            original_log_id,
+            user_id,
+            video_id,
+            log_type,
+            create_time,
+            update_time,
+            qw_external_contact_id,
+            duration,
+            qw_user_id,
+            company_user_id,
+            company_id,
+            course_id,
+            send_type,
+            reward_type,
+            last_heartbeat_time,
+            sop_id,
+            finish_time,
+            send_finish_msg,
+            camp_period_time,
+            day,
+            project,
+            create_by,
+            update_by,
+            period_id,
+            project_id,
+            im_msg_send_detail_id,
+            watch_type,
+            delete_time,
+            delete_by,
+            delete_method,
+            original_data,
+            backup_operator,
+            backup_batch_no,
+            remark
+        ) VALUES (
+                     #{originalLogId},
+                     #{userId},
+                     #{videoId},
+                     #{logType},
+                     #{createTime},
+                     #{updateTime},
+                     #{qwExternalContactId},
+                     #{duration},
+                     #{qwUserId},
+                     #{companyUserId},
+                     #{companyId},
+                     #{courseId},
+                     #{sendType},
+                     #{rewardType},
+                     #{lastHeartbeatTime},
+                     #{sopId},
+                     #{finishTime},
+                     #{sendFinishMsg},
+                     #{campPeriodTime},
+                     #{day},
+                     #{project},
+                     #{createBy},
+                     #{updateBy},
+                     #{periodId},
+                     #{projectId},
+                     #{imMsgSendDetailId},
+                     #{watchType},
+                     #{deleteTime},
+                     #{deleteBy},
+                     #{deleteMethod},
+                     #{originalData},
+                     #{backupOperator},
+                     #{backupBatchNo},
+                     #{remark}
+                 )
+    </insert>
 </mapper>

+ 39 - 0
fs-service/src/main/resources/mapper/feishu/FsAuthLinkTokenMapper.xml

@@ -0,0 +1,39 @@
+<?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.FsAuthLinkTokenMapper">
+
+    <resultMap id="FsAuthLinkTokenResult" type="com.fs.feishu.domain.FsAuthLinkToken">
+        <result property="token"       column="token"/>
+        <result property="paramsJson"  column="params_json"/>
+        <result property="createTime"  column="create_time"/>
+        <result property="expireTime"  column="expire_time"/>
+    </resultMap>
+
+    <sql id="selectFsAuthLinkTokenVo">
+        select token, params_json, create_time, expire_time
+        from fs_auth_link_token
+    </sql>
+
+    <insert id="insertFsAuthLinkToken" parameterType="com.fs.feishu.domain.FsAuthLinkToken">
+        insert into fs_auth_link_token
+        (token, params_json, create_time, expire_time)
+        values
+        (#{token}, #{paramsJson}, #{createTime}, #{expireTime})
+    </insert>
+
+    <select id="selectFsAuthLinkTokenByToken" parameterType="String" resultMap="FsAuthLinkTokenResult">
+        <include refid="selectFsAuthLinkTokenVo"/>
+        where token = #{token}
+    </select>
+
+    <delete id="deleteFsAuthLinkTokenByToken" parameterType="String">
+        delete from fs_auth_link_token where token = #{token}
+    </delete>
+
+    <delete id="deleteExpiredTokens">
+        delete from fs_auth_link_token where expire_time &lt; NOW()
+    </delete>
+
+</mapper>

+ 4 - 0
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -109,6 +109,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <include refid="selectFsUserVo"/>
         where user_id = #{userId}
     </select>
+    <select id="selectFsUserByUserIdNotDel" parameterType="Long" resultMap="FsUserResult">
+        <include refid="selectFsUserVo"/>
+        where user_id = #{userId} and is_del = 0
+    </select>
     <select id="courseAnalysisWatchLog" resultType="com.fs.course.vo.newfs.FsCourseAnalysisCountVO">
         SELECT
         count( DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END ) AS courseWatchNum,

+ 21 - 4
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -24,6 +24,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.dto.FsUserBindSalesParamDTO;
 import com.fs.his.enums.FsUserOperationEnum;
@@ -56,6 +58,8 @@ public class CourseFsUserController extends AppBaseController {
     @Autowired
     private IFsUserCourseVideoService courseVideoService;
 
+    @Autowired
+    private FeiShuService feiShuService;
 
     @Autowired
     private IFsCourseLinkService courseLinkService;
@@ -113,7 +117,9 @@ public class CourseFsUserController extends AppBaseController {
     @GetMapping("/videoDetails")
     @UserOperationLog(operationType = FsUserOperationEnum.STUDY)
     public ResponseResult<FsUserCourseVideoLinkDetailsVO> getCourseVideoDetails(FsUserCourseVideoLinkParam param) {
-        param.setFsUserId(Long.parseLong(getUserId()));
+        if(param.getFsUserId()==null){
+            param.setFsUserId(Long.parseLong(getUserId()));
+        }
         return courseVideoService.getLinkCourseVideoDetails(param);
     }
 
@@ -129,7 +135,9 @@ public class CourseFsUserController extends AppBaseController {
     @Login
     public R updateWatchDuration(@RequestBody FsUserCourseVideoUParam param)
     {
-        param.setUserId(Long.parseLong(getUserId()));
+        if(param.getUserId()==null){
+            param.setUserId(Long.parseLong(getUserId()));
+        }
         return courseVideoService.updateWatchDurationWx(param);
     }
 
@@ -138,7 +146,9 @@ public class CourseFsUserController extends AppBaseController {
     @PostMapping("/getInternetTraffic")
     @Login
     public R getInternetTraffic(@RequestBody FsUserCourseVideoFinishUParam param) {
-        param.setUserId(Long.parseLong(getUserId()));
+        if(param.getUserId()==null){
+            param.setUserId(Long.parseLong(getUserId()));
+        }
         return courseVideoService.getInternetTraffic(param);
     }
 
@@ -150,7 +160,9 @@ public class CourseFsUserController extends AppBaseController {
     @PostMapping("/getPublicCourseInternetTraffic")
     @Login
     public R getPublicCourseInternetTraffic(@RequestBody FsUserCourseVideoFinishUParam param ){
-        param.setUserId(Long.parseLong(getUserId()));
+        if(param.getUserId()==null){
+            param.setUserId(Long.parseLong(getUserId()));
+        }
         return courseVideoService.getPublicCourseInternetTraffic(param);
     }
 
@@ -229,5 +241,10 @@ public class CourseFsUserController extends AppBaseController {
 
 
 
+    @ApiOperation("获取飞书看课新链接")
+    @PostMapping("/getFeiShuCourseNewLink")
+    public R getFeiShuCourseNewLink(@RequestBody FeishuLinkParam param) {
+        return R.ok().put("data", feiShuService.getFeishuCourseNewLink(param));
+    }
 
 }

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

@@ -0,0 +1,129 @@
+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 com.fs.feishu.service.IFsAuthLinkTokenService;
+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("/feishu/course/h5")
+public class FeiShuCourseController {
+
+    @Autowired
+    private IFsUserCourseService courseService;
+    @Autowired
+    private IFsUserCourseVideoService courseVideoService;
+    @Autowired
+    private IFsAuthLinkTokenService authLinkTokenService;
+
+    @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");
+        Long periodId = (Long) params.get("periodId");
+
+        if (StringUtils.isNull(identifier)) {
+            return R.error("非法操作!");
+        }
+        return courseVideoService.getFeiShuLinkCourseVideoDetails(companyUserId, videoId, periodId, 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");
+        Long periodId = (Long) params.get("periodId");
+        if (StringUtils.isNull(identifier)) {
+            return;
+        }
+
+        courseVideoService.feiShuUpdateWatchDurationWx(companyUserId, videoId, periodId, 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");
+        Long periodId = (Long) params.get("periodId");
+        if (StringUtils.isNull(identifier)) {
+            return;
+        }
+        courseVideoService.getFeiShuInternetTraffic(identifier, companyUserId, videoId, periodId, param.getUuId(), param.getBufferRate());
+    }
+
+    @ApiOperation("根据短token查询授权参数")
+    @GetMapping("/getAuthParams")
+    public R getAuthParams(@RequestParam String token) {
+        Map<String, Object> params = authLinkTokenService.getAuthParamsByToken(token);
+        if (params == null || params.isEmpty()) {
+            return R.error("授权链接无效或已过期");
+        }
+        return R.ok().put("data", params);
+    }
+
+    /**
+     * 安全获取课程信息
+     */
+    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);
+        }
+    }
+}

+ 20 - 1
fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java

@@ -6,6 +6,8 @@ import com.fs.app.exception.FSException;
 import com.fs.app.utils.JwtUtils;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
 import io.jsonwebtoken.Claims;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;
@@ -25,7 +27,10 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     private JwtUtils jwtUtils;
     @Autowired
     RedisCache redisCache;
+    @Autowired
+    FsUserMapper fsUserMapper;
     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 +45,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)){
@@ -55,13 +66,21 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
         if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
             throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
         }
+
         //查询用户的TOKEN是否和REDIS中的一样
 //        String redisToken=redisCache.getCacheObject("token:"+ Long.parseLong(claims.getSubject()));
 //        if(redisToken==null||!redisToken.equals(token)){
 //            throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
 //        }
+
+        Long userId = Long.parseLong(claims.getSubject());
+        FsUser fsUser = fsUserMapper.selectFsUserByUserIdNotDel(userId);
+        if (fsUser == null) {
+            throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
+        }
+
         //设置userId到request里,后续根据userId,获取用户信息
-        request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));
+        request.setAttribute(USER_KEY, userId);
 
         return true;
     }

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

+ 15 - 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,14 +116,21 @@ 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);