xw 1 день тому
батько
коміт
ca8042ceb4

+ 6 - 3
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -1601,9 +1601,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     String chatIdForFeishu = null;
                     if (isGroupChat) {
                         chatIdForFeishu = externalId;
-                    } else {
+                    } else if (FeiShuService.isAuthRequired(setting.getFeishuNeedAuth())) {
                         externalIdLong = Long.parseLong(externalId);
                         addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo, 2);
+                    } else {
+                        externalIdLong = Long.parseLong(externalId);
                     }
 
                     Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
@@ -1611,8 +1613,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     String shortLink = feiShuLinkMap.get("link");
 
                     try {
-                        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyIdLong, courseId, companyUserIdLong,
-                                shortLink, setting.getFeishuAccountId());
+                        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
+                                shortLink, setting.getFeishuAccountId(), setting.getFeishuNeedAuth());
                         if (StringUtils.isEmpty(feishuLink)) {
                             log.error("生成飞书注册链接失败,sopId={}, videoId={}, externalId={}", logVo.getSopId(), videoId, externalId);
                             throw new RuntimeException("生成飞书注册链接失败");
@@ -1929,6 +1931,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
                 companyUserId, companyId, externalIdStr, 3);
         link.setFeishuAccountId(setting.getFeishuAccountId());
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
         link.setProjectCode(cloudHostProper.getProjectCode());
         if (StringUtils.isNotEmpty(chatId)) {
             link.setChatId(chatId);

+ 3 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseLink.java

@@ -72,4 +72,7 @@ public class FsCourseLink extends BaseEntity
     @ApiModelProperty(value = "飞书应用账号id")
     private Long feishuAccountId;
 
+    /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+    private Integer feishuNeedAuth;
+
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseRealLink.java

@@ -53,4 +53,7 @@ public class FsCourseRealLink implements Serializable
      * 业务id
      */
     private String businessId;
+
+    /** 飞书看课是否需要客户授权:1-需要,0-免授权 */
+    private Integer feishuNeedAuth;
 }

+ 2 - 1
fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java

@@ -69,7 +69,8 @@ public interface FsCourseLinkMapper
     public int deleteFsCourseLinkByLinkIds(Long[] linkIds);
 
     @Select("select link_id, chat_id, is_room, link, real_link, link_type, create_time, update_time, " +
-            "company_id, company_user_id, qw_user_id, video_id, corp_id, course_id, qw_external_id, feishu_account_id " +
+            "company_id, company_user_id, qw_user_id, video_id, corp_id, course_id, qw_external_id, " +
+            "feishu_account_id, feishu_need_auth " +
             "from fs_course_link where link = #{suffix} order by link_id desc limit 1 ")
     FsCourseLink selectFsCourseLinkByLink(@Param("suffix") String suffix);
 

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

@@ -29,4 +29,7 @@ public class FsCourseLinkMiniParam {
     /** 飞书应用账号 id(contentType=17 发课时必选) */
     private Long feishuAccountId;
 
+    /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+    private Integer feishuNeedAuth;
+
 }

+ 7 - 4
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -3703,12 +3703,14 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
         Long externalUserId = externalContact.getId();
 
-        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, externalUserId, 2);
+        if (FeiShuService.isAuthRequired(param.getFeishuNeedAuth())) {
+            addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), param.getFsUserId(), qwUser, externalUserId, 2);
+        }
 
-        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config, null);
+        Map<String, String> feishuH5Link = createFeishuH5Link(param.getCorpId(), createTime, courseId.intValue(), videoId.intValue(), String.valueOf(qwUserId), companyUserId, companyId, externalUserId, config, param.getFeishuAccountId(), param.getFeishuNeedAuth());
         String shortCode = feishuH5Link.get("link");
 
-        String feishuLink = feiShuService.getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortCode, null);
+        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortCode, param.getFeishuAccountId(), param.getFeishuNeedAuth());
 
         JSONObject news = new JSONObject(true);
         news.put("linkTitle", param.getTitle());
@@ -5652,10 +5654,11 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
                                                    Integer courseId, Integer videoId, String qwUserId,
                                                    Long companyUserId, Long companyId, Long externalId, CourseConfig config,
-                                                   Long feishuAccountId) {
+                                                   Long feishuAccountId, Integer feishuNeedAuth) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, null);
         link.setFeishuAccountId(feishuAccountId);
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(feishuNeedAuth) ? 1 : 0);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(courseMap);

+ 130 - 23
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -13,6 +13,7 @@ import com.fs.feishu.mapper.FeishuAccountMapper;
 import com.fs.feishu.service.IFeishuAccountService;
 import com.fs.feishu.model.FeishuClientHolder;
 import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.util.SecureTokenUtil;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -50,6 +51,97 @@ public class FeiShuService {
     @Autowired
     private IFeishuAccountService feishuAccountService;
 
+    private static final String FEISHU_DOC_URL_PREFIX = "https://www.feishu.cn/docx/";
+
+    /**
+     * 按发课配置生成飞书文档链接:需要授权走注册链,免授权直接生成看课文档。
+     */
+    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
+                                        Long companyUserId, String shortLink, Long feishuAccountId,
+                                        Integer feishuNeedAuth) {
+        if (isAuthRequired(feishuNeedAuth)) {
+            return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
+        }
+        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
+    }
+
+    /**
+     * 是否需要客户 wxauth 授权后才可看课。null/非0 均视为需要授权(兼容历史数据)。
+     */
+    public static boolean isAuthRequired(Integer feishuNeedAuth) {
+        return feishuNeedAuth == null || feishuNeedAuth != 0;
+    }
+
+    /**
+     * 持久化到 fs_course_link / course JSON 的值:1-需要授权,0-免授权。
+     */
+    public static int toStoredFeishuNeedAuth(Integer feishuNeedAuth) {
+        return isAuthRequired(feishuNeedAuth) ? 1 : 0;
+    }
+
+    /**
+     * 是否记录看课数据(时长/看课日志等)。免授权模式不记录。
+     */
+    public boolean isWatchDataEnabled(String linkCode) {
+        if (StringUtils.isBlank(linkCode)) {
+            return true;
+        }
+        FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(linkCode);
+        if (link == null) {
+            return true;
+        }
+        return isAuthRequired(link.getFeishuNeedAuth());
+    }
+
+    /**
+     * 生成飞书免授权看课链接(发课时直接嵌入播放页 iframe 文档)。
+     */
+    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
+                                            Long companyUserId, String shortLink, Long feishuAccountId) {
+        CourseConfig config = getCourseConfig();
+        if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
+            throw new CustomException("未开启飞书看课");
+        }
+        if (companyUserId == null) {
+            throw new CustomException("销售ID不能为空");
+        }
+        feishuAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);
+
+        FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
+        if (userCourseVideo == null) {
+            throw new CustomException("视频不存在: " + videoId);
+        }
+        int questionFlag = resolveQuestionFlag(userCourseVideo);
+
+        List<FeishuAccount> accounts = clientPool.listEnabledAccounts(companyUserId, feishuAccountId);
+        CustomException lastError = null;
+        for (FeishuAccount account : accounts) {
+            FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
+            try {
+                String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
+                String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
+                docApiService.createIframeBlock(holder, documentId, iframeUrl);
+                docApiService.changeDocumentPermissions(holder, documentId);
+                feishuAccountMapper.incrementNumberUse(account.getId());
+                updateCourseLinkFeishuAccount(shortLink, account.getId());
+                return FEISHU_DOC_URL_PREFIX + documentId;
+            } catch (CustomException e) {
+                lastError = e;
+                if (isAccountUnavailable(e)) {
+                    log.warn("销售[{}]飞书账号[{}]不可用,尝试下一个", companyUserId, account.getId());
+                    continue;
+                }
+                throw e;
+            } catch (Exception e) {
+                throw new CustomException("创建飞书免授权看课链接失败: " + e.getMessage(), e);
+            }
+        }
+        if (lastError != null) {
+            throw lastError;
+        }
+        throw new CustomException("当前销售没有可用的飞书账号,请先配置或更换账号");
+    }
+
     /**
      * 生成飞书初始链接(注册授权链接)。
      */
@@ -110,6 +202,9 @@ public class FeiShuService {
         }
 
         FsCourseLink courseLink = requireCourseLink(param.getLink());
+        if (!isAuthRequired(courseLink.getFeishuNeedAuth())) {
+            throw new CustomException("免授权链接无需调用此接口");
+        }
         Long feishuAccountId = courseLink.getFeishuAccountId();
         if (feishuAccountId == null) {
             feishuAccountId = feishuAccountService.resolveSendAccountId(param.getCompanyUserId(), null);
@@ -131,19 +226,17 @@ public class FeiShuService {
             throw new CustomException("视频不存在: " + param.getVideoId());
         }
 
-        String questionBankId = userCourseVideo.getQuestionBankId();
-        boolean hasQuestion = StringUtils.isNotBlank(questionBankId) && !"null".equals(questionBankId);
-        Integer questionFlag = hasQuestion ? 1 : 0;
+        int questionFlag = resolveQuestionFlag(userCourseVideo);
 
         FeishuClientHolder holder = clientPool.getClientByAccountId(feishuAccountId);
         try {
             String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
-            String url = buildCourseNewLink(param.getCompanyId(), param.getCompanyUserId(),
+            String iframeUrl = buildCoursePageLink(param.getCompanyId(), param.getCompanyUserId(),
                     param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink(), questionFlag);
-            docApiService.createIframeBlock(holder, documentId, url);
+            docApiService.createIframeBlock(holder, documentId, iframeUrl);
             docApiService.changeDocumentPermissions(holder, documentId);
 
-            String resultUrl = "https://www.feishu.cn/docx/" + documentId;
+            String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
             return resultUrl;
@@ -168,39 +261,52 @@ public class FeiShuService {
         return message != null && (message.contains("不可用") || message.contains("已被自动禁用") || message.contains("已禁用"));
     }
 
-    private String buildCourseNewLink(Long companyId, Long companyUserId, Long courseId,
-                                      Long videoId, Long userId, String link, Integer questionFlag) {
+    private String buildCoursePageLink(Long companyId, Long companyUserId, Long courseId,
+                                       Long videoId, Long userId, String link, Integer questionFlag) {
         CourseConfig config = getCourseConfig();
+        Map<String, Object> courseParam = buildCourseParam(companyId, companyUserId, courseId, videoId, link, questionFlag);
 
+        try {
+            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
+            String courseToken = SecureTokenUtil.generateCourseAccessToken(companyUserId, videoId, userId);
+            String encodedToken = URLEncoder.encode(courseToken, "UTF-8");
+            String pageUrl = config.getFeishuCourseDomain()
+                    + "/feishuCourse/pages_course/video?course=" + encodedJson
+                    + "&userId=" + userId
+                    + "&token=" + encodedToken;
+            return URLEncoder.encode(pageUrl, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new CustomException("看课URL拼接失败", e);
+        }
+    }
+
+    private Map<String, Object> buildCourseParam(Long companyId, Long companyUserId, Long courseId,
+                                                 Long videoId, String link, Integer questionFlag) {
         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);
+        if (questionFlag != null) {
+            courseParam.put("questionFlag", questionFlag);
+        }
         appendLinkMeta(courseParam, requireCourseLink(link));
+        return courseParam;
+    }
 
-        try {
-            String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(courseParam), "UTF-8");
-            return URLEncoder.encode(config.getFeishuCourseDomain()
-                    + "/feishuCourse/pages_course/video?course=" + encodedJson + "&userId=" + userId, "UTF-8");
-        } catch (UnsupportedEncodingException e) {
-            throw new CustomException("看课URL拼接失败", e);
-        }
+    private int resolveQuestionFlag(FsUserCourseVideo video) {
+        String questionBankId = video.getQuestionBankId();
+        boolean hasQuestion = StringUtils.isNotBlank(questionBankId) && !"null".equals(questionBankId);
+        return hasQuestion ? 1 : 0;
     }
 
     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));
+        Map<String, Object> authParam = buildCourseParam(companyId, companyUserId, courseId, videoId, shortLink, null);
+        authParam.put("feishuNeedAuth", 1);
 
         try {
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
@@ -224,6 +330,7 @@ public class FeiShuService {
         courseParam.put("linkType", fsCourseLink.getLinkType());
         courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
         courseParam.put("qwUserId", fsCourseLink.getQwUserId());
+        courseParam.put("feishuNeedAuth", toStoredFeishuNeedAuth(fsCourseLink.getFeishuNeedAuth()));
         if (StringUtils.isNotBlank(fsCourseLink.getProjectCode())) {
             courseParam.put("projectCode", fsCourseLink.getProjectCode());
         }

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

@@ -15,6 +15,15 @@ public class SecureTokenUtil {
     private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
     private static final String SECRET_KEY = "9f3a7c21b8e4d6a5c2f1097e3b4a6d8f"; // 16字节密钥
 
+    /**
+     * 生成飞书看课课程访问 token(与 getNoAuthH5CourseVideoDetails 解析规则一致)。
+     */
+    public static String generateCourseAccessToken(Long companyUserId, Long videoId, Long userId) {
+        long timestamp = System.currentTimeMillis() / 1000;
+        long effectiveUserId = userId != null ? userId : 0L;
+        return generateTokenUser(companyUserId, videoId, 0L, timestamp, effectiveUserId);
+    }
+
     /**
      * 生成安全令牌
      */

+ 3 - 0
fs-service/src/main/java/com/fs/qw/vo/QwSopCourseFinishTempSetting.java

@@ -153,6 +153,9 @@ public class QwSopCourseFinishTempSetting implements Serializable,Cloneable{
         /** 飞书应用账号 id(contentType=17 时使用) */
         private Long feishuAccountId;
 
+        /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+        private Integer feishuNeedAuth;
+
         /**
          * 模板表 qw_sop_temp_content 中存储的整段 JSON 字符串(个微 SOP 生成日志时会原样带入)。
          * 真实 fileUrl、展示用文件名等多在该嵌套串中,发送前需合并到本对象扁平字段。

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

@@ -180,6 +180,9 @@ public class QwSopTempSetting implements Serializable{
             /** 飞书应用账号 id(contentType=17 时使用) */
             private Long feishuAccountId;
 
+            /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+            private Integer feishuNeedAuth;
+
             public boolean isRandomSendEnabled() {
                 return Objects.equals(isRandomSend, 1) || "1".equals(String.valueOf(isRandomSend));
             }

+ 15 - 10
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -767,7 +767,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 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, null);
+                                String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth());
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                     st.setLinkUrl(feishuLink);
                                 } else {
@@ -994,7 +994,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), null, config, groupChat.getChatId());
                                 String shortCodeGroup = feishuH5LinkGroup.get("link");
-                                String feishuLinkGroup = feiShuService.getFeishuRegisterLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup, null);
+                                String feishuLinkGroup = feiShuService.resolveFeishuSendLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth());
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                     st.setLinkUrl(feishuLinkGroup);
                                 } else {
@@ -1318,9 +1318,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 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);
+                            if (FeiShuService.isAuthRequired(st.getFeishuNeedAuth())) {
+                                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())) {
@@ -1335,7 +1337,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             }
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, qwUserId, companyUserId, companyId, item.getExternalId(), config);
                             String shortCode = feishuH5Link.get("link");
-                            String feishuLink = feiShuService.getFeishuRegisterLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, null);
+                            String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth());
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
                             } else {
@@ -1952,12 +1954,14 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         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);
+                    if (FeiShuService.isAuthRequired(st.getFeishuNeedAuth())) {
+                        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, null);
+                    String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth());
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String customerTitle = contact == null ? "同学" :
@@ -2002,6 +2006,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, chatId);
         link.setFeishuAccountId(setting.getFeishuAccountId());
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(courseMap);

+ 5 - 1
fs-service/src/main/resources/mapper/course/FsCourseLinkMapper.xml

@@ -21,10 +21,11 @@
         <result property="isRoom"    column="is_room"    />
         <result property="chatId"    column="chat_id"    />
         <result property="feishuAccountId"    column="feishu_account_id"    />
+        <result property="feishuNeedAuth"    column="feishu_need_auth"    />
     </resultMap>
 
     <sql id="selectFsCourseLinkVo">
-        select link_id,chat_id, is_room,link, real_link,link_type, create_time, update_time, company_id, company_user_id, qw_user_id, video_id, corp_id, course_id ,qw_external_id, feishu_account_id from fs_course_link
+        select link_id,chat_id, is_room,link, real_link,link_type, create_time, update_time, company_id, company_user_id, qw_user_id, video_id, corp_id, course_id ,qw_external_id, feishu_account_id, feishu_need_auth from fs_course_link
     </sql>
 
     <select id="selectFsCourseLinkList" parameterType="FsCourseLink" resultMap="FsCourseLinkResult">
@@ -66,6 +67,7 @@
             <if test="isRoom != null">is_room,</if>
             <if test="chatId != null">chat_id,</if>
             <if test="feishuAccountId != null">feishu_account_id,</if>
+            <if test="feishuNeedAuth != null">feishu_need_auth,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="link != null">#{link},</if>
@@ -83,6 +85,7 @@
             <if test="isRoom != null">#{isRoom},</if>
             <if test="chatId != null">#{chatId},</if>
             <if test="feishuAccountId != null">#{feishuAccountId},</if>
+            <if test="feishuNeedAuth != null">#{feishuNeedAuth},</if>
         </trim>
     </insert>
 
@@ -192,6 +195,7 @@
             <if test="isRoom != null">is_room = #{isRoom},</if>
             <if test="chatId != null">chat_id = #{chatId},</if>
             <if test="feishuAccountId != null">feishu_account_id = #{feishuAccountId},</if>
+            <if test="feishuNeedAuth != null">feishu_need_auth = #{feishuNeedAuth},</if>
         </trim>
         where link_id = #{linkId}
     </update>

+ 157 - 0
fs-user-app/src/main/java/com/fs/app/controller/course/CourseQwController.java

@@ -1,7 +1,9 @@
 package com.fs.app.controller.course;
 
 
+import cn.hutool.core.util.IdUtil;
 import cn.hutool.json.JSONUtil;
+import com.fs.app.param.FeiShuNoAuthInternetTrafficParam;
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.fs.app.annotation.Login;
 import com.fs.app.annotation.UserOperationLog;
@@ -21,6 +23,8 @@ import com.fs.course.param.*;
 import com.fs.course.service.*;
 import com.fs.course.service.impl.TencentCloudCosService;
 import com.fs.course.vo.*;
+import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.util.SecureTokenUtil;
 import com.fs.his.domain.FsUser;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
@@ -90,6 +94,10 @@ public class CourseQwController extends AppBaseController {
     private IQwSopService qwSopService;
     @Autowired
     private IFsUserService userService;
+
+    @Autowired
+    private FeiShuService feiShuService;
+
     @ApiOperation("查询全部公域的课程")
     @GetMapping("/getAppletCourse")
     public R getAppletCourse()
@@ -230,6 +238,155 @@ public class CourseQwController extends AppBaseController {
 
     }
 
+    @ApiOperation("飞书SOP免授权课程简介")
+    @GetMapping("/getNoAuthH5CourseByVideoId")
+    public R getNoAuthH5CourseByVideoId(@RequestParam("videoId") Long videoId) {
+        if (videoId == null) {
+            return R.error("videoId不能为空");
+        }
+        FsUserCourseVideoH5VO course = courseService.selectFsUserCourseVideoH5VOByVideoId(videoId);
+        if (ObjectUtils.isEmpty(course)) {
+            return R.error("课堂视频对象不存在,请联系客服!");
+        }
+        return R.ok().put("data", course);
+    }
+
+    @ApiOperation("飞书SOP免授权课程详情")
+    @GetMapping("/getNoAuthH5CourseVideoDetails")
+    public R getNoAuthH5CourseVideoDetails(@RequestParam("token") String token,
+                                           @RequestParam("link") String link) {
+        R accessError = validateNoAuthCourseAccess(token, link);
+        if (accessError != null) {
+            return accessError;
+        }
+
+        Long videoId;
+        try {
+            Map<String, Object> tokenParams = SecureTokenUtil.parseToken(token);
+            videoId = (Long) tokenParams.get("videoId");
+        } catch (Exception e) {
+            logger.error("免授权飞书看课token解析失败", e);
+            return R.error("课程访问失败: " + e.getMessage());
+        }
+
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        FsUserCourseVideoH5DVO course = courseService.selectFsUserCourseVideoH5DVOByVideoId(videoId);
+        if (ObjectUtils.isEmpty(course)) {
+            return R.error("课堂视频对象不存在,请联系客服!");
+        }
+
+        List<FsUserCourseVideoQuestionVO> questionVOList = buildH5CourseQuestionList(course.getQuestionBankId());
+
+        int isRedPackage = 1;
+        if (course.getRedPacketMoney() == null || "0".equals(course.getRedPacketMoney())) {
+            isRedPackage = 0;
+        }
+
+        String videoRedisKey = "h5user:video:duration:" + videoId;
+        Long videoDuration = redisCache.getCacheObject(videoRedisKey);
+        if (videoDuration == null && course.getDuration() != null) {
+            redisCache.setCacheObject(videoRedisKey, course.getDuration());
+        }
+
+        return R.ok()
+                .put("course", course)
+                .put("questions", questionVOList)
+                .put("config", config)
+                .put("playDuration", 0L)
+                .put("tipsTime", 0L)
+                .put("isFinish", 0)
+                .put("isRedPackage", isRedPackage);
+    }
+
+    private List<FsUserCourseVideoQuestionVO> buildH5CourseQuestionList(String questionBankId) {
+        List<FsUserCourseVideoQuestionVO> questionVOList = new ArrayList<>();
+        if (StringUtils.isEmpty(questionBankId)) {
+            return questionVOList;
+        }
+        for (String questionId : questionBankId.split(",")) {
+            FsUserCourseVideoQuestionVO vo = new FsUserCourseVideoQuestionVO();
+            FsCourseQuestionBank questionBank = questionBankService.selectFsCourseQuestionBankById(Long.parseLong(questionId));
+            if (questionBank != null && questionBank.getStatus() != 0) {
+                BeanUtils.copyProperties(questionBank, vo);
+                questionVOList.add(vo);
+            }
+        }
+        return questionVOList;
+    }
+
+    @ApiOperation("飞书SOP免授权获取流量会话标识")
+    @GetMapping("/getNoAuthUniqueIdentifier")
+    public R getNoAuthUniqueIdentifier(@RequestParam("token") String token,
+                                       @RequestParam("link") String link) {
+        R accessError = validateNoAuthCourseAccess(token, link);
+        if (accessError != null) {
+            return accessError;
+        }
+        return R.ok().put("data", IdUtil.getSnowflake(0, 0).nextIdStr());
+    }
+
+    @ApiOperation("飞书SOP免授权流量上报")
+    @PostMapping("/getNoAuthInternetTraffic")
+    public R getNoAuthInternetTraffic(@Valid @RequestBody FeiShuNoAuthInternetTrafficParam param,
+                                      @RequestHeader(value = "Identifier", required = false) Long identifierHeader) {
+        R accessError = validateNoAuthCourseAccess(param.getToken(), param.getLink());
+        if (accessError != null) {
+            return accessError;
+        }
+        Long identifier = param.getIdentifier() != null ? param.getIdentifier() : identifierHeader;
+        if (identifier == null) {
+            return R.error("identifier不能为空");
+        }
+        try {
+            Map<String, Object> tokenParams = SecureTokenUtil.parseToken(param.getToken());
+            Long timestamp = (Long) tokenParams.get("timestamp");
+            if (!SecureTokenUtil.isTokenValid(timestamp)) {
+                return R.error("链接已过期,请重新获取");
+            }
+            Long companyUserId = (Long) tokenParams.get("companyUserId");
+            Long videoId = (Long) tokenParams.get("videoId");
+            if (companyUserId == null || videoId == null) {
+                return R.error("无效的访问参数");
+            }
+            courseVideoService.getFeiShuInternetTraffic(identifier, companyUserId, videoId,
+                    param.getUuId(), param.getBufferRate());
+            return R.ok();
+        } catch (Exception e) {
+            logger.error("免授权飞书看课流量上报失败", e);
+            return R.error("流量上报失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 校验免授权看课 token + 短链,通过则允许拉课详情/上报流量(不写看课记录)。
+     */
+    private R validateNoAuthCourseAccess(String token, String link) {
+        if (StringUtils.isBlank(token)) {
+            return R.error("token不能为空");
+        }
+        if (StringUtils.isBlank(link)) {
+            return R.error("link不能为空");
+        }
+        if (feiShuService.isWatchDataEnabled(link)) {
+            return R.error("该链接需要授权,请走授权看课流程");
+        }
+        try {
+            Map<String, Object> tokenParams = SecureTokenUtil.parseToken(token);
+            Long timestamp = (Long) tokenParams.get("timestamp");
+            if (!SecureTokenUtil.isTokenValid(timestamp)) {
+                return R.error("链接已过期,请重新获取");
+            }
+            if (tokenParams.get("videoId") == null) {
+                return R.error("无效的访问参数");
+            }
+        } catch (Exception e) {
+            logger.error("免授权飞书看课token解析失败", e);
+            return R.error("课程访问失败: " + e.getMessage());
+        }
+        return null;
+    }
+
     @ApiOperation("答题")
     @PostMapping("/courseAnswer")
     @Login

+ 25 - 0
fs-user-app/src/main/java/com/fs/app/param/FeiShuNoAuthInternetTrafficParam.java

@@ -0,0 +1,25 @@
+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 FeiShuNoAuthInternetTrafficParam {
+
+    @NotBlank(message = "token不能为空")
+    private String token;
+
+    @NotBlank(message = "link不能为空")
+    private String link;
+
+    private String uuId;
+
+    @NotNull(message = "bufferRate不能为空")
+    private BigDecimal bufferRate;
+
+    /** 免鉴权唯一标识,通过请求头Identifier传递,仅免鉴权模式使用 */
+    private Long identifier;
+}