cgp 5 дней назад
Родитель
Сommit
46f390d777

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

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

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

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

+ 11 - 4
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -1214,15 +1214,22 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
                 case "18" :
                     //飞书看课链接
+                    Long companyIdLong = Long.parseLong(companyId);
+                    Long companyUserIdLong = Long.parseLong(companyUserId);
+                    Long externalIdLong = Long.parseLong(externalId);
+
+                    //1.添加待看课记录
                     addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo);
 
+                    //2.创建飞书课程短链
                     Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
-                            qwUserId, companyUserId, companyId, Long.parseLong(externalId), cachedCourseConfig);
+                            qwUserId, companyUserId, companyId, externalIdLong, cachedCourseConfig);
                     //课程短码
-                    String shortCode = feiShuLinkMap.get("link");
-                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId,Long.valueOf(companyId),courseId,Long.valueOf(companyUserId),shortCode);
+                    String shortLink = feiShuLinkMap.get("link");
+
+                    //3.调用生成飞书注册授权链接
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId,companyIdLong,courseId,companyUserIdLong,shortLink);
                     if (StringUtils.isNotEmpty(feishuLink)) {
-                        setting.setFeiShuLinkUrl(feishuLink);
                         setting.setLinkUrl(feishuLink);
                     }
                     break;

+ 2 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -93,6 +93,7 @@ import org.springframework.beans.BeanUtils;
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -264,6 +265,7 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
     private RedPacketMapper redPacketMapper;
 
     @Autowired
+    @Lazy
     private XiaoShouYiTrackLinkCacheService xiaoShouYiTrackLinkCacheService;
 
     @Autowired

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

+ 107 - 316
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -1,159 +1,145 @@
 package com.fs.feishu.service;
 
 import cn.hutool.json.JSONUtil;
-import com.fs.course.domain.FsCourseLink;
-import com.fs.course.mapper.FsCourseLinkMapper;
-import com.fs.feishu.config.FeiShuConfig;
-import com.fs.feishu.model.FeishuFileDTO;
-import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.CustomException;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsCourseLink;
 import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
+import com.fs.feishu.model.FeishuLinkParam;
 import com.fs.system.service.ISysConfigService;
 import com.lark.oapi.Client;
-import com.lark.oapi.service.docx.v1.model.*;
-import com.lark.oapi.service.drive.v1.model.DeleteFileReq;
-import com.lark.oapi.service.drive.v1.model.DeleteFileResp;
-import com.lark.oapi.service.drive.v1.model.ListFileReq;
-import com.lark.oapi.service.drive.v1.model.ListFileResp;
-import com.lark.oapi.service.drive.v2.model.PatchPermissionPublicReq;
-import com.lark.oapi.service.drive.v2.model.PermissionPublic;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
-import javax.annotation.PostConstruct;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.util.*;
-
 import org.springframework.stereotype.Component;
 
+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 FeiShuConfig feishuConfig;
     @Autowired
     private ISysConfigService configService;
     @Autowired
     private FsUserCourseVideoMapper videoMapper;
-
     @Autowired
     private FsCourseLinkMapper courseLinkMapper;
-
-    private Client client;
-
-    @PostConstruct
-    public void init() {
-        this.client = Client.newBuilder(feishuConfig.getAppId(), feishuConfig.getAppSecret()).logReqAtDebug(true).build();
-    }
+    @Autowired
+    private RedisCache redisCache;
+    @Autowired
+    private FeishuClientPool clientPool;
+    @Autowired
+    private FeishuDocApiService docApiService;
 
     /**
-     * 001 生成飞书初始链接
+     * 001 生成飞书初始链接(注册授权链接)
+     * 每次 shortCode 唯一
      */
-    public String getFeishuRegisterLink(Long videoId, Long companyId, Long courseId,Long companyUserId,String shortCode) {
-        // 读取配置判断是否启用飞书新跳转
-        String json = configService.selectConfigByKey("course.config");
-        if (StringUtils.isBlank(json)) {
-            throw new CustomException("飞书看课配置为空");
-        }
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-        Boolean enableFeishuNewLink = config.getEnableFeishuNewLink();
-        if (!Boolean.TRUE.equals(enableFeishuNewLink)) {
+    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("未开启飞书看课");
         }
 
-        // 构建参数
-        FeishuLinkParam param = new FeishuLinkParam();
-        param.setCompanyId(companyId);
-        param.setCompanyUserId(companyUserId);
-        param.setCourseId(courseId);
-        param.setVideoId(videoId);
-
-        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
         if (userCourseVideo == null) {
-            throw new CustomException("视频不存在: " + param.getVideoId());
+            throw new CustomException("视频不存在: " + videoId);
         }
 
-        try {
-            // 创建云文档
-            String documentId = createDocument(userCourseVideo.getTitle() + "-注册");
-
-            // 拼接授权url
-            String authUrl = buildAuthLink(param.getCompanyId(), param.getCompanyUserId(),
-                    param.getCourseId(), param.getVideoId(),shortCode);
-
-            // 创建关注公众号 text + link block
-            createTextLinkBlock(documentId, authUrl);
-            // 更新文档权限
-            changeDocumentPermissions(documentId);
-
-            // 返回飞书看课链接
-            return "https://www.feishu.cn/docx/" + documentId;
-        } catch (Exception e) {
-            throw new CustomException("创建飞书注册链接失败: " + e.getMessage(), e);
+        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生成飞书课程链接
+     * 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());
         }
-        try {
-            // 创建云文档
-            String documentId = createDocument(userCourseVideo.getTitle());
-
-            // 拼接新看课url
-            String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(), param.getCourseId(), param.getVideoId(),param.getUserId(),param.getLink());
 
-            // 创建iframe块
-            createIframeBlock(documentId, url);
-
-            // 更新文档权限
-            changeDocumentPermissions(documentId);
-
-            // 返回飞书看课链接
-            return "https://www.feishu.cn/docx/" + documentId;
-        } catch (Exception e) {
-            throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
+        int maxRetries = clientPool.getAvailableCount();
+        for (int i = 0; i < maxRetries; i++) {
+            Client client = clientPool.getClient();
+            try {
+                String documentId = docApiService.createDocument(client, userCourseVideo.getTitle());
+                String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(),
+                        param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink());
+                docApiService.createIframeBlock(client, documentId, url);
+                docApiService.changeDocumentPermissions(client, documentId);
+
+                String resultUrl = "https://www.feishu.cn/docx/" + documentId;
+                redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
+                return resultUrl;
+            } catch (CustomException e) {
+                if (e.getMessage().contains("不可用") || e.getMessage().contains("已被自动禁用")) {
+                    log.warn("账号不可用,尝试下一个");
+                    continue;
+                }
+                throw e;
+            } catch (Exception e) {
+                throw new CustomException("创建飞书课程链接失败: " + e.getMessage(), e);
+            }
         }
+        throw new CustomException("所有飞书账号均不可用,请联系管理员");
     }
 
+    // ==================== URL 构建(纯业务逻辑,无外部调用) ====================
 
-    /**
-     * 创建云文档
-     */
-    private String 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();
-    }
+    private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId,
+                                      Long videoId, Long userId, String link) {
+        CourseConfig config = getCourseConfig();
 
-
-    /**
-     * 拼接看课url(课程参数JSON方式,适配飞书iframe,双重URL编码)
-     */
-    private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId, Long videoId,Long userId,String link) {
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-
-        // 构造课程参数 JSON
         Map<String, Object> courseParam = new LinkedHashMap<>();
         courseParam.put("companyUserId", companyUserId);
         courseParam.put("videoId", videoId);
@@ -161,48 +147,40 @@ public class FeiShuService {
         courseParam.put("courseId", courseId);
         courseParam.put("link", link);
 
-        //查询课程短链表拼接参数
         FsCourseLink fsCourseLink = courseLinkMapper.selectFsCourseLinkByLink(link);
-        if (fsCourseLink==null){
+        if (fsCourseLink == null) {
             throw new CustomException("课程链接已过期");
         }
         courseParam.put("corpId", fsCourseLink.getCorpId());
         courseParam.put("linkType", fsCourseLink.getLinkType());
         courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
         courseParam.put("qwUserId", fsCourseLink.getQwUserId());
-        String courseJson = JSONUtil.toJsonStr(courseParam);
+
         try {
-            // 第一次编码:对JSON进行URL编码
-            String encodedJson = URLEncoder.encode(courseJson, "UTF-8");
-            // 拼接完整URL(userId与course参数同级)
-            String baseUrl = config.getFeishuCourseDomain();
-            String fullUrl = baseUrl + "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" +userId;
-            // 第二次编码:对整个URL进行URL编码(适配飞书iframe创建要求)
+            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);
         }
     }
 
-    /**
-     * 拼接授权url(课程参数JSON方式,适配飞书iframe,双重URL编码)
-     */
-    private String buildAuthLink(Long companyId, Long companyUserId, Long courseId, Long videoId,String shortCode) {
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+    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", shortCode);
-        String authJson = JSONUtil.toJsonStr(authParam);
+        authParam.put("link", shortLink);
 
         try {
-            String encodedJson = URLEncoder.encode(authJson, "UTF-8");
-            String baseUrl =config.getFeishuAuthDomain();
-            String fullUrl = baseUrl + "/feishuCourse/pages/wxauth?course=" + encodedJson;
+            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);
@@ -210,200 +188,13 @@ public class FeiShuService {
     }
 
     /**
-     * 创建iframe块
-     */
-    private void createIframeBlock(String documentId, String url) throws Exception {
-        CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
-                .documentId(documentId)
-                .blockId(documentId)
-                .documentRevisionId(-1)
-                .createDocumentBlockChildrenReqBody(CreateDocumentBlockChildrenReqBody.newBuilder()
-                        .children(new Block[] {
-                                Block.newBuilder()
-                                        .blockType(26)
-                                        .iframe(Iframe.newBuilder()
-                                                .component(IframeComponent.newBuilder()
-                                                        .iframeType(1)
-                                                        .url(url).build()
-                                                ).build()
-                                        )
-                                        .build()
-                        })
-                        .index(0)
-                        .build())
-                .build();
-
-        // 发起请求
-        client.docx().v1().documentBlockChildren().create(req);
-    }
-
-    /**
-     * 创建 text + link block
+     * 读取课程配置并检查非空
      */
-    private void createTextLinkBlock(String documentId, String url) throws Exception {
-
-        CreateDocumentBlockChildrenReq req = CreateDocumentBlockChildrenReq.newBuilder()
-                .documentId(documentId)
-                .blockId(documentId)
-                .documentRevisionId(-1)
-                .createDocumentBlockChildrenReqBody(
-                        CreateDocumentBlockChildrenReqBody.newBuilder()
-                                .index(0)
-                                .children(new Block[] {
-                                        Block.newBuilder()
-                                                .blockType(2)
-                                                .text(Text.newBuilder()
-                                                        .elements(new TextElement[] {
-                                                                TextElement.newBuilder()
-                                                                        .textRun(TextRun.newBuilder()
-                                                                                .content("观看课程")
-                                                                                .textElementStyle(TextElementStyle.newBuilder()
-                                                                                        .link(Link.newBuilder()
-                                                                                                .url(url)
-                                                                                                .build())
-                                                                                        .build())
-                                                                                .build())
-                                                                        .build()
-                                                        })
-                                                        .build())
-                                                .build()
-                                })
-                                .build())
-                .build();
-
-        client.docx().v1().documentBlockChildren().create(req);
-    }
-
-    /**
-     * 获取飞书云文档列表
-     */
-    public List<FeishuFileDTO> listFiles(int pageSize) throws Exception {
-
-        ListFileReq req = ListFileReq.newBuilder()
-                .pageSize(pageSize)
-                .orderBy("EditedTime")
-                .direction("DESC")
-                .build();
-
-        ListFileResp resp = client.drive().v1().file().list(req);
-
-        if (!resp.success()) {
-            throw new RuntimeException(
-                    "Feishu error code:" + resp.getCode()
-                            + ", msg:" + resp.getMsg()
-                            + ", reqId:" + resp.getRequestId()
-            );
-        }
-
-        List<FeishuFileDTO> result = new ArrayList<>();
-
-        if (resp.getData() != null && resp.getData().getFiles() != null) {
-            com.lark.oapi.service.drive.v1.model.File[] files = resp.getData().getFiles();
-            Arrays.stream(files).forEach(file -> {
-                FeishuFileDTO dto = new FeishuFileDTO();
-                dto.setName(file.getName());
-                dto.setToken(file.getToken());
-                dto.setType(file.getType());
-                dto.setUrl(file.getUrl());
-                dto.setOwnerId(file.getOwnerId());
-                dto.setParentToken(file.getParentToken());
-                dto.setCreatedTime(file.getCreatedTime());
-                dto.setModifiedTime(file.getModifiedTime());
-
-                result.add(dto);
-            });
-        }
-
-        return result;
-    }
-
-    /**
-     * 修改文档权限
-     */
-    private void changeDocumentPermissions(String documentId) throws Exception {
-        PatchPermissionPublicReq req = PatchPermissionPublicReq.newBuilder()
-                .token(documentId)
-                .type("docx")
-                .permissionPublic(PermissionPublic.newBuilder()
-                        .externalAccessEntity("open")
-                        .securityEntity("anyone_can_view")
-                        .commentEntity("anyone_can_edit")
-                        .shareEntity("anyone")
-                        .manageCollaboratorEntity("collaborator_full_access")
-                        .linkShareEntity("anyone_readable")
-                        .copyEntity("only_full_access")
-                        .build())
-                .build();
-
-        // 发起请求
-        client.drive().v2().permissionPublic().patch(req);
-    }
-
-    /**
-     * 根据 token 删除飞书文档
-     *
-     * @param fileToken 文档token
-     * @param type 文档类型(docx / sheet / bitable / file 等)
-     */
-    public boolean deleteFile(String fileToken, String type) throws Exception {
-
-        DeleteFileReq req = DeleteFileReq.newBuilder()
-                .fileToken(fileToken)
-                .type(type)
-                .build();
-
-        DeleteFileResp resp = client.drive().v1().file().delete(req);
-
-        if (!resp.success()) {
-            throw new RuntimeException(
-                    "Feishu delete failed, code=" + resp.getCode()
-                            + ", msg=" + resp.getMsg()
-                            + ", reqId=" + resp.getRequestId()
-            );
-        }
-
-        return true;
-    }
-
-    /**
-     * 删除今天之前创建的飞书云文档
-     * 1. 查询云文档列表
-     * 2. 遍历比较创建时间(飞书返回秒级时间戳)
-     * 3. 创建时间在今天 00:00:00 之前的执行删除
-     */
-    public void deleteFilesBeforeToday() throws Exception {
-        // 今天 00:00:00 的时间戳(秒)
-        Calendar today = Calendar.getInstance();
-        today.set(Calendar.HOUR_OF_DAY, 0);
-        today.set(Calendar.MINUTE, 0);
-        today.set(Calendar.SECOND, 0);
-        today.set(Calendar.MILLISECOND, 0);
-        long todayStartSec = today.getTimeInMillis() / 1000;
-
-        List<FeishuFileDTO> files = listFiles(200);
-        int total = files.size();
-        int deleted = 0;
-        for (FeishuFileDTO file : files) {
-            try {
-                // 飞书返回的 createdTime 为秒级时间戳
-                long createdTime = Long.parseLong(file.getCreatedTime());
-                if (createdTime < todayStartSec) {
-                    String type = file.getType();
-                    // wiki 类型无法直接删除,跳过
-                    if ("wiki".equalsIgnoreCase(type)) {
-                        continue;
-                    }
-                    boolean ok = deleteFile(file.getToken(), type);
-                    if (ok) {
-                        deleted++;
-                        log.info("已删除: {} | type={} | createdTime={}", file.getName(), type, file.getCreatedTime());
-                    }
-                }
-            } catch (Exception e) {
-                log.error("删除失败: {} | token={} | err={}", file.getName(), file.getToken(), e.getMessage());
-            }
+    private CourseConfig getCourseConfig() {
+        String json = configService.selectConfigByKey("course.config");
+        if (StringUtils.isBlank(json)) {
+            throw new CustomException("飞书看课配置为空");
         }
-        log.info("飞书云文档清理完成 | 列表总数={} | 删除成功={}", total, deleted);
+        return JSONUtil.toBean(json, CourseConfig.class);
     }
-
 }

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

@@ -0,0 +1,130 @@
+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() {
+        String indexStr = stringRedisTemplate.opsForList()
+                .rightPopAndLeftPush(POOL_KEY, POOL_KEY);
+        if (indexStr == null) {
+            log.warn("账号池为空,尝试重新初始化");
+            resetPoolIndexes(clients.size());
+            indexStr = stringRedisTemplate.opsForList()
+                    .rightPopAndLeftPush(POOL_KEY, POOL_KEY);
+            if (indexStr == null) {
+                throw new CustomException("无法获取飞书账号索引,账号池不可用");
+            }
+        }
+        return clients.get(Integer.parseInt(indexStr));
+    }
+
+    /**
+     * 禁用指定的 Client,并移除出账号池
+     *
+     * @param failedClient 需要禁用的 Client
+     * @param errorMsg     错误原因,会写入数据库 error_msg 字段
+     */
+    public synchronized void disableClient(Client failedClient, String errorMsg) {
+        for (int i = 0; i < clients.size(); i++) {
+            if (clients.get(i) == failedClient) {
+                FeishuAccount account = accountList.get(i);
+                // 更新数据库状态为禁用(status=0),并记录错误信息
+                feishuAccountMapper.updateStatusAndErrorMsg(account.getId(), 0, errorMsg);
+                // 从内存中移除
+                clients.remove(i);
+                accountList.remove(i);
+                // 重建索引池,保证索引仍然连续
+                resetPoolIndexes(clients.size());
+                log.warn("飞书账号[{}]已被自动禁用,原因: {}", account.getAppId(), errorMsg);
+                throw new CustomException(String.format("飞书账号[%s]不可用: %s", account.getAppId(), errorMsg));
+            }
+        }
+        // 如果未找到,理论上不会执行到这里
+        throw new CustomException("无法找到对应的飞书账号");
+    }
+
+    /**
+     * 获取当前可用账号数量的快照(用于重试上限)
+     */
+    public int getAvailableCount() {
+        return clients.size();
+    }
+
+    /**
+     * 获取所有可用 Client 的快照(用于定时任务等遍历场景)
+     */
+    public List<Client> getAllClients() {
+        return new ArrayList<>(clients);
+    }
+}

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

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

+ 0 - 2
fs-service/src/main/java/com/fs/qw/vo/QwSopTempSetting.java

@@ -103,8 +103,6 @@ public class QwSopTempSetting implements Serializable{
             //销售易追踪链接
             private String xsyLinkUrl;
 
-            //飞书追踪链接
-            private String feiShuLinkUrl;
             //发送app消息的链接
             private String appLinkUrl;
 

+ 31 - 13
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -1136,10 +1136,6 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             break;
                         //飞书看课
                         case "18":
-                            addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
-                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
-                                    item.getExternalId(), param.getStartTime(), createTime);
-
                             // 参数转换
                             Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
@@ -1149,6 +1145,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 break;
                             }
+
+                            //1.添加待看课记录
+                            addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime);
+
                             // 替换占位符(销售称呼、客户称呼等)
                             String txt = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
@@ -1163,11 +1165,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                         .replaceAll("#客户称呼#", customerTitle));
                             }
 
-
+                            //2.创建课程短链
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId, videoId, String.valueOf(qwUser.getId()), companyUserId, companyId, item.getExternalId(), config);
                             //获取生成的课程短链码
                             String shortCode = feishuH5Link.get("link");
-                            // 调用生成飞书注册授权链接
+
+                            //3.调用生成飞书注册授权链接
                             String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortCode);
 
                             if (StringUtils.isNotEmpty(feishuLink)) {
@@ -1824,10 +1827,6 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     break;
                 //飞书看课
                 case "18":
-                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
-                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
-                            item.getExternalId(), item.getStartTime(), dataTime);
-
                     // 参数转换
                     Integer videoId = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                     Integer courseId = param.getCourseId() != null ? param.getCourseId().intValue() : null;
@@ -1839,12 +1838,31 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书链接生成失败:视频ID为空,sopId={}", item.getSopId());
                         break;
                     }
+                    if (courseId == null) {
+                        log.error("飞书链接生成失败:课程ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (externalId == null) {
+                        log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    if (qwUserId == null) {
+                        log.error("飞书链接生成失败:用户ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+
+                    //1.添加待看课记录
+                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime);
 
+                    //2.创建课程短链
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId, videoId, qwUserId, companyUserId, companyId, externalId, config);
                     //获取生成的课程短链码
-                    String shortCode = feishuH5Link.get("link");
-                    // 调用生成飞书注册授权链接
-                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortCode);
+                    String shortLink = feishuH5Link.get("link");
+
+                    //3.调用生成飞书注册授权链接
+                    String feishuLink = feiShuService.getFeishuRegisterLink(videoId.longValue(), companyIdLong, courseId.longValue(), companyUserIdLong,shortLink);
 
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         // 替换占位符

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