Ver Fonte

红德堂-飞书看课

Long há 4 dias atrás
pai
commit
d44de7a292
22 ficheiros alterados com 1057 adições e 27 exclusões
  1. 13 4
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  2. 22 0
      fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  3. 6 10
      fs-service/pom.xml
  4. 1 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  5. 15 0
      fs-service/src/main/java/com/fs/course/constant/CourseConstant.java
  6. 94 0
      fs-service/src/main/java/com/fs/course/domain/FeiShuCourseWatchLog.java
  7. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java
  8. 6 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodDaysMapper.java
  9. 1 1
      fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoFinishUParam.java
  10. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  11. 16 2
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  12. 171 2
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  13. 247 8
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  14. 13 0
      fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java
  15. 147 0
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  16. 86 0
      fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java
  17. 5 0
      fs-service/src/main/resources/application-config-dev.yml
  18. 4 0
      fs-service/src/main/resources/application-config-druid-hdt.yml
  19. 28 0
      fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml
  20. 116 0
      fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java
  21. 19 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java
  22. 16 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java

+ 13 - 4
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -1,15 +1,14 @@
 package com.fs.app.controller;
 
 import cn.hutool.core.date.DateUtil;
-import cn.hutool.json.JSONUtil;
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.app.annotation.Login;
 import com.fs.app.config.ImageStorageConfig;
 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.ServiceException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyMiniapp;
@@ -17,7 +16,6 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
-import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsUserCoursePeriod;
 import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.dto.BatchUrgeCourseDTO;
@@ -34,7 +32,6 @@ import com.fs.course.vo.FsUserCourseParticipationRecordVO;
 import com.fs.course.vo.newfs.*;
 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;
@@ -403,4 +400,16 @@ public class FsUserCourseVideoController extends AppBaseController {
         return R.ok().put("data", appId);
     }
 
+    @Autowired
+    private FeiShuService feiShuService;
+
+    @Login
+    @ApiOperation("获取飞书看课链接")
+    @GetMapping("/getFeiShuCourseLink")
+    public R getFeiShuCourseLink(@RequestParam Long companyUserId,
+                                 @RequestParam Long videoId,
+                                 @RequestParam Long periodId) {
+        return R.ok().put("data", feiShuService.getFeishuCourseLink(companyUserId, videoId, periodId));
+    }
+
 }

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

@@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @Component
@@ -148,7 +149,28 @@ 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 - 10
fs-service/pom.xml

@@ -268,16 +268,6 @@
             <version>${org.mapstruct.version}</version>
         </dependency>
 
-        <dependency>
-            <groupId>org.mapstruct</groupId>
-            <artifactId>mapstruct</artifactId>
-            <version>${org.mapstruct.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.mapstruct</groupId>
-            <artifactId>mapstruct-processor</artifactId>
-            <version>${org.mapstruct.version}</version>
-        </dependency>
         <dependency>
             <groupId>com.hc</groupId>
             <artifactId>openapi</artifactId>
@@ -298,6 +288,12 @@
             <version>1.0.250</version>
         </dependency>
 
+        <!-- 飞书SDK -->
+        <dependency>
+            <groupId>com.larksuite.oapi</groupId>
+            <artifactId>oapi-sdk</artifactId>
+            <version>2.5.3</version>
+        </dependency>
 
     </dependencies>
 

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

@@ -19,6 +19,7 @@ public class CourseConfig implements Serializable {
     private Integer appAnswerIntegral; //app答题积分
     private Integer defaultLine;//默认看课线路
     private String realLinkDomainName;//真链域名
+    private String feishuLinkDomainName;//飞书看课域名
     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;
+}

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

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

@@ -124,4 +124,10 @@ public interface FsUserCoursePeriodDaysMapper extends BaseMapper<FsUserCoursePer
 
     @Select("SELECT distinct period_id from fs_user_course_period_days  where day_date >=#{periodSTime} and day_date <=#{periodETime} ")
     List<Long> selectFsUserCoursePeriodDaysByTime(@Param("periodSTime") String periodSTime,@Param("periodETime") String periodETime);
+
+    /**
+     * 查询营期视频
+     */
+    @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);
 }

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

@@ -25,5 +25,5 @@ public class FsUserCourseVideoFinishUParam implements Serializable {
     private Long periodId;
     private Integer projectId;
     private String appId; // 小程序AppId
-    private Integer typeFlag; //0 小程序 1 app
+    private Integer typeFlag; //0 小程序 1 app 2飞书看课
 }

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

@@ -178,4 +178,14 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      * @param projectId
      */
     R clearUserWatchLog(Long userId, Long projectId);
+
+    /**
+     * 检查飞书看课记录-完课
+     */
+    void scheduleFeiShuBatchUpdateToDatabase();
+
+    /**
+     * 检查飞书看课状态
+     */
+    void checkFeiShuWatchStatus();
 }

+ 16 - 2
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -1,6 +1,5 @@
 package com.fs.course.service;
 
-import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.course.domain.CompanyCourseRedpacket;
@@ -18,8 +17,8 @@ import com.fs.course.vo.newfs.FsUserVideoListVO;
 import com.fs.his.domain.FsUser;
 import com.fs.his.vo.OptionsVO;
 import com.fs.qw.param.FsUserCourseRedPageParam;
-import org.springframework.web.multipart.MultipartFile;
 
+import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
 
@@ -241,4 +240,19 @@ public interface IFsUserCourseVideoService
     R getVideoInfoByVid();
 
     void updateMediaPublishStatus(String vid);
+
+    /**
+     * 获取飞书课程详情
+     */
+    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);
 }

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

@@ -20,6 +20,7 @@ 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.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
@@ -50,7 +51,6 @@ import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.qw.param.SendSopParamDetails;
 import com.fs.qw.vo.QwSopCourseFinishTempSetting;
 import com.fs.qw.vo.QwSopTempSetting;
-import com.fs.qw.vo.QwWatchLogStatisticsListVO;
 import com.fs.sop.domain.QwSopLogs;
 import com.fs.sop.mapper.SopUserLogsMapper;
 import com.fs.store.service.cache.IFsUserCacheService;
@@ -60,10 +60,12 @@ import com.fs.system.service.ISysConfigService;
 import com.fs.tag.service.FsTagUpdateService;
 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;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
@@ -1982,5 +1984,172 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         }
     }
 
+    /**
+     * 检查飞书看课记录-完课
+     */
+    @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);
+        }
+    }
 
 }

+ 247 - 8
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -3,23 +3,17 @@ package com.fs.course.service.impl;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
-import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.fs.common.BeanCopyUtils;
-import com.fs.common.constant.FsConstants;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.enums.BizResponseEnum;
-import com.fs.common.exception.CustomException;
 import com.fs.common.exception.ServiceException;
-import com.fs.common.exception.base.BaseException;
 import com.fs.common.utils.CloudHostUtils;
 import com.fs.common.utils.DateUtils;
-import com.fs.common.utils.PubFun;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.date.DateUtil;
 import com.fs.company.constant.CompanyTrafficConstants;
@@ -33,6 +27,7 @@ import com.fs.company.util.CompanyRedPacketBalanceUtil;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.core.utils.OrderCodeUtils;
 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.*;
@@ -41,7 +36,6 @@ import com.fs.course.param.newfs.*;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.IFsUserCourseVideoService;
-import com.fs.course.service.IFsVideoResourceService;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
 import com.fs.his.config.AppConfig;
@@ -67,7 +61,6 @@ import com.fs.qwApi.Result.QwAddContactWayResult;
 import com.fs.qwApi.param.QwAddContactWayParam;
 import com.fs.qwApi.service.QwApiService;
 import com.fs.sop.domain.SopUserLogsInfo;
-import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.sop.mapper.SopUserLogsInfoMapper;
 import com.fs.sop.service.ISopUserLogsInfoService;
 import com.fs.system.mapper.SysDictDataMapper;
@@ -160,6 +153,8 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
     @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
+    private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
+    @Autowired
     private ISopUserLogsInfoService iSopUserLogsInfoService;
     @Autowired
     private FsCourseLinkMapper fsCourseLinkMapper;
@@ -3854,5 +3849,249 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService {
             }
         }
     }
+
+    /**
+     * 获取飞书课程详情
+     */
+    @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);
+        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
+        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;
+}

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

@@ -0,0 +1,147 @@
+package com.fs.feishu.service;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.feishu.config.FeiShuConfig;
+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.v2.model.PatchPermissionPublicReq;
+import com.lark.oapi.service.drive.v2.model.PermissionPublic;
+import org.springframework.beans.factory.annotation.Autowired;
+import javax.annotation.PostConstruct;
+import org.springframework.stereotype.Component;
+
+@Component
+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);
+        }
+    }
+
+    /**
+     * 创建云文档
+     */
+    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);
+    }
+
+    /**
+     * 创建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);
+    }
+
+    /**
+     * 修改文档权限
+     */
+    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);
+    }
+}

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

@@ -0,0 +1,86 @@
+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);
+        }
+    }
+
+    /**
+     * 加密数据
+     */
+    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);
+    }
+    
+    /**
+     * 解析安全令牌
+     */
+    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]));
+            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));
+    }
+}

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

@@ -127,3 +127,8 @@ 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-config-druid-hdt.yml

@@ -101,3 +101,7 @@ wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:
 
+# 飞书
+feishu:
+  appId: "cli_a92ff75ce0785bc0"
+  appSecret: "DCQPW11pTY48zSdwAnqPwhJfihe0kP8G"

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

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

@@ -0,0 +1,116 @@
+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");
+        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());
+    }
+
+    /**
+     * 安全获取课程信息
+     */
+    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);
+        }
+    }
+}

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