Forráskód Böngészése

飞书免授权观看

xw 2 napja
szülő
commit
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;
                     String chatIdForFeishu = null;
                     if (isGroupChat) {
                     if (isGroupChat) {
                         chatIdForFeishu = externalId;
                         chatIdForFeishu = externalId;
-                    } else {
+                    } else if (FeiShuService.isAuthRequired(setting.getFeishuNeedAuth())) {
                         externalIdLong = Long.parseLong(externalId);
                         externalIdLong = Long.parseLong(externalId);
                         addWatchLogIfNeeded(sopLogs, videoId, courseId, sendTime, qwUserId, companyUserId, companyId, externalId, logVo, 2);
                         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,
                     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");
                     String shortLink = feiShuLinkMap.get("link");
 
 
                     try {
                     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)) {
                         if (StringUtils.isEmpty(feishuLink)) {
                             log.error("生成飞书注册链接失败,sopId={}, videoId={}, externalId={}", logVo.getSopId(), videoId, externalId);
                             log.error("生成飞书注册链接失败,sopId={}, videoId={}, externalId={}", logVo.getSopId(), videoId, externalId);
                             throw new RuntimeException("生成飞书注册链接失败");
                             throw new RuntimeException("生成飞书注册链接失败");
@@ -1929,6 +1931,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
                 companyUserId, companyId, externalIdStr, 3);
                 companyUserId, companyId, externalIdStr, 3);
         link.setFeishuAccountId(setting.getFeishuAccountId());
         link.setFeishuAccountId(setting.getFeishuAccountId());
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
         link.setProjectCode(cloudHostProper.getProjectCode());
         link.setProjectCode(cloudHostProper.getProjectCode());
         if (StringUtils.isNotEmpty(chatId)) {
         if (StringUtils.isNotEmpty(chatId)) {
             link.setChatId(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")
     @ApiModelProperty(value = "飞书应用账号id")
     private Long feishuAccountId;
     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
      * 业务id
      */
      */
     private String businessId;
     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);
     public int deleteFsCourseLinkByLinkIds(Long[] linkIds);
 
 
     @Select("select link_id, chat_id, is_room, link, real_link, link_type, create_time, update_time, " +
     @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 ")
             "from fs_course_link where link = #{suffix} order by link_id desc limit 1 ")
     FsCourseLink selectFsCourseLinkByLink(@Param("suffix") String suffix);
     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 发课时必选) */
     /** 飞书应用账号 id(contentType=17 发课时必选) */
     private Long feishuAccountId;
     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();
         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 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);
         JSONObject news = new JSONObject(true);
         news.put("linkTitle", param.getTitle());
         news.put("linkTitle", param.getTitle());
@@ -5652,10 +5654,11 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
     private Map<String, String> createFeishuH5Link(String corpId, Date sendTime,
                                                    Integer courseId, Integer videoId, String qwUserId,
                                                    Integer courseId, Integer videoId, String qwUserId,
                                                    Long companyUserId, Long companyId, Long externalId, CourseConfig config,
                                                    Long companyUserId, Long companyId, Long externalId, CourseConfig config,
-                                                   Long feishuAccountId) {
+                                                   Long feishuAccountId, Integer feishuNeedAuth) {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, null);
                 companyUserId, companyId, externalId, 3, null);
         link.setFeishuAccountId(feishuAccountId);
         link.setFeishuAccountId(feishuAccountId);
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(feishuNeedAuth) ? 1 : 0);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(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.service.IFeishuAccountService;
 import com.fs.feishu.model.FeishuClientHolder;
 import com.fs.feishu.model.FeishuClientHolder;
 import com.fs.feishu.model.FeishuLinkParam;
 import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.util.SecureTokenUtil;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.StringUtils;
@@ -50,6 +51,97 @@ public class FeiShuService {
     @Autowired
     @Autowired
     private IFeishuAccountService feishuAccountService;
     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());
         FsCourseLink courseLink = requireCourseLink(param.getLink());
+        if (!isAuthRequired(courseLink.getFeishuNeedAuth())) {
+            throw new CustomException("免授权链接无需调用此接口");
+        }
         Long feishuAccountId = courseLink.getFeishuAccountId();
         Long feishuAccountId = courseLink.getFeishuAccountId();
         if (feishuAccountId == null) {
         if (feishuAccountId == null) {
             feishuAccountId = feishuAccountService.resolveSendAccountId(param.getCompanyUserId(), null);
             feishuAccountId = feishuAccountService.resolveSendAccountId(param.getCompanyUserId(), null);
@@ -131,19 +226,17 @@ public class FeiShuService {
             throw new CustomException("视频不存在: " + param.getVideoId());
             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);
         FeishuClientHolder holder = clientPool.getClientByAccountId(feishuAccountId);
         try {
         try {
             String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
             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);
                     param.getCourseId(), param.getVideoId(), param.getUserId(), param.getLink(), questionFlag);
-            docApiService.createIframeBlock(holder, documentId, url);
+            docApiService.createIframeBlock(holder, documentId, iframeUrl);
             docApiService.changeDocumentPermissions(holder, documentId);
             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);
             redisCache.setCacheObject(cacheKey, resultUrl, 24, TimeUnit.HOURS);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
             return resultUrl;
             return resultUrl;
@@ -168,39 +261,52 @@ public class FeiShuService {
         return message != null && (message.contains("不可用") || message.contains("已被自动禁用") || message.contains("已禁用"));
         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();
         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<>();
         Map<String, Object> courseParam = new LinkedHashMap<>();
         courseParam.put("companyUserId", companyUserId);
         courseParam.put("companyUserId", companyUserId);
         courseParam.put("videoId", videoId);
         courseParam.put("videoId", videoId);
         courseParam.put("companyId", companyId);
         courseParam.put("companyId", companyId);
         courseParam.put("courseId", courseId);
         courseParam.put("courseId", courseId);
         courseParam.put("link", link);
         courseParam.put("link", link);
-        courseParam.put("questionFlag", questionFlag);
+        if (questionFlag != null) {
+            courseParam.put("questionFlag", questionFlag);
+        }
         appendLinkMeta(courseParam, requireCourseLink(link));
         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,
     private String buildAuthLink(Long companyId, Long companyUserId, Long courseId,
                                  Long videoId, String shortLink) {
                                  Long videoId, String shortLink) {
         CourseConfig config = getCourseConfig();
         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 {
         try {
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
             String encodedJson = URLEncoder.encode(JSONUtil.toJsonStr(authParam), "UTF-8");
@@ -224,6 +330,7 @@ public class FeiShuService {
         courseParam.put("linkType", fsCourseLink.getLinkType());
         courseParam.put("linkType", fsCourseLink.getLinkType());
         courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
         courseParam.put("qwExternalId", fsCourseLink.getQwExternalId());
         courseParam.put("qwUserId", fsCourseLink.getQwUserId());
         courseParam.put("qwUserId", fsCourseLink.getQwUserId());
+        courseParam.put("feishuNeedAuth", toStoredFeishuNeedAuth(fsCourseLink.getFeishuNeedAuth()));
         if (StringUtils.isNotBlank(fsCourseLink.getProjectCode())) {
         if (StringUtils.isNotBlank(fsCourseLink.getProjectCode())) {
             courseParam.put("projectCode", 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 TRANSFORMATION = "AES/ECB/PKCS5Padding";
     private static final String SECRET_KEY = "9f3a7c21b8e4d6a5c2f1097e3b4a6d8f"; // 16字节密钥
     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 时使用) */
         /** 飞书应用账号 id(contentType=17 时使用) */
         private Long feishuAccountId;
         private Long feishuAccountId;
 
 
+        /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+        private Integer feishuNeedAuth;
+
         /**
         /**
          * 模板表 qw_sop_temp_content 中存储的整段 JSON 字符串(个微 SOP 生成日志时会原样带入)。
          * 模板表 qw_sop_temp_content 中存储的整段 JSON 字符串(个微 SOP 生成日志时会原样带入)。
          * 真实 fileUrl、展示用文件名等多在该嵌套串中,发送前需合并到本对象扁平字段。
          * 真实 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 时使用) */
             /** 飞书应用账号 id(contentType=17 时使用) */
             private Long feishuAccountId;
             private Long feishuAccountId;
 
 
+            /** 飞书看课是否需要客户授权:1-需要(默认),0-免授权 */
+            private Integer feishuNeedAuth;
+
             public boolean isRandomSendEnabled() {
             public boolean isRandomSendEnabled() {
                 return Objects.equals(isRandomSend, 1) || "1".equals(String.valueOf(isRandomSend));
                 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);
                                 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 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)) {
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                     st.setLinkUrl(feishuLink);
                                     st.setLinkUrl(feishuLink);
                                 } else {
                                 } 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());
                                 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 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)) {
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                     st.setLinkUrl(feishuLinkGroup);
                                     st.setLinkUrl(feishuLinkGroup);
                                 } else {
                                 } else {
@@ -1318,9 +1318,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 log.error("飞书链接生成失败:视频ID为空,sopId={}", param.getSopId());
                                 break;
                                 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 txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             String customerTitle = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
                             if (StringUtils.isNotEmpty(st.getLinkTitle())) {
                             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);
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, qwUserId, companyUserId, companyId, item.getExternalId(), config);
                             String shortCode = feishuH5Link.get("link");
                             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)) {
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
                                 st.setLinkUrl(feishuLink);
                             } else {
                             } else {
@@ -1952,12 +1954,14 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         log.error("飞书链接生成失败:外部ID为空,sopId={}", item.getSopId());
                         break;
                         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);
                     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 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)) {
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String customerTitle = contact == null ? "同学" :
                         String customerTitle = contact == null ? "同学" :
@@ -2002,6 +2006,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
         FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, Long.valueOf(qwUserId),
                 companyUserId, companyId, externalId, 3, chatId);
                 companyUserId, companyId, externalId, 3, chatId);
         link.setFeishuAccountId(setting.getFeishuAccountId());
         link.setFeishuAccountId(setting.getFeishuAccountId());
+        link.setFeishuNeedAuth(FeiShuService.isAuthRequired(setting.getFeishuNeedAuth()) ? 1 : 0);
         FsCourseRealLink courseMap = new FsCourseRealLink();
         FsCourseRealLink courseMap = new FsCourseRealLink();
         BeanUtils.copyProperties(link, courseMap);
         BeanUtils.copyProperties(link, courseMap);
         String courseJson = JSON.toJSONString(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="isRoom"    column="is_room"    />
         <result property="chatId"    column="chat_id"    />
         <result property="chatId"    column="chat_id"    />
         <result property="feishuAccountId"    column="feishu_account_id"    />
         <result property="feishuAccountId"    column="feishu_account_id"    />
+        <result property="feishuNeedAuth"    column="feishu_need_auth"    />
     </resultMap>
     </resultMap>
 
 
     <sql id="selectFsCourseLinkVo">
     <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>
     </sql>
 
 
     <select id="selectFsCourseLinkList" parameterType="FsCourseLink" resultMap="FsCourseLinkResult">
     <select id="selectFsCourseLinkList" parameterType="FsCourseLink" resultMap="FsCourseLinkResult">
@@ -66,6 +67,7 @@
             <if test="isRoom != null">is_room,</if>
             <if test="isRoom != null">is_room,</if>
             <if test="chatId != null">chat_id,</if>
             <if test="chatId != null">chat_id,</if>
             <if test="feishuAccountId != null">feishu_account_id,</if>
             <if test="feishuAccountId != null">feishu_account_id,</if>
+            <if test="feishuNeedAuth != null">feishu_need_auth,</if>
         </trim>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="link != null">#{link},</if>
             <if test="link != null">#{link},</if>
@@ -83,6 +85,7 @@
             <if test="isRoom != null">#{isRoom},</if>
             <if test="isRoom != null">#{isRoom},</if>
             <if test="chatId != null">#{chatId},</if>
             <if test="chatId != null">#{chatId},</if>
             <if test="feishuAccountId != null">#{feishuAccountId},</if>
             <if test="feishuAccountId != null">#{feishuAccountId},</if>
+            <if test="feishuNeedAuth != null">#{feishuNeedAuth},</if>
         </trim>
         </trim>
     </insert>
     </insert>
 
 
@@ -192,6 +195,7 @@
             <if test="isRoom != null">is_room = #{isRoom},</if>
             <if test="isRoom != null">is_room = #{isRoom},</if>
             <if test="chatId != null">chat_id = #{chatId},</if>
             <if test="chatId != null">chat_id = #{chatId},</if>
             <if test="feishuAccountId != null">feishu_account_id = #{feishuAccountId},</if>
             <if test="feishuAccountId != null">feishu_account_id = #{feishuAccountId},</if>
+            <if test="feishuNeedAuth != null">feishu_need_auth = #{feishuNeedAuth},</if>
         </trim>
         </trim>
         where link_id = #{linkId}
         where link_id = #{linkId}
     </update>
     </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;
 package com.fs.app.controller.course;
 
 
 
 
+import cn.hutool.core.util.IdUtil;
 import cn.hutool.json.JSONUtil;
 import cn.hutool.json.JSONUtil;
+import com.fs.app.param.FeiShuNoAuthInternetTrafficParam;
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.fs.app.annotation.Login;
 import com.fs.app.annotation.Login;
 import com.fs.app.annotation.UserOperationLog;
 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.*;
 import com.fs.course.service.impl.TencentCloudCosService;
 import com.fs.course.service.impl.TencentCloudCosService;
 import com.fs.course.vo.*;
 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.his.domain.FsUser;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
@@ -90,6 +94,10 @@ public class CourseQwController extends AppBaseController {
     private IQwSopService qwSopService;
     private IQwSopService qwSopService;
     @Autowired
     @Autowired
     private IFsUserService userService;
     private IFsUserService userService;
+
+    @Autowired
+    private FeiShuService feiShuService;
+
     @ApiOperation("查询全部公域的课程")
     @ApiOperation("查询全部公域的课程")
     @GetMapping("/getAppletCourse")
     @GetMapping("/getAppletCourse")
     public R 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("答题")
     @ApiOperation("答题")
     @PostMapping("/courseAnswer")
     @PostMapping("/courseAnswer")
     @Login
     @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;
+}