Bläddra i källkod

飞书自动发课

xw 1 dag sedan
förälder
incheckning
b2094053b1
29 ändrade filer med 1751 tillägg och 18 borttagningar
  1. 4 0
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  2. 52 0
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  3. 6 0
      fs-qwhook-sop/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  4. 8 0
      fs-service/pom.xml
  5. 4 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  6. 15 0
      fs-service/src/main/java/com/fs/course/constant/CourseConstant.java
  7. 94 0
      fs-service/src/main/java/com/fs/course/domain/FeiShuCourseWatchLog.java
  8. 21 0
      fs-service/src/main/java/com/fs/course/mapper/FeiShuCourseWatchLogMapper.java
  9. 17 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  10. 292 15
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  11. 13 0
      fs-service/src/main/java/com/fs/feishu/config/FeiShuConfig.java
  12. 18 0
      fs-service/src/main/java/com/fs/feishu/domain/FeishuAccount.java
  13. 22 0
      fs-service/src/main/java/com/fs/feishu/mapper/FeishuAccountMapper.java
  14. 16 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuFileDTO.java
  15. 41 0
      fs-service/src/main/java/com/fs/feishu/model/FeishuLinkParam.java
  16. 215 0
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  17. 139 0
      fs-service/src/main/java/com/fs/feishu/service/FeishuClientPool.java
  18. 211 0
      fs-service/src/main/java/com/fs/feishu/service/FeishuDocApiService.java
  19. 102 0
      fs-service/src/main/java/com/fs/feishu/util/FeishuErrorCode.java
  20. 99 0
      fs-service/src/main/java/com/fs/feishu/util/SecureTokenUtil.java
  21. 146 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  22. 28 0
      fs-service/src/main/resources/mapper/course/FeiShuCourseWatchLogMapper.xml
  23. 15 0
      fs-service/src/main/resources/mapper/his/FeishuAccountMapper.xml
  24. 5 3
      fs-user-app/src/main/java/com/fs/app/controller/AppBaseController.java
  25. 14 0
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java
  26. 113 0
      fs-user-app/src/main/java/com/fs/app/controller/course/FeiShuCourseController.java
  27. 6 0
      fs-user-app/src/main/java/com/fs/app/interceptor/AuthorizationInterceptor.java
  28. 19 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuGetInternetTrafficParam.java
  29. 16 0
      fs-user-app/src/main/java/com/fs/app/param/FeiShuUpdateWatchDurationParam.java

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

@@ -854,6 +854,10 @@ public class IpadSendServer {
                     // 链接
                     sendLink(vo, content);
                     break;
+                case "17":
+                    // 飞书看课
+                    sendLink(vo, content);
+                    break;
                 case "4":
                 case "10":
                 case "13":

+ 52 - 0
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -25,6 +25,7 @@ import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.live.domain.LiveWatchLog;
 import com.fs.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
@@ -87,6 +88,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
     private static final String miniappRealLink = "/pages_course/video.html?course=";
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
     private static final String appRealLink = "/pages/courseAnswer/index?link=";
     private static final String appLink = "https://jump.ylrztop.com/jumpapp/pages/index/index?link=";
 
@@ -140,6 +142,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private FeiShuService feiShuService;
+
     @Autowired
     private FsCourseDomainNameMapper fsCourseDomainNameMapper;
 
@@ -1587,6 +1592,23 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         logVo.getSopId(), setting.getContentType(), setting.getMiniType(), setting.getMiniprogramAppid(), setting.getMiniprogramPage(),
                         setting.getMiniprogramTitle(), setting.getMiniprogramPicUrl(), setting.getArticleUrl());
 
+                    break;
+                case "17":
+                    //飞书看课链接
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                    Long externalIdLong = Long.parseLong(externalId);
+
+                    addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo, 2);
+
+                    Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
+                            qwUserId, companyUserId, companyId, externalIdLong, cachedCourseConfig);
+                    String shortLink = feiShuLinkMap.get("link");
+
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyIdLong, courseId, companyUserIdLong, shortLink);
+                    if (StringUtils.isNotEmpty(feishuLink)) {
+                        setting.setLinkUrl(feishuLink);
+                    }
                     break;
                 default:
                     break;
@@ -1882,6 +1904,36 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         return updateTime;
     }
 
+    /**
+     * 生成飞书看课课程短链
+     */
+    private Map<String, String> createFeiShuLinkByMiniApp(QwSopTempSetting.Content.Setting setting, String corpId, Date sendTime,
+                                                        Long courseId, Long videoId, String qwUserId,
+                                                        String companyUserId, String companyId, Long externalId, CourseConfig config) {
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId.toString(), 3);
+
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+
+        String courseJson = JSON.toJSONString(courseMap);
+
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+
+        Date updateTime = createUpdateTime(setting, sendTime, config);
+
+        link.setUpdateTime(updateTime);
+        enqueueCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
+
     private String createLinkByMiniApp(QwSopTempSetting.Content.Setting setting, SopUserLogsVo logVo, Date sendTime,
                                        Long courseId, Long videoId, String qwUserId,
                                        String companyUserId, String companyId, String externalId,String isOfficial,Long fsUserId, String chatId) {

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

@@ -122,6 +122,12 @@ public class FsUserCourseVideoController {
         return fsUserCourseVideoService.createRoomMiniLink(param);
     }
 
+    @GetMapping("/createRoomLinkFeishu")
+    @ApiOperation("创建发群课程链接--飞书")
+    public R createRoomLinkByFeishu(FsCourseLinkMiniParam param) {
+        return fsUserCourseVideoService.createCartLinkByFeiShu(param);
+    }
+
     /**
      * 创建 发客户小程序
      */

+ 8 - 0
fs-service/pom.xml

@@ -297,6 +297,14 @@
             <artifactId>volc-sdk-java</artifactId>
             <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>
         <dependency>
             <groupId>org.springframework.retry</groupId>
             <artifactId>spring-retry</artifactId>

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

@@ -20,6 +20,10 @@ public class CourseConfig implements Serializable {
     private Integer appAnswerIntegral; //app答题积分
     private Integer defaultLine;//默认看课线路
     private String realLinkDomainName;//真链域名
+    private String feishuCourseDomain;//飞书看课域名
+    private String feishuAuthDomain;//飞书授权域名
+    private Boolean enableFeishuNewLink;//是否启用飞书新跳转
+    private String realLinkGjDomainName;//外网通用看课链接域名
     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) {
+        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;
+}

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

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

@@ -184,6 +184,8 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
 
     R createMiniLink(FsCourseLinkMiniParam param);
 
+    R createCartLinkByFeiShu(FsCourseLinkMiniParam param);
+
     R createCartLink(FsCourseLinkMiniParam param);
 
     /**
@@ -288,4 +290,19 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
     List<OptionsVO> selectVideoOptionsByCourseId(Long courseId);
 
     R withdrawal(FsCourseSendRewardUParam param);
+
+    /**
+     * 获取飞书课程详情
+     */
+    R getFeiShuLinkCourseVideoDetails(Long companyUserId, Long videoId, Long identifier);
+
+    /**
+     * 更新飞书看课时长
+     */
+    void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long identifier, Long duration);
+
+    /**
+     * 飞书看课流量统计
+     */
+    void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, String uuid, java.math.BigDecimal bufferRate);
 }

+ 292 - 15
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -31,6 +31,7 @@ import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.company.service.ICompanyService;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.constant.CourseConstant;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePeriodSyncResultDTO;
 import com.fs.course.dto.CoursePackageDTO;
@@ -50,6 +51,7 @@ import com.fs.course.param.newfs.*;
 import com.fs.course.service.*;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.*;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.enums.ExceptionCodeEnum;
 import com.fs.his.config.AppConfig;
 import com.fs.his.domain.FsUser;
@@ -147,6 +149,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     private static final Logger logger = LoggerFactory.getLogger(FsUserCourseVideoServiceImpl.class);
 
     private static final String miniappRealLink = "/pages_course/video.html?course=";
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
     private static final String REAL_LINK_PREFIX = "/courseH5/pages/course/learning?course=";
     private static final String SHORT_LINK_PREFIX = "/courseH5/pages/course/learning?s=";
     // 排除看课数量限制的公司集合
@@ -176,6 +179,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private FsCourseWatchLogMapper courseWatchLogMapper;
     @Autowired
+    private FeiShuCourseWatchLogMapper feishuCourseWatchLogMapper;
+    @Autowired
     private IFsCourseWatchLogService courseWatchLogService;
     @Autowired
     private ISopUserLogsInfoService iSopUserLogsInfoService;
@@ -284,6 +289,9 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private RedisTemplate redisTemplate;
 
+    @Autowired
+    private FeiShuService feiShuService;
+
 
     @Autowired
     private IFsCourseLinkService linkService;
@@ -955,22 +963,17 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         if (qwGroupChatUsers == null || qwGroupChatUsers.isEmpty()) {
             return R.error(ExceptionCodeEnum.GROUP_PARAM_ERROR.getCode(), ExceptionCodeEnum.GROUP_PARAM_ERROR.getDescription());
         }
-        QwExternalContact qwExternalContact =  qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
-                            .eq("user_id", qwGroupChat.getOwner())
-                            .eq("fs_user_id", param.getUserId())
-                            .eq("corp_id", param.getCorpId()));
+        QwExternalContact qwExternalContact = selectValidQwExternalContact(new QueryWrapper<QwExternalContact>()
+                .eq("user_id", qwGroupChat.getOwner())
+                .eq("fs_user_id", param.getUserId())
+                .eq("corp_id", param.getCorpId()));
 
-        if(null == qwExternalContact){
-            try{
-                //修改成通过昵称匹配
-                qwExternalContact =
-                        qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
-                                .eq("user_id", qwGroupChat.getOwner())
-                                .eq("name", user.getNickName())
-                                .eq("corp_id", param.getCorpId()));
-            } catch(Exception e){
-                log.error("群聊用户昵称匹配异常,参数user_id:{},name:{},corp_id:{}",qwGroupChat.getOwner(),user.getNickName(),param.getCorpId(),e);
-            }
+        if (qwExternalContact == null) {
+            // 通过昵称匹配
+            qwExternalContact = selectValidQwExternalContact(new QueryWrapper<QwExternalContact>()
+                    .eq("user_id", qwGroupChat.getOwner())
+                    .eq("name", user.getNickName())
+                    .eq("corp_id", param.getCorpId()));
         }
         if(qwExternalContact==null){
             return R.error(noRegisterMsg);
@@ -1044,6 +1047,18 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
         return R.error(567, "群聊通用链接").put("qwExternalId", qwExternalContact.getId());
     }
+
+    /**
+     * 查询有效状态的企微外部联系人(0正常 1待接替 2接替中),排除删除/流失;多条时优先正常状态、最新记录
+     */
+    private QwExternalContact selectValidQwExternalContact(QueryWrapper<QwExternalContact> queryWrapper) {
+        List<QwExternalContact> list = qwExternalContactMapper.selectList(
+                queryWrapper.in("status", Arrays.asList(0, 1, 2))
+                        .orderByAsc("status")
+                        .orderByDesc("create_time"));
+        return CollectionUtils.isEmpty(list) ? null : list.get(0);
+    }
+
     private R handleRoom(FsUserCourseVideoAddKfUParam param,FsUser user,String noRegisterMsg) {
         //查询客户列表
         List<QwExternalContact> contacts = qwExternalContactMapper.selectQwExternalContactListVOByfsUserId(user.getUserId());
@@ -3560,6 +3575,53 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     }
 
+    @Override
+    public R createCartLinkByFeiShu(FsCourseLinkMiniParam param) {
+
+        QwUser qwUser = qwExternalContactService.getQwUserByRedis(param.getCorpId().trim(), param.getQwUserId().trim());
+
+        if (qwUser == null || qwUser.getCompanyId() == null || qwUser.getCompanyUserId() == null) {
+            return R.error("员工未绑定 销售公司 或 销售 请先绑定");
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(param.getCorpId());
+
+        if (qwCompany == null) {
+            return R.error().put("msg", "企业不存在,请联系管理员");
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSON.parseObject(json, CourseConfig.class);
+
+        if (config == null) {
+            return R.error().put("msg", "课程默认配置为空,请联系管理员");
+        }
+
+        Long courseId = param.getCourseId();
+        Long qwUserId = qwUser.getId();
+        Date createTime = DateUtils.getNowDate();
+        Long externalUserId = param.getExternalUserId();
+        Long videoId = param.getVideoId();
+        Long companyId = qwUser.getCompanyId();
+        Long companyUserId = qwUser.getCompanyUserId();
+
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, param.getExternalUserId(), 2);
+
+        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config);
+        String shortCode = feishuH5Link.get("link");
+
+        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortCode);
+
+        JSONObject news = new JSONObject(true);
+        news.put("linkTitle", param.getTitle());
+        news.put("linkDescribe", param.getTitle());
+        news.put("linkImageUrl", config.getSidebarImageUrl());
+        news.put("linkUrl", feishuLink);
+
+        return R.ok().put("data", news);
+
+    }
+
     //插入观看记录
     private void addWatchLogIfNeeded(Long videoId, Long courseId,
                                      Long fsUserId, QwUser qwUser, Long externalId,Integer watchType) {
@@ -5316,6 +5378,221 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         return Objects.equals(user.getRedStatus(), 1);
     }
 
+    @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, "请观看最新的课程项目");
+        }
+
+        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);
+        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);
+        }
+
+        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);
+        fsUserCourseVideoDetailsVO.setVideoUrl(fsUserCourseVideo.getLineOne());
+
+        String questionBankId = fsUserCourseVideo.getQuestionBankId();
+        List<FsUserVideoQuestionVO> questionList = Collections.emptyList();
+        if (StringUtils.isNotEmpty(questionBankId)) {
+            String[] questionBankIds = questionBankId.split(",");
+            List<FsCourseQuestionBank> fsCourseQuestionBanks = courseQuestionBankMapper.selectFsCourseQuestionBankByIdVO(questionBankIds);
+            questionList = fsCourseQuestionBanks.stream().map(v -> {
+                FsUserVideoQuestionVO fsUserVideoQuestionVO = new FsUserVideoQuestionVO();
+                BeanUtils.copyProperties(v, fsUserVideoQuestionVO);
+                return fsUserVideoQuestionVO;
+            }).collect(Collectors.toList());
+        }
+
+        fsUserCourseVideoDetailsVO.setQuestionBankList(questionList);
+        return fsUserCourseVideoDetailsVO;
+    }
+
+    @Override
+    public void feiShuUpdateWatchDurationWx(Long companyUserId, Long videoId, Long identifier, Long duration) {
+        Long videoDuration = getAutoLookVideoDuration(videoId);
+        if (duration > videoDuration + 10) {
+            return;
+        }
+
+        String redisKey = CourseConstant.getFeiShuWatchDurationKey(identifier, videoId, companyUserId);
+        Long userLookDuration = redisCache.getCacheObject(redisKey);
+
+        if (duration > (userLookDuration != null ? userLookDuration : 0) + 90) {
+            return;
+        }
+        redisCache.setCacheObject(redisKey, duration, 2, TimeUnit.HOURS);
+
+        String heartRedisKey = CourseConstant.getFeiShuWatchHeartKey(identifier, videoId, companyUserId);
+        redisCache.setCacheObject(heartRedisKey, LocalDateTime.now().toString(), 5, TimeUnit.MINUTES);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public void getFeiShuInternetTraffic(Long identifier, Long companyUserId, Long videoId, String uuid, BigDecimal bufferRate) {
+        if (StringUtils.isBlank(uuid)) {
+            return;
+        }
+
+        FsCourseTrafficLog trafficLog = new FsCourseTrafficLog();
+        trafficLog.setCreateTime(new Date());
+        trafficLog.setTypeFlag(2);
+        trafficLog.setUuId(uuid);
+        trafficLog.setUserId(identifier);
+        trafficLog.setVideoId(videoId);
+        trafficLog.setCompanyUserId(companyUserId);
+
+        FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (video == null) {
+            return;
+        }
+
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
+        if (companyUser == null) {
+            return;
+        }
+
+        Company company = companyMapper.selectCompanyById(companyUser.getCompanyId());
+        if (company == null) {
+            return;
+        }
+
+        trafficLog.setCompanyId(company.getCompanyId());
+        trafficLog.setCourseId(video.getCourseId());
+
+        BigDecimal result = bufferRate.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
+        BigDecimal longAsBigDecimal = BigDecimal.valueOf(video.getFileSize());
+        long roundedResult = result.multiply(longAsBigDecimal).setScale(0, RoundingMode.HALF_UP).longValue();
+        trafficLog.setInternetTraffic(roundedResult);
+
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(video.getCourseId());
+        if (fsUserCourse != null) {
+            trafficLog.setProject(fsUserCourse.getProject());
+        }
+
+        fsCourseTrafficLogMapper.insertOrUpdateTrafficLog(trafficLog);
+        asyncDeductTraffic(company, trafficLog);
+    }
+
+    private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
+                                                   Integer courseId, Integer videoId, String qwUserId,
+                                                   Long companyUserId, Long companyId, Long externalId, CourseConfig config) {
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+        LocalDate tomorrow = LocalDate.now().plusDays(1);
+        LocalDateTime expireDateTime = LocalDateTime.of(tomorrow, LocalTime.of(23, 59, 59));
+        Date expireDate = Date.from(expireDateTime.atZone(ZoneId.systemDefault()).toInstant());
+        link.setUpdateTime(expireDate);
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
+
+    public FsCourseLink createFsCourseLink(String corpId, Date sendTime, Integer courseId, Integer videoId, Long qwUserId,
+                                           Long companyUserId, Long companyId, Long externalId, Integer type, String chatId) {
+        FsCourseLink link = new FsCourseLink();
+        link.setCompanyId(companyId);
+        link.setQwUserId(qwUserId);
+        link.setCompanyUserId(companyUserId);
+        link.setVideoId(videoId.longValue());
+        link.setCorpId(corpId);
+        link.setCourseId(courseId.longValue());
+        link.setChatId(chatId);
+        link.setQwExternalId(externalId);
+        link.setLinkType(type);
+        link.setIsRoom(1);
+        link.setUNo(UUID.randomUUID().toString());
+        String randomString = generateRandomStringWithLock();
+        if (StringUtil.strIsNullOrEmpty(randomString)) {
+            link.setLink(UUID.randomUUID().toString().replace("-", ""));
+        } else {
+            link.setLink(randomString);
+        }
+        link.setCreateTime(sendTime);
+        link.setProjectCode(cloudHostProper.getProjectCode());
+        return link;
+    }
+
 
 }
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 102 - 0
fs-service/src/main/java/com/fs/feishu/util/FeishuErrorCode.java

@@ -0,0 +1,102 @@
+package com.fs.feishu.util;
+
+import lombok.Getter;
+
+/**
+ * 飞书错误码枚举,按处理策略分类
+ */
+@Getter
+public enum FeishuErrorCode {
+
+    // ========== 账号级错误(需要禁用应用) ==========
+    ACCOUNT_DISABLE_10012(10012, ErrorCategory.ACCOUNT_DISABLE, "获取App Token失败"),
+    ACCOUNT_DISABLE_10014(10014, ErrorCategory.ACCOUNT_DISABLE, "应用状态不可用"),
+    ACCOUNT_DISABLE_10015(10015, ErrorCategory.ACCOUNT_DISABLE, "App Secret错误"),
+    ACCOUNT_DISABLE_99991662(99991662, ErrorCategory.ACCOUNT_DISABLE, "应用已停用"),
+    ACCOUNT_DISABLE_99991673(99991673, ErrorCategory.ACCOUNT_DISABLE, "应用未授权"),
+
+    // ========== 频率限制错误 ==========
+    RATE_LIMIT_99991400(99991400, ErrorCategory.RATE_LIMIT, "请求频率超限"),
+    RATE_LIMIT_1000004(1000004, ErrorCategory.RATE_LIMIT, "接口限流"),
+    RATE_LIMIT_1000005(1000005, ErrorCategory.RATE_LIMIT, "应用限流"),
+    RATE_LIMIT_11232(11232, ErrorCategory.RATE_LIMIT, "创建消息触发系统限流"),
+    RATE_LIMIT_11233(11233, ErrorCategory.RATE_LIMIT, "创建消息触发群限流"),
+    RATE_LIMIT_11247(11247, ErrorCategory.RATE_LIMIT, "内部发送消息触发频控"),
+
+    // ========== 内部可重试错误 ==========
+    INTERNAL_RETRY_1500(1500, ErrorCategory.INTERNAL_RETRYABLE, "内部错误"),
+    INTERNAL_RETRY_1503(1503, ErrorCategory.INTERNAL_RETRYABLE, "Token鉴权内部错误"),
+    INTERNAL_RETRY_1551(1551, ErrorCategory.INTERNAL_RETRYABLE, "获取tenant id错误"),
+    INTERNAL_RETRY_1642(1642, ErrorCategory.INTERNAL_RETRYABLE, "更新session失败"),
+    INTERNAL_RETRY_1663(1663, ErrorCategory.INTERNAL_RETRYABLE, "内部错误"),
+    INTERNAL_RETRY_1665(1665, ErrorCategory.INTERNAL_RETRYABLE, "内部错误"),
+    INTERNAL_RETRY_1668(1668, ErrorCategory.INTERNAL_RETRYABLE, "内部错误"),
+    INTERNAL_RETRY_2200(2200, ErrorCategory.INTERNAL_RETRYABLE, "内部服务错误"),
+    INTERNAL_RETRY_5000(5000, ErrorCategory.INTERNAL_RETRYABLE, "内部错误"),
+    INTERNAL_RETRY_55001(55001, ErrorCategory.INTERNAL_RETRYABLE, "服务内部错误"),
+    INTERNAL_RETRY_10500(10500, ErrorCategory.INTERNAL_RETRYABLE, "服务端异常"),
+    INTERNAL_RETRY_99991663(99991663, ErrorCategory.INTERNAL_RETRYABLE, "无效token"),
+
+    // ========== 常见业务错误(不可重试) ==========
+    BUSINESS_ERROR_600(600, ErrorCategory.BUSINESS_ERROR, "资源被删除"),
+    BUSINESS_ERROR_1002(1002, ErrorCategory.BUSINESS_ERROR, "文档已删除"),
+    BUSINESS_ERROR_4000(4000, ErrorCategory.BUSINESS_ERROR, "请求参数错误"),
+    BUSINESS_ERROR_4001(4001, ErrorCategory.BUSINESS_ERROR, "Token无效"),
+    BUSINESS_ERROR_4004(4004, ErrorCategory.BUSINESS_ERROR, "用户不存在"),
+    BUSINESS_ERROR_10001(10001, ErrorCategory.BUSINESS_ERROR, "请求参数无效"),
+    BUSINESS_ERROR_10006(10006, ErrorCategory.BUSINESS_ERROR, "资源不存在"),
+
+    // 未知错误
+    UNKNOWN(-1, ErrorCategory.UNKNOWN, "未知错误");
+
+    private final int code;
+    private final ErrorCategory category;
+    private final String defaultMsg;
+
+    FeishuErrorCode(int code, ErrorCategory category, String defaultMsg) {
+        this.code = code;
+        this.category = category;
+        this.defaultMsg = defaultMsg;
+    }
+
+    /**
+     * 根据错误码查找对应枚举,若未找到则返回 UNKNOWN
+     */
+    public static FeishuErrorCode fromCode(int code) {
+        for (FeishuErrorCode e : values()) {
+            if (e.code == code) {
+                return e;
+            }
+        }
+        return UNKNOWN;
+    }
+
+    /**
+     * 是否为账号级错误(需要禁用应用)
+     */
+    public static boolean isAccountDisable(int code) {
+        return fromCode(code).getCategory() == ErrorCategory.ACCOUNT_DISABLE;
+    }
+
+    /**
+     * 是否为频率限制错误
+     */
+    public static boolean isRateLimit(int code) {
+        return fromCode(code).getCategory() == ErrorCategory.RATE_LIMIT;
+    }
+
+    /**
+     * 是否为内部可重试错误
+     */
+    public static boolean isInternalRetryable(int code) {
+        return fromCode(code).getCategory() == ErrorCategory.INTERNAL_RETRYABLE;
+    }
+
+    public enum ErrorCategory {
+        ACCOUNT_DISABLE,   // 账号级错误,需禁用
+        RATE_LIMIT,        // 频率限制,需等待后重试
+        INTERNAL_RETRYABLE,// 内部错误,可重试
+        BUSINESS_ERROR,    // 业务错误,不重试
+        UNKNOWN            // 未知
+    }
+}

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

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

+ 146 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -32,6 +32,7 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.live.domain.LiveWatchLog;
@@ -102,6 +103,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String appActivitlLink = "/pages_course/activity.html?link=";
     private static final String appLink = "https://jump.ylrztop.com/jumpapp/pages/index/index?link=";
     private static final String registeredRealLink = "/pages_course/register.html?link=";
+    private static final String feishuMiniappLink = "/pages_course/video?course=";
 //    private static final String miniappRealLink = "/pages/index/index?course=";
 
     @Autowired
@@ -200,6 +202,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private IQwSopLogsService qwSopLogsService;
 
+    @Autowired
+    private FeiShuService feiShuService;
 
 
     @Override
@@ -750,6 +754,26 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             case "16":
                                 createVoiceUrl(st, companyUserId, qwSop);
                                 break;
+                            //飞书看课
+                            case "17":
+                                Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                Long companyIdLong = qwUser.getCompanyId();
+                                Long externalUserId = vo.getId();
+                                Long companyUserIdLong = Long.parseLong(companyUserId);
+                                if (videoId18 == null) {
+                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                    break;
+                                }
+                                Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
+                                String shortCode = feishuH5Link.get("link");
+                                String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode);
+                                if (StringUtils.isNotEmpty(feishuLink)) {
+                                    st.setLinkUrl(feishuLink);
+                                } else {
+                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                                }
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -958,6 +982,26 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                 }
                                 break;
+                            //飞书看课
+                            case "17":
+                                Integer videoId18Group = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseId18Group = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                Long companyIdLongGroup = Long.valueOf(qwUser.getCompanyId().toString());
+                                Long externalUserIdGroup = Long.valueOf(groupChat.getChatId());
+                                Long companyUserIdLongGroup = Long.valueOf(qwUser.getCompanyUserId().toString());
+                                if (videoId18Group == null) {
+                                    log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                    break;
+                                }
+                                Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), externalUserIdGroup, config);
+                                String shortCodeGroup = feishuH5LinkGroup.get("link");
+                                String feishuLinkGroup = feiShuService.getFeishuRegisterLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup);
+                                if (StringUtils.isNotEmpty(feishuLinkGroup)) {
+                                    st.setLinkUrl(feishuLinkGroup);
+                                } else {
+                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                                }
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -1265,6 +1309,40 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 throw new RuntimeException(e);
                             }
                             break;
+                        //飞书看课
+                        case "17":
+                            Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                            Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                            Long companyIdLong = Long.parseLong(companyId);
+                            Long companyUserIdLong = Long.parseLong(companyUserId);
+                            if (videoId18 == null) {
+                                log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
+                                break;
+                            }
+                            addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime, 2);
+                            String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                            String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
+                            if (StringUtils.isNotEmpty(st.getLinkTitle())) {
+                                st.setLinkTitle(st.getLinkTitle()
+                                        .replaceAll("#销售称呼#", txt18)
+                                        .replaceAll("#客户称呼#", customerTitle));
+                            }
+                            if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                                st.setLinkDescribe(st.getLinkDescribe()
+                                        .replaceAll("#销售称呼#", txt18)
+                                        .replaceAll("#客户称呼#", customerTitle));
+                            }
+                            Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
+                            String shortCode = feishuH5Link.get("link");
+                            String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode);
+                            if (StringUtils.isNotEmpty(feishuLink)) {
+                                st.setLinkUrl(feishuLink);
+                            } else {
+                                log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
+                            }
+                            break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         case "11":
                             log.warn("群公告不能发给个人,跳过处理,sopId:{}, externalId:{}", param.getSopId(), item.getExternalId());
@@ -1856,6 +1934,50 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     log.info("处理订阅号文章类型(processSetting),保留字段: articleUrl={}, miniprogramPicUrl={}, miniprogramPage={}",
                             st.getArticleUrl(), st.getMiniprogramPicUrl(), st.getMiniprogramPage());
                     break;
+                //飞书看课
+                case "17":
+                    Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                    Integer courseId18 = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                    Long externalId = item.getExternalId();
+                    if (videoId18 == null) {
+                        log.error("飞书链接生成失败:视频ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (courseId18 == null) {
+                        log.error("飞书链接生成失败:课程ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (externalId == null) {
+                        log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime, 2);
+                    Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
+                    String shortLink = feishuH5Link.get("link");
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortLink);
+                    if (StringUtils.isNotEmpty(feishuLink)) {
+                        String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                        String customerTitle = contact == null ? "同学" :
+                                (StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus());
+                        if (StringUtils.isNotEmpty(st.getLinkTitle())) {
+                            st.setLinkTitle(st.getLinkTitle()
+                                    .replaceAll("#销售称呼#", txt18)
+                                    .replaceAll("#客户称呼#", customerTitle));
+                        }
+                        if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                            st.setLinkDescribe(st.getLinkDescribe()
+                                    .replaceAll("#销售称呼#", txt18)
+                                    .replaceAll("#客户称呼#", customerTitle));
+                        }
+                        st.setLinkUrl(feishuLink);
+                    } else {
+                        log.error("生成飞书注册链接失败,sopId={}", item.getSopId());
+                    }
+                    break;
 
                 default:
                     break;
@@ -1866,6 +1988,30 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return  list;
     }
 
+    /**
+     * 生成飞书看课课程短链
+     */
+    private Map<String, String> createFeishuH5Link(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                                   Integer courseId, Integer videoId, String qwUserId,
+                                                   String companyUserId, String companyId, Long externalId, CourseConfig config) {
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
+                companyUserId, companyId, externalId, 3, null);
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        String realLinkFull = feishuMiniappLink + courseJson;
+        if (StringUtils.isNotEmpty(config.getRealLinkGjDomainName())) {
+            realLinkFull = config.getRealLinkGjDomainName() + realLinkFull;
+        }
+        link.setRealLink(realLinkFull);
+        link.setUpdateTime(createUpdateTime(setting, sendTime, config));
+        fsCourseLinkMapper.insertFsCourseLink(link);
+        Map<String, String> result = new HashMap<>();
+        result.put("url", link.getRealLink());
+        result.put("link", link.getLink());
+        return result;
+    }
+
     public String createActivityLinkByMiniApp(QwSopCourseFinishTempSetting.Setting st, QwSopLogs sopLogs, String corpId, Date sendTime, Integer courseId, Integer videoId, String qwUserId, String companyUserId, String companyId, Long externalId, CourseConfig config, String chatId) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, null, 3, chatId);

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

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

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

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

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

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

+ 14 - 0
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -26,6 +26,8 @@ import com.fs.course.param.newfs.FsUserCourseVideoUParam;
 import com.fs.course.service.*;
 import com.fs.course.vo.FsUserCourseVideoH5VO;
 import com.fs.course.vo.newfs.FsUserCourseVideoLinkDetailsVO;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.enums.FsUserOperationEnum;
 import com.fs.his.service.IFsUserService;
@@ -68,6 +70,9 @@ public class CourseFsUserController extends AppBaseController {
     @Autowired
     private IFsCourseQuestionBankService questionBankService;
 
+    @Autowired
+    private FeiShuService feiShuService;
+
     @Autowired
     private RedisTemplate redisTemplate;
 
@@ -210,6 +215,15 @@ public class CourseFsUserController extends AppBaseController {
         logger.error("zyp \n【h5看课中途报错】:{}",msg);
     }
 
+    @ApiOperation("获取飞书课程链接")
+    @PostMapping("/getFeiShuCourseNewLink")
+    public R getFeiShuCourseNewLink(@RequestBody FeishuLinkParam param) {
+        if (StringUtils.isBlank(param.getLink())){
+            return R.error("飞书课程短链不能为空");
+        }
+        String feishuCourseNewLink = feiShuService.getFeishuCourseNewLink(param);
+        return R.ok().put("data", feishuCourseNewLink);
+    }
 
     @Login
     @ApiOperation("发送红包(以积分提现的形式)")

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

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

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

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

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