cgp hace 9 horas
padre
commit
a167c2f66b
Se han modificado 31 ficheros con 1626 adiciones y 29 borrados
  1. 48 0
      fs-admin/src/main/java/com/fs/course/task/CourseWatchLogScheduler.java
  2. 31 0
      fs-admin/src/main/java/com/fs/task/FeiShuFileCleanTask.java
  3. 27 4
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  4. 47 0
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  5. 64 0
      fs-company/src/main/java/com/fs/company/controller/course/FeiShuCourseController.java
  6. 8 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. 7 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseTrafficLog.java
  11. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java
  12. 5 0
      fs-service/src/main/java/com/fs/course/param/FsCourseSendRewardUParam.java
  13. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  14. 17 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  15. 170 4
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  16. 241 10
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  17. 13 0
      fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java
  18. 16 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java
  19. 38 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java
  20. 430 0
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  21. 103 0
      fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java
  22. 3 0
      fs-service/src/main/resources/application-config-druid-syysy-test.yml
  23. 3 0
      fs-service/src/main/resources/application-config-druid-syysy.yml
  24. 3 1
      fs-service/src/main/resources/application-druid-syysy-test.yml
  25. 4 0
      fs-service/src/main/resources/application-druid-syysy.yml
  26. 28 0
      fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml
  27. 115 0
      fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java
  28. 10 1
      fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  29. 19 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java
  30. 16 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java
  31. 17 9
      fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

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

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

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

+ 27 - 4
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -3,10 +3,7 @@ package com.fs.common.core.redis;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.redis.core.BoundSetOperations;
-import org.springframework.data.redis.core.HashOperations;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.data.redis.core.*;
 import org.springframework.stereotype.Component;
 
 /**
@@ -301,4 +298,30 @@ public class RedisCache
     {
         redisTemplate.opsForList().rightPush(key, value);
     }
+
+    public void addToSetRaw(String setKey, String... values) {
+        redisTemplate.execute((RedisCallback<Void>) connection -> {
+            byte[] rawKey = redisTemplate.getStringSerializer().serialize(setKey);
+            for (String value : values) {
+                byte[] rawValue = redisTemplate.getStringSerializer().serialize(value);
+                connection.sAdd(rawKey, rawValue);
+            }
+            return null;
+        });
+    }
+
+
+    public Set<String> getSetMembersRaw(String setKey) {
+        return (Set<String>) redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
+            byte[] rawKey = redisTemplate.getStringSerializer().serialize(setKey);
+            Set<byte[]> rawMembers = connection.sMembers(rawKey);
+            Set<String> members = new HashSet<>();
+            if (rawMembers != null) {
+                for (byte[] rawMember : rawMembers) {
+                    members.add((String) redisTemplate.getStringSerializer().deserialize(rawMember));
+                }
+            }
+            return members;
+        });
+    }
 }

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

@@ -8,6 +8,7 @@ import com.fs.common.annotation.Log;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.exception.CustomException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.service.ICompanyUserService;
@@ -25,12 +26,15 @@ import com.fs.course.service.*;
 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;
 import com.fs.im.service.IFsImMsgSendLogService;
 import com.fs.im.service.OpenIMService;
 import com.fs.im.vo.FsImMsgSendLogVO;
+import com.fs.system.service.ISysConfigService;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
@@ -346,4 +350,47 @@ public class FsUserCourseVideoController extends AppBaseController {
         return imMsgSendLogService.deleteFsImMsgSendLogAndDetail(logId);
     }
 
+    @Autowired
+    private FeiShuService feiShuService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Login
+    @ApiOperation("生成飞书看课链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public R getFeiShuCourseLink(@RequestParam Long companyUserId,
+                                 @RequestParam Long videoId,
+                                 @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);
+            throw new CustomException("读取飞书看课配置失败");
+        }
+
+        if (Boolean.TRUE.equals(enableFeishuNewLink)) {
+            FeishuLinkParam param = new FeishuLinkParam();
+            param.setCompanyId(companyId);
+            param.setCompanyUserId(companyUserId);
+            param.setCourseId(courseId);
+            param.setVideoId(videoId);
+            param.setProjectId(projectId);
+            param.setId(id);
+            String link = feiShuService.getFeishuRegisterLink(param);
+            log.info("链接{}", link);
+            return R.ok().put("data", link);
+        }
+        return R.error("未开启飞书看课,生成链接失败!");
+    }
+
 }

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

@@ -0,0 +1,64 @@
+package com.fs.company.controller.course;
+
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
+import com.fs.system.service.ISysConfigService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+
+@Api("销售端-飞书看课接口")
+@RestController
+@RequestMapping("/feishu/course/")
+@Slf4j
+public class FeiShuCourseController extends BaseController {
+
+    @Autowired
+    private FeiShuService feiShuService;
+
+    @Autowired
+    private ISysConfigService configService;
+    @ApiOperation("生成飞书看课链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public R getFeiShuCourseLink(@RequestParam Long companyUserId,
+                                 @RequestParam Long videoId,
+                                 @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);
+            throw new CustomException("读取飞书看课配置失败");
+        }
+
+        if (Boolean.TRUE.equals(enableFeishuNewLink)) {
+            FeishuLinkParam param = new FeishuLinkParam();
+            param.setCompanyId(companyId);
+            param.setCompanyUserId(companyUserId);
+            param.setCourseId(courseId);
+            param.setVideoId(videoId);
+            param.setProjectId(projectId);
+            param.setId(id);
+            String link = feiShuService.getFeishuRegisterLink(param);
+            log.info("链接{}", link);
+            return R.ok().put("data", link);
+        }
+        return R.error("未开启飞书看课,生成链接失败!");
+    }
+}

+ 8 - 0
fs-service/pom.xml

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

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

@@ -18,6 +18,9 @@ public class CourseConfig implements Serializable {
     private Integer answerIntegral;//答题获得积分
     private Integer defaultLine;//默认看课线路
     private String realLinkDomainName;//真链域名
+    private String feishuLinkDomainName;//飞书看课域名
+    private String feishuNewLinkDomainName;//新飞书看课域名
+    private Boolean enableFeishuNewLink;//是否启用飞书新跳转
     private String authDomainName;//网页授权域名
     private String smsDomainName;//短信推送域名
     private String smsDomain;//短信推送域名

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

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

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

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

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

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

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

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

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

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

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

@@ -139,4 +139,14 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
     int deleteOldDataBatch();
 
     long getOldDataCount();
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    void scheduleFeiShuBatchUpdateToDatabase();
+
+    /**
+     * 检查飞书看课状态
+     */
+    void checkFeiShuWatchStatus();
 }

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

@@ -19,6 +19,7 @@ import com.fs.course.vo.newfs.FsUserVideoListVO;
 import com.fs.his.domain.FsUser;
 import com.fs.his.vo.OptionsVO;
 
+import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
 
@@ -222,4 +223,20 @@ public interface IFsUserCourseVideoService
      * @return
      */
     R createSqMiniLink(FsCourseLinkMiniParam param);
+
+
+    /**
+     * 获取飞书课程详情
+     */
+    R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId,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);
 }

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

@@ -16,10 +16,8 @@ import com.fs.company.cache.ICompanyUserCacheService;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
 import com.fs.course.config.CourseConfig;
-import com.fs.course.domain.FsCourseFinishTemp;
-import com.fs.course.domain.FsCourseWatchLog;
-import com.fs.course.domain.FsUserCourse;
-import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.constant.CourseConstant;
+import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -54,6 +52,9 @@ import com.fs.system.mapper.SysDictDataMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.vo.DictVO;
 import com.hc.openapi.tool.util.StringUtils;
+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;
@@ -1182,4 +1183,169 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         return baseMapper.countOldData(cutoffTime);
     }
 
+    /**
+     * 检查飞书看课记录-完课
+     */
+    @Override
+    public void scheduleFeiShuBatchUpdateToDatabase() {
+        log.info("开始更新飞书看课时长,检查完课>>>>>>");
+        //读取所有的key
+        Collection<String> keys = redisCache.keys(CourseConstant.FEI_SHU_WATCH_DURATION + "*");
+        //读取看课配置
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        List<FeiShuCourseWatchLog> logs = new ArrayList<>();
+        for (String key : keys) {
+            //取key中数据
+            Long userId = null;
+            Long videoId = null;
+            Long companyUserId = null;
+            try {
+                String[] parts = key.split(":");
+                userId = Long.parseLong(parts[3]);
+                videoId = Long.parseLong(parts[4]);
+                companyUserId = Long.parseLong(parts[5]);
+            } catch (Exception e) {
+                log.error("飞书key中id为null:{}", key);
+                continue;
+            }
+            Long duration = redisCache.getCacheObject(key);
+            if (duration == null) {
+                log.error("飞书key中数据为null:{}", key);
+                continue;  // 如果 Redis 中没有记录,跳过
+            }
+
+            FeiShuCourseWatchLog watchLog = new FeiShuCourseWatchLog();
+            watchLog.setUserId(userId);
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setDuration(duration.intValue());
+
+            //取对应视频的时长
+            Long videoDuration = 0L;
+            try {
+                videoDuration = getVideoDuration(videoId);
+            } catch (Exception e) {
+                log.error("飞书视频时长识别错误:{}", key);
+                continue;
+            }
+
+            if (videoDuration != null && videoDuration != 0) {
+                boolean complete = false;
+                // 判断百分比
+                if (config.getCompletionMode() == 1 && config.getAnswerRate() != null) {
+                    long percentage = (duration * 100 / videoDuration);
+                    complete = percentage >= config.getAnswerRate();
+                }
+                // 判断分钟数
+                if (config.getCompletionMode() == 2 && config.getMinutesNum() != null) {
+                    int i = config.getMinutesNum() * 60;
+                    complete = duration >= i;
+                }
+                //判断是否完课
+                if (complete) {
+                    watchLog.setLogType(2); // 设置状态为"已完成"
+                    watchLog.setFinishTime(LocalDateTime.now());
+                    String heartbeatKey = CourseConstant.getFeiShuWatchHeartKey(userId, videoId, companyUserId);
+                    // 完课删除心跳记录
+                    redisCache.deleteObject(heartbeatKey);
+                    // 完课删除看课时长记录
+                    redisCache.deleteObject(key);
+                }
+            }
+
+            //集合中增加
+            logs.add(watchLog);
+        }
+
+        // 批量更新飞书看课记录
+        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(logs)) {
+            batchUpdateFeiShuCourseWatchLog(logs);
+        }
+    }
+
+    /**
+     * 检查飞书看课状态
+     */
+    @Override
+    public void checkFeiShuWatchStatus() {
+        log.info("开始更新飞书看课中断记录>>>>>");
+        // 从 Redis 中获取所有正在看课的用户记录
+        Collection<String> keys = redisCache.keys(CourseConstant.FEI_SHU_WATCH_HEART + "*");
+        LocalDateTime now = LocalDateTime.now();
+        List<FeiShuCourseWatchLog> logs = new ArrayList<>();
+
+        for (String key : keys) {
+            FeiShuCourseWatchLog watchLog = new FeiShuCourseWatchLog();
+            //取key中数据
+            Long userId = null;
+            Long videoId = null;
+            Long companyUserId = null;
+            Long periodId = null;
+            try {
+                String[] parts = key.split(":");
+                userId = Long.parseLong(parts[3]);
+                videoId = Long.parseLong(parts[4]);
+                companyUserId = Long.parseLong(parts[5]);
+                periodId = Long.parseLong(parts[6]);
+            } catch (Exception e) {
+                log.error("飞书key中id为null:{}", key);
+                continue;
+            }
+            // 获取最后心跳时间
+            String lastHeartbeatStr = redisCache.getCacheObject(key);
+            if (StringUtils.isEmpty(lastHeartbeatStr)) {
+                redisCache.deleteObject(key);
+                continue; // 如果 Redis 中没有记录,跳过
+            }
+            LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
+            Duration duration = Duration.between(lastHeartbeatTime, now);
+
+            watchLog.setUserId(userId);
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setPeriodId(periodId);
+            watchLog.setLastHeartbeatTime(lastHeartbeatTime);
+            // 如果超过一分钟没有心跳,标记为"观看中断"
+            if (duration.getSeconds() >= 60) {
+                watchLog.setLogType(4);
+                // 从 Redis 中删除该记录
+                redisCache.deleteObject(key);
+            } else {
+                watchLog.setLogType(1);
+            }
+            logs.add(watchLog);
+        }
+
+        // 批量更新飞书看课记录
+        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(logs)) {
+            batchUpdateFeiShuCourseWatchLog(logs);
+        }
+    }
+
+    @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);
+        }
+    }
+
 }

+ 241 - 10
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -28,6 +28,7 @@ import com.fs.company.service.ICompanyService;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.core.config.WxOpenProperties;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePackageDTO;
 import com.fs.course.mapper.*;
@@ -37,10 +38,7 @@ import com.fs.course.service.IFsUserCompanyUserQwService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.service.IFsVideoResourceService;
-import com.fs.course.vo.FsCourseVideoListBySidebarVO;
-import com.fs.course.vo.FsUserCourseVideoListUVO;
-import com.fs.course.vo.FsUserCourseVideoQVO;
-import com.fs.course.vo.FsUserCourseVideoVO;
+import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUserIntegralLogs;
@@ -147,6 +145,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
     @Autowired
     private FsUserCourseStudyLogMapper courseStudyLogMapper;
 
+    @Autowired
+    private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
+
     @Autowired
     private FsUserCourseOrderMapper fsUserCourseOrderMapper;
 
@@ -1160,11 +1161,15 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
             return R.error("无记录");
         }
 
-        FsCourseAnswerLogs rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
-
-        if (rightLog == null) {
-            logger.error("未答题:{}",param.getUserId());
-            return R.error("未答题");
+        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("未答题");
+            }
         }
         if (log.getRewardType() != null ) {
             if (log.getRewardType() == 1){
@@ -1512,7 +1517,9 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
             // 更新观看记录的奖励类
             log.setRewardType(config.getRewardType());
             courseWatchLogMapper.updateFsCourseWatchLog(log);
-            return R.ok("红包发送成功");
+//            return R.ok("红包发送成功");
+            String msg=param.getIsFeiShu()?"奖励发放成功,请联系客服":"红包发送成功";
+            return R.ok(msg);
         }
 
     }
@@ -3518,5 +3525,229 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
         return R.ok("生成看课记录成功!");
     }
 
+
+    /**
+     * 获取飞书课程详情
+     */
+    @Override
+    public R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId,  Long identifier) {
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        //判断该销售是否存在
+        if (companyUser == null) {
+            return R.error(405, "当前销售不存在");
+        }
+
+        // 获取视频详情
+        FsUserCourseVideoDetailsVO courseVideoDetails = getFeiShuVideoDetails(videoId);
+
+        // 查看为最新课程
+        FsUserCourseAddCompanyUserParam param = new FsUserCourseAddCompanyUserParam();
+        param.setCourseId(courseVideoDetails.getCourseId());
+        param.setVideoId(videoId);
+        param.setCompanyUserId(companyUserId);
+        if (!isUserCoursePeriodValid(param)) {
+            return R.error(504, "请观看最新的课程项目");
+        }
+
+        // 获取课程所属项目id
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(param.getCourseId());
+        Long courseProject = fsUserCourse.getProject();
+        if (Objects.isNull(courseProject)) {
+            return R.error(504, "课程配置错误,项目归属为空,课程ID: " + param.getCourseId());
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        // 课程logo
+        config.setCourseLogo(fsUserCourse.getImgUrl());
+
+
+        long duration = 0L;
+        long tipsTime = 0L;
+        long tipsTime2 = 0L;
+        int isFinish = 0;
+        FsUserCourseVideoLinkDetailsVO vo = new FsUserCourseVideoLinkDetailsVO();
+        vo.setCourseVideoDetails(courseVideoDetails);
+        vo.setCourseConfig(config);
+        vo.setIsFinish(isFinish);
+        vo.setPlayDuration(duration);
+
+        // 获取看课记录
+        FeiShuCourseWatchLog watchLog = feishuCourseWatchLogMapper.getWatchLogByFsUser(videoId, identifier, companyUserId);
+        if (watchLog == null) {
+            watchLog = new FeiShuCourseWatchLog();
+            watchLog.setUserId(identifier);
+            watchLog.setCourseId(courseVideoDetails.getCourseId());
+            watchLog.setVideoId(videoId);
+            watchLog.setCompanyId(companyUser.getCompanyId());
+            watchLog.setCompanyUserId(companyUserId);
+            watchLog.setSendType(1);
+            watchLog.setDuration(0);
+            watchLog.setCreateTime(LocalDateTime.now());
+            watchLog.setLogType(1);
+            watchLog.setProject(courseProject);
+            watchLog.setPeriodId(courseProject);
+            feishuCourseWatchLogMapper.insert(watchLog);
+
+            String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId);
+            redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), 5, TimeUnit.MINUTES);
+        }
+
+        // 从Redis中获取用户目前的观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId);
+        Long durationCurrent = redisCache.getCacheObject(redisKey);
+        if (durationCurrent != null) {
+            duration = durationCurrent;
+        } else {
+            duration = watchLog.getDuration();
+            redisCache.setCacheObject(redisKey, duration, 2, TimeUnit.HOURS);
+        }
+
+        //判断是否完课
+        if (watchLog.getLogType() == 2) {
+            isFinish = 1;
+        }
+
+        vo.setTipsTime(tipsTime);
+        vo.setTipsTime2(tipsTime2);
+        vo.setIsFinish(isFinish);
+        vo.setPlayDuration(duration);
+
+        return R.ok().put("data", vo);
+    }
+
+
+    /**
+     * 获取视频详情
+     */
+    private FsUserCourseVideoDetailsVO getFeiShuVideoDetails(Long videoId) {
+        FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        FsUserCourseVideoDetailsVO fsUserCourseVideoDetailsVO = new FsUserCourseVideoDetailsVO();
+        BeanUtils.copyProperties(fsUserCourseVideo, fsUserCourseVideoDetailsVO);
+
+        //这里 改成取线路一值,返回给前端。VideoUrl 是原视频(用来算流量的),不要去改,lineOne是转码后的视频
+        fsUserCourseVideoDetailsVO.setVideoUrl(fsUserCourseVideo.getLineOne());
+
+        // 获取课程相关的题库
+        String questionBankId = fsUserCourseVideo.getQuestionBankId();
+        List<FsUserVideoQuestionVO> questionList = Collections.emptyList();
+        if (StringUtils.isNotEmpty(questionBankId)) {
+            String[] questionBankIds = questionBankId.split(",");
+            List<FsCourseQuestionBank> fsCourseQuestionBanks = courseQuestionBankMapper.selectFsCourseQuestionBankByIdVO(questionBankIds);
+            questionList = fsCourseQuestionBanks.stream().map(v -> {
+                FsUserVideoQuestionVO fsUserVideoQuestionVO = new FsUserVideoQuestionVO();
+                BeanUtils.copyProperties(v, fsUserVideoQuestionVO);
+                return fsUserVideoQuestionVO;
+            }).collect(Collectors.toList());
+        }
+
+        fsUserCourseVideoDetailsVO.setQuestionBankList(questionList);
+        return fsUserCourseVideoDetailsVO;
+    }
+    /**
+     * 更新飞书看课时长
+     */
+    @Override
+    public void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long periodId, Long identifier, Long duration) {
+        // 获取视频总时长
+        Long videoDuration = getAutoLookVideoDuration(videoId);
+        if (duration > videoDuration + 10) {
+            return;
+        }
+
+        // 获取用户观看时长
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId);
+        Long userLookDuration = redisCache.getCacheObject(redisKey);
+
+        // 更新观看时长
+        if (duration > (userLookDuration != null ? userLookDuration : 0) + 90) {
+            return;
+        }
+        redisCache.setCacheObject(redisKey, duration, 2, TimeUnit.HOURS);
+
+        // 更新心跳
+        String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId);
+        redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().toString(), 5, TimeUnit.MINUTES);
+    }
+
+    /**
+     * 飞书看课流量统计
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, 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);
+    }
+
+
+    /**
+     * 获取视频时长(优先从Redis获取,不存在则查数据库)
+     */
+    private Long getAutoLookVideoDuration(Long videoId) {
+        //将视频时长也存到redis
+        String videoRedisKey = "h5wxuser:video:duration:" + videoId;
+        Long videoDuration = 0L;
+        try {
+            videoDuration = redisCache.getCacheObject(videoRedisKey);
+        } catch (Exception e) {
+            String string = redisCache.getCacheObject(videoRedisKey);
+            videoDuration = Long.parseLong(string);
+            log.error("key中id为S:{}", videoDuration);
+        }
+        if (videoDuration == null) {
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+            videoDuration = video.getDuration();
+            redisCache.setCacheObject(videoRedisKey, video.getDuration());
+        }
+        return videoDuration;
+    }
+
 }
 

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

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

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

@@ -0,0 +1,430 @@
+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;
+
+    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.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 + "/feishuCourse/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(课程参数JSON方式,适配飞书iframe,双重URL编码)
+     */
+    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId, Long videoId, Long projectId,Long id) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+
+        Map<String, Object> authParam = new LinkedHashMap<>();
+        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 authJson = JSONUtil.toJsonStr(authParam);
+
+        try {
+            String encodedJson = URLEncoder.encode(authJson, "UTF-8");
+            String baseUrl =config.getFeishuNewLinkDomainName();
+            String fullUrl = baseUrl + "/feishuCourse/pages_course/wxauth?course=" + encodedJson;
+            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. 创建时间在今天 00:00:00 之前的执行删除
+     */
+    public void deleteFilesBeforeToday() throws Exception {
+        // 今天 00:00:00 的时间戳(秒)
+        Calendar today = Calendar.getInstance();
+        today.set(Calendar.HOUR_OF_DAY, 0);
+        today.set(Calendar.MINUTE, 0);
+        today.set(Calendar.SECOND, 0);
+        today.set(Calendar.MILLISECOND, 0);
+        long todayStartSec = today.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);
+    }
+
+}

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

+ 3 - 0
fs-service/src/main/resources/application-config-druid-syysy-test.yml

@@ -89,4 +89,7 @@ ipad:
 wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:
+feishu:
+  appId: "cli_aac042e670789bc1"
+  appSecret: "vsBsTrlexcB248quBX6TbcdPGdGP7lEd"
 

+ 3 - 0
fs-service/src/main/resources/application-config-druid-syysy.yml

@@ -99,3 +99,6 @@ jst:
   app_secret: 3f382758a4f4470e80932a24912be0ce #聚水潭2025-09-0
   authorization_code: 999999
   shop_code: "18886784"
+feishu:
+  appId: "cli_aac042e670789bc1"
+  appSecret: "vsBsTrlexcB248quBX6TbcdPGdGP7lEd"

+ 3 - 1
fs-service/src/main/resources/application-druid-syysy-test.yml

@@ -169,5 +169,7 @@ im:
     type: OPENIM
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
-
+feishu:
+    appId: "cli_aac042e670789bc1"
+    appSecret: "vsBsTrlexcB248quBX6TbcdPGdGP7lEd"
 

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

@@ -217,4 +217,8 @@ im:
     type: OPENIM
 #是否为新商户,新商户不走mpOpenId
 isNewWxMerchant: true
+# 飞书
+feishu:
+    appId: "cli_aac042e670789bc1"
+    appSecret: "vsBsTrlexcB248quBX6TbcdPGdGP7lEd"
 

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

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

@@ -0,0 +1,115 @@
+package com.fs.app.controller.course;
+
+import cn.hutool.core.util.IdUtil;
+import com.fs.app.param.FeiShuGetInternetTrafficParam;
+import com.fs.app.param.FeiShuUpdateWatchDurationParam;
+import com.fs.feishu.util.SecureTokenUtil;
+import com.fs.common.core.domain.R;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.vo.FsUserCourseVideoH5VO;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@Api("会员-飞书看课接口")
+@RestController
+@RequestMapping("/feishu/course/h5")
+public class FeiShuCourseController {
+
+    @Autowired
+    private IFsUserCourseService courseService;
+    @Autowired
+    private IFsUserCourseVideoService courseVideoService;
+
+    @ApiOperation("飞书获取唯一标识")
+    @GetMapping("/getUniqueIdentifier")
+    public R getUniqueIdentifier(@RequestParam("token") String token) {
+        return R.ok().put("data", IdUtil.getSnowflake(0, 0).nextIdStr());
+    }
+
+    @ApiOperation("飞书课程简介")
+    @GetMapping("/getH5CourseByVideoId")
+    public R getCourseByVideoId(@RequestParam("token") String token) {
+        Map<String, Object> params = accessCourse(token);
+        Long videoId = (Long) params.get("videoId");
+        FsUserCourseVideoH5VO course = courseService.selectFsUserCourseVideoH5VOByVideoId(videoId);
+        return R.ok().put("data", course);
+    }
+
+    @ApiOperation("飞书课程详情")
+    @GetMapping("/videoDetails")
+    public R getCourseVideoDetails(@RequestParam("token") String token,
+                                   @RequestHeader(value = "Identifier", required = false) Long identifier) {
+        Map<String, Object> params = accessCourse(token);
+        Long companyUserId = (Long) params.get("companyUserId");
+        Long videoId = (Long) params.get("videoId");
+
+        if (StringUtils.isNull(identifier)) {
+            return R.error("非法操作!");
+        }
+        return courseVideoService.getFeiShuLinkCourseVideoDetails(companyUserId, videoId, identifier);
+    }
+
+    @ApiOperation("更新看课时长")
+    @PostMapping("/updateWatchDuration")
+    public void updateWatchDuration(@Validated @RequestBody FeiShuUpdateWatchDurationParam param,
+                                    @RequestHeader(value = "Identifier", required = false) Long identifier) {
+        Map<String, Object> params = accessCourse(param.getToken());
+        Long companyUserId = (Long) params.get("companyUserId");
+        Long videoId = (Long) params.get("videoId");
+        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());
+    }
+
+    /**
+     * 安全获取课程信息
+     */
+    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);
+        }
+    }
+}

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

@@ -26,6 +26,7 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
     @Autowired
     RedisCache redisCache;
     public static final String USER_KEY = "userId";
+    private static final String SKIP_AUTH_HEADER = "X-Skip-Auth";
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
@@ -40,6 +41,12 @@ public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
             return true;
         }
 
+        // 请求头存在跳过认证标识,直接放行
+        String skipAuth = request.getHeader(SKIP_AUTH_HEADER);
+        if (StringUtils.isNotEmpty(skipAuth)) {
+            return true;
+        }
+
         //获取用户凭证
         String token = request.getHeader(jwtUtils.getHeader());
         if(StringUtils.isBlank(token)){
@@ -55,11 +62,13 @@ 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()));
+//        String redisToken=redisCache.getCacheObject("AiChatToken:"+ Long.parseLong(claims.getSubject()));
 //        if(redisToken==null||!redisToken.equals(token)){
 //            throw new FSException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
 //        }
+//        long l = Long.parseLong(claims.getSubject());
         //设置userId到request里,后续根据userId,获取用户信息
         request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));
 

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

+ 17 - 9
fs-user-app/src/main/java/com/fs/framework/aspectj/UserOperationLogAspect.java

@@ -9,6 +9,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.param.LoginMaWxParam;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.course.domain.FsCourseQuestionBank;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
@@ -49,6 +50,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() {}
@@ -106,15 +108,21 @@ public class UserOperationLogAspect {
             operationLog.setOperationType(annotation.operationType().getLabel());
 
             //用户
-            Long userId =null;
-            try {
-                userId = Long.valueOf(jwtUtils.getClaimByToken(ServletUtils.getRequest().getHeader("APPToken")).getSubject().toString());
-            } catch (Exception ie){
-                log.info("获取用户id失败");
-            }
-            if (userId == null) {
-                LOG_HOLDER.set(operationLog);
-                return;
+            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失败");
+                }
             }
 
             FsUser fsUser = userMapper.selectFsUserByUserId(userId);