Explorar o código

解决生成缓慢问题

吴树波 hai 2 días
pai
achega
0593dd6870

+ 184 - 71
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -32,6 +32,7 @@ import com.fs.live.domain.LiveWatchLog;
 import com.fs.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwGroupChatUserMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwGroupChatService;
@@ -121,6 +122,14 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     /** his.config 任务级缓存,定时刷新,避免课程链路反复查库解析 */
     private volatile FSSysConfig cachedHisConfig;
 
+    /** 全量查询内存缓存(公司小程序/部门/公司用户/公司列表),55分钟失效,避免每小时任务重试时重复全表扫描 */
+    private volatile long fullQueryCacheTime = 0;
+    private volatile Map<Long, Map<Integer, List<CompanyMiniapp>>> cachedMiniMap;
+    private volatile Map<Long, CompanyDept> cachedDeptMiniAppMap;
+    private volatile Map<Long, Long> cachedCompanyUserDeptMap;
+    private volatile List<Company> cachedCompanies;
+    private static final long FULL_QUERY_CACHE_TTL_MS = 55 * 60 * 1000L;
+
     /** 用户日志并行度(8C 机器且库连接有限,保守控制) */
     private static final int USER_LOG_PARALLELISM = 4;
 
@@ -131,9 +140,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private static final int WRITE_QUEUE_CAPACITY = 50000;
 
     /** 主写入消费者线程数(IO 等待型,略增吞吐,不把连接池打满) */
-    private static final int QW_SOP_LOGS_CONSUMERS = 3;
-    private static final int WATCH_LOG_CONSUMERS = 2;
-    private static final int COURSE_LINK_CONSUMERS = 2;
+    private static final int QW_SOP_LOGS_CONSUMERS = 5;
+    private static final int WATCH_LOG_CONSUMERS = 3;
+    private static final int COURSE_LINK_CONSUMERS = 3;
 
 
     /** 单次刷库目标条数;队列有积压时应尽量凑满再写 */
@@ -268,6 +277,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
 
+    @Autowired
+    private QwGroupChatUserMapper qwGroupChatUserMapper;
+
     /** 用户日志有限并行(避免同类 @Async 自调用失效) */
     @Autowired
     @org.springframework.beans.factory.annotation.Qualifier("sopTaskExecutor")
@@ -447,8 +459,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         String[] array = sopUserLogsVos.stream().map(SopUserLogsVo::getChatId).filter(StringUtils::isNotEmpty).toArray(String[]::new);
         Map<String, QwGroupChat> groupChatMap = new HashMap<>();
         if (array.length > 0) {
-            List<QwGroupChat> qwGroupChatList = qwGroupChatService.selectQwGroupChatByChatIds(array);
-            List<QwGroupChatUser> qwGroupChatUserList = qwGroupChatUserService.selectQwGroupChatUserByChatIds(array);
+            // 群聊和群聊用户分批查询,避免单次 IN 过大拖垮优化器
+            int chatBatchSize = 500;
+            List<QwGroupChat> qwGroupChatList = new ArrayList<>();
+            List<QwGroupChatUser> qwGroupChatUserList = new ArrayList<>();
+            for (int i = 0; i < array.length; i += chatBatchSize) {
+                String[] batch = Arrays.copyOfRange(array, i, Math.min(i + chatBatchSize, array.length));
+                qwGroupChatList.addAll(qwGroupChatService.selectQwGroupChatByChatIds(batch));
+                // 只查 chat_id 和 user_id,走 unique_chat_user 覆盖索引,避免 select * 回表
+                qwGroupChatUserList.addAll(qwGroupChatUserMapper.selectChatIdAndUserIdByChatIds(batch));
+            }
             List<String> groupChatUserIds = PubFun.listToNewList(qwGroupChatUserList, QwGroupChatUser::getUserId);
             if(!groupChatUserIds.isEmpty()){
                 List<GroupUserExternalVo> userList = qwExternalContactMapper.selectByGroupUser(groupChatUserIds);
@@ -465,14 +485,29 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
                 .collect(Collectors.groupingBy(SopUserLogsVo::getSopId));
 
-        // 查询公司关联小程序数据
-        List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
-
-        Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
-        Map<Long, CompanyDept> deptMiniAppMap = MiniAppIdResolver.buildDeptMiniAppMap(companyDeptMapper.queryDeptDataAll());
-        Map<Long, Long> companyUserDeptMap = MiniAppIdResolver.buildCompanyUserDeptMap(companyUserMapper.selectAllCompanyUserList());
-
-        List<Company> companies = companyMapper.selectCompanyAllList();
+        // D 优化:全量查询使用内存缓存,55分钟失效(留5分钟余量,避免跨小时数据不一致)
+        long fullQueryStart = System.currentTimeMillis();
+        long nowMs = System.currentTimeMillis();
+        if (cachedMiniMap == null || nowMs - fullQueryCacheTime > FULL_QUERY_CACHE_TTL_MS) {
+            List<CompanyMiniapp> miniList = companyMiniappService.list(new QueryWrapper<CompanyMiniapp>().orderByAsc("sort_num"));
+            cachedMiniMap = miniList.stream().collect(Collectors.groupingBy(CompanyMiniapp::getCompanyId, Collectors.groupingBy(CompanyMiniapp::getType)));
+            cachedDeptMiniAppMap = MiniAppIdResolver.buildDeptMiniAppMap(companyDeptMapper.queryDeptDataAll());
+            cachedCompanyUserDeptMap = MiniAppIdResolver.buildCompanyUserDeptMap(companyUserMapper.selectAllCompanyUserList());
+            cachedCompanies = companyMapper.selectCompanyAllList();
+            fullQueryCacheTime = nowMs;
+            log.info("全量查询缓存已刷新,耗时 {} 毫秒", System.currentTimeMillis() - fullQueryStart);
+        }
+        Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap = cachedMiniMap;
+        Map<Long, CompanyDept> deptMiniAppMap = cachedDeptMiniAppMap;
+        Map<Long, Long> companyUserDeptMap = cachedCompanyUserDeptMap;
+        List<Company> companies = cachedCompanies;
+
+        // A+B 优化:预加载 QwUser 和 CompanyUser,避免每个 userLog 重复查 Redis
+        long preloadStart = System.currentTimeMillis();
+        Map<String, QwUser> qwUserMap = preloadQwUsers(sopUserLogsVos);
+        Map<Long, CompanyUser> companyUserMap = preloadCompanyUsers(qwUserMap);
+        log.info("预加载 QwUser {} 条、CompanyUser {} 条,耗时 {} 毫秒",
+                qwUserMap.size(), companyUserMap.size(), System.currentTimeMillis() - preloadStart);
 
         log.info("共分组 {} 个 SOP ID 进行处理。", sopLogsGroupedById.size());
 
@@ -482,7 +517,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             List<SopUserLogsVo> userLogsVos = entry.getValue();
             try {
                 processSopGroup(sopId, userLogsVos, currentTime, groupChatMap, config, miniMap, companies,
-                        deptMiniAppMap, companyUserDeptMap);
+                        deptMiniAppMap, companyUserDeptMap, qwUserMap, companyUserMap);
             } catch (Exception e) {
                 log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
             }
@@ -504,9 +539,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     )
     public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch ,LocalDateTime currentTime,
                                      Map<String, QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                     List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
+                                     List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
+                                     Map<String, QwUser> qwUserMap, Map<Long, CompanyUser> companyUserMap) {
         try {
-            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
+            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap,
+                    qwUserMap, companyUserMap);
         } catch (Exception e) {
             log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
         } finally {
@@ -517,7 +554,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
                                          QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                 List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) throws Exception {
+                                 List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
+                                 Map<String, QwUser> qwUserMap, Map<Long, CompanyUser> companyUserMap) throws Exception {
         QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
 
         if (ruleTimeVO == null) {
@@ -570,6 +608,21 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 infoMapByUserLogsId.size(),
                 System.currentTimeMillis() - preloadStart);
 
+        // C 优化:SOP 级预加载外部联系人,避免每个 userLog 的 insertSopUserLogs 重复分批 IN 查
+        Map<Long, QwExternalContact> sopContactMap = Collections.emptyMap();
+        if (CollectionUtils.isNotEmpty(allInfos)) {
+            long contactPreloadStart = System.currentTimeMillis();
+            List<Long> extIds = allInfos.stream()
+                    .map(SopUserLogsInfo::getExternalId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+            sopContactMap = loadExternalContactsByIds(extIds);
+            log.info("SOP ID {} 预加载外部联系人 {} 条,耗时 {} 毫秒",
+                    sopId, sopContactMap.size(), System.currentTimeMillis() - contactPreloadStart);
+        }
+        final Map<Long, QwExternalContact> finalSopContactMap = sopContactMap;
+
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
         Semaphore userLogSemaphore = new Semaphore(USER_LOG_PARALLELISM);
         FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forAutoSop(config);
@@ -586,7 +639,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 try {
                     processUserLog(logVo, ruleTimeVO, rulesList, currentTime, groupChatMap, qwCompany.getMiniAppId(),
                             config, miniMap, companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator,
-                            infoMapByUserLogsId);
+                            infoMapByUserLogsId, qwUserMap, companyUserMap, finalSopContactMap);
                 } catch (Exception e) {
                     log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
                 } finally {
@@ -617,10 +670,13 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                     String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                     List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
                                     FeishuDirectDocShardAllocator feishuShardAllocator,
-                                    Map<String, List<SopUserLogsInfo>> infoMapByUserLogsId) {
+                                    Map<String, List<SopUserLogsInfo>> infoMapByUserLogsId,
+                                    Map<String, QwUser> qwUserMap, Map<Long, CompanyUser> companyUserMap,
+                                    Map<Long, QwExternalContact> sopContactMap) {
         try {
             processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies,
-                    deptMiniAppMap, companyUserDeptMap, feishuShardAllocator, infoMapByUserLogsId);
+                    deptMiniAppMap, companyUserDeptMap, feishuShardAllocator, infoMapByUserLogsId,
+                    qwUserMap, companyUserMap, sopContactMap);
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
         } finally {
@@ -634,7 +690,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                 CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                 List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
                                 FeishuDirectDocShardAllocator feishuShardAllocator,
-                                Map<String, List<SopUserLogsInfo>> infoMapByUserLogsId) {
+                                Map<String, List<SopUserLogsInfo>> infoMapByUserLogsId,
+                                Map<String, QwUser> qwUserMap, Map<Long, CompanyUser> companyUserMap,
+                                Map<Long, QwExternalContact> sopContactMap) {
         try {
 
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
@@ -681,7 +739,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
 
             //获取企业微信员工的称呼//从redis里或者从库里取
-            QwUser qwUserByRedis = qwExternalContactService.getQwUserByRedis(logVo.getCorpId(),logVo.getQwUserId());
+            // A 优化:优先从预加载 Map 获取 QwUser,未命中再回退查 Redis(保持原逻辑)
+            String qwUserKey = logVo.getCorpId() + ":" + logVo.getQwUserId();
+            QwUser qwUserByRedis = qwUserMap != null ? qwUserMap.get(qwUserKey) : null;
+            if (qwUserByRedis == null) {
+                qwUserByRedis = qwExternalContactService.getQwUserByRedis(logVo.getCorpId(), logVo.getQwUserId());
+            }
             if (qwUserByRedis==null){
                 log.error("无企微员工信息 {} 跳过处理。:{}", logVo.getUserId(),logVo.getCorpId());
                 return;
@@ -697,7 +760,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 return;
             }
 
-            CompanyUser companyUser = companyUserService.selectCompanyUserByIdForRedis(Long.valueOf(companyUserId));
+            // B 优化:优先从预加载 Map 获取 CompanyUser,未命中再回退查 Redis(保持原逻辑)
+            CompanyUser companyUser = companyUserMap != null ? companyUserMap.get(Long.valueOf(companyUserId)) : null;
+            if (companyUser == null) {
+                companyUser = companyUserService.selectCompanyUserByIdForRedis(Long.valueOf(companyUserId));
+            }
             if (Objects.nonNull(companyUser)) {
                 if (!StringUtil.strIsNullOrEmpty(companyUser.getDomain())) {
                     logVo.setDomain(companyUser.getDomain().trim());
@@ -899,7 +966,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
                                 companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
                                 groupChatMap, miniAppId,config,miniMap, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap,
-                                feishuShardAllocator);
+                                feishuShardAllocator, sopContactMap);
 
                     }
                 } catch (Exception e) {
@@ -913,6 +980,61 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     }
 
 
+    /**
+     * A 优化:预加载 QwUser,按 corpId:qwUserId 去重后逐个查 Redis/DB,构建 Map。
+     * 同一销售对应多个 userLog 时只查一次,避免并行线程内重复查 Redis。
+     */
+    private Map<String, QwUser> preloadQwUsers(List<SopUserLogsVo> sopUserLogsVos) {
+        if (CollectionUtils.isEmpty(sopUserLogsVos)) {
+            return Collections.emptyMap();
+        }
+        Set<String> keys = sopUserLogsVos.stream()
+                .filter(e -> StringUtils.isNotEmpty(e.getCorpId()) && StringUtils.isNotEmpty(e.getQwUserId()))
+                .map(e -> e.getCorpId() + ":" + e.getQwUserId())
+                .collect(Collectors.toSet());
+        Map<String, QwUser> map = new HashMap<>(keys.size());
+        for (String key : keys) {
+            int idx = key.indexOf(':');
+            String corpId = key.substring(0, idx);
+            String userId = key.substring(idx + 1);
+            try {
+                QwUser qwUser = qwExternalContactService.getQwUserByRedis(corpId, userId);
+                if (qwUser != null) {
+                    map.put(key, qwUser);
+                }
+            } catch (Exception e) {
+                log.error("预加载 QwUser 失败, corpId={}, userId={}", corpId, userId, e);
+            }
+        }
+        return map;
+    }
+
+    /**
+     * B 优化:从已加载的 QwUser 中收集 companyUserId,去重后逐个查 Redis/DB,构建 Map。
+     */
+    private Map<Long, CompanyUser> preloadCompanyUsers(Map<String, QwUser> qwUserMap) {
+        if (qwUserMap == null || qwUserMap.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Set<Long> companyUserIds = qwUserMap.values().stream()
+                .map(QwUser::getCompanyUserId)
+                .filter(Objects::nonNull)
+                .filter(id -> id > 0)
+                .collect(Collectors.toSet());
+        Map<Long, CompanyUser> map = new HashMap<>(companyUserIds.size());
+        for (Long id : companyUserIds) {
+            try {
+                CompanyUser companyUser = companyUserService.selectCompanyUserByIdForRedis(id);
+                if (companyUser != null) {
+                    map.put(id, companyUser);
+                }
+            } catch (Exception e) {
+                log.error("预加载 CompanyUser 失败, companyUserId={}", id, e);
+            }
+        }
+        return map;
+    }
+
     /**
      * 按主键分批加载企微客户。单次 IN 不超过 CONTACT_IN_CHUNK_SIZE,避免大 IN 拖垮优化器。
      */
@@ -1002,7 +1124,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                    Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
                                    Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
                                    List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
-                                   FeishuDirectDocShardAllocator feishuShardAllocator) {
+                                   FeishuDirectDocShardAllocator feishuShardAllocator,
+                                   Map<Long, QwExternalContact> sopContactMap) {
         String formattedSendTime = sendTime.toInstant()
                 .atZone(ZoneId.systemDefault())
                 .format(DATE_TIME_FORMATTER);
@@ -1108,14 +1231,20 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             List<SopUserLogsInfo> skippedCustomers = new ArrayList<>();
 
             // 课程消息需要客户称呼等字段:按 externalId 分批 IN 预加载,避免逐条 selectById
-            Map<Long, QwExternalContact> contactMap = Collections.emptyMap();
-            if (type == 2 && CollectionUtils.isNotEmpty(sopUserLogsInfos)) {
-                List<Long> extIds = sopUserLogsInfos.stream()
-                        .map(SopUserLogsInfo::getExternalId)
-                        .filter(Objects::nonNull)
-                        .distinct()
-                        .collect(Collectors.toList());
-                contactMap = loadExternalContactsByIds(extIds);
+            // C 优化:优先使用 SOP 级预加载的外部联系人 Map,未命中再回退分批 IN 查(保持原逻辑)
+            Map<Long, QwExternalContact> contactMap;
+            if (sopContactMap != null && !sopContactMap.isEmpty()) {
+                contactMap = sopContactMap;
+            } else {
+                contactMap = Collections.emptyMap();
+                if (type == 2 && CollectionUtils.isNotEmpty(sopUserLogsInfos)) {
+                    List<Long> extIds = sopUserLogsInfos.stream()
+                            .map(SopUserLogsInfo::getExternalId)
+                            .filter(Objects::nonNull)
+                            .distinct()
+                            .collect(Collectors.toList());
+                    contactMap = loadExternalContactsByIds(extIds);
+                }
             }
 
             Map<Long, QwExternalContact> finalContactMap = contactMap;
@@ -1838,16 +1967,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
                     break;
                 case "17":
-                    // 飞书看课链接(旧):固定免授权发送,不再按模板授权开关分支
+                    // 飞书看课(固定免授权):一批客户复用同一飞书文档链接;不再走授权/领取页(领取页是 contentType=18)
                     setting.setFeishuNeedAuth(0);
-                    // 飞书看课链接:失败仅记录日志并跳过本条,不阻塞整批 SOP 生成
+                    // 失败仅记录日志并跳过本条,不阻塞整批 SOP 生成
                     try {
                         Long companyIdLong = Long.parseLong(companyId);
                         Long companyUserIdLong = Long.parseLong(companyUserId);
                         Long externalIdLong = null;
                         String chatIdForFeishu = null;
                         if (isGroupChat) {
-                            // 群聊飞书发课:按群成员写看课记录(对齐小程序 case 4);发送仍只有一条群消息
+                            // 群聊飞书发课:按群成员写看课记录;群消息仍发一条共用飞书链接
                             try {
                                 groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
                                     Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
@@ -1872,46 +2001,32 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                         String shortLink = feiShuLinkMap.get("link");
-
-                        String feishuLink;
-                        if (isGroupChat) {
-                            // 群:发课时不建飞书文档,只发领取页;领取时按当前用户生成独立飞书链接
-                            feishuLink = buildFeishuClaimPageUrl(shortLink, cachedCourseConfig);
-                            if (StringUtils.isEmpty(feishuLink)) {
-                                log.error("生成飞书群领取链接失败,跳过本条: sopId={}, videoId={}, chatId={}",
-                                        logVo.getSopId(), videoId, chatIdForFeishu);
-                                recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
-                                        companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
-                                        "生成飞书群领取链接失败");
-                                return;
-                            }
-                        } else {
-                            Integer directDocShard = null;
-                            if (feishuShardAllocator != null) {
-                                directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
-                                        setting.getFeishuAccountId());
-                            }
-                            feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
-                                    shortLink, setting.getFeishuAccountId(), 0, directDocShard, fsUserId);
-                            if (StringUtils.isEmpty(feishuLink)) {
-                                log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
-                                        logVo.getSopId(), videoId, externalId);
-                                recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
-                                        companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
-                                        "生成飞书注册链接失败");
-                                return;
-                            }
+                        Integer directDocShard = null;
+                        if (feishuShardAllocator != null) {
+                            directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
+                                    setting.getFeishuAccountId());
+                        }
+                        // 免授权直出飞书文档;同销售+课节+账号+天+分片复用,不传 fsUserId
+                        String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
+                                shortLink, setting.getFeishuAccountId(), 0, directDocShard, null);
+                        if (StringUtils.isEmpty(feishuLink)) {
+                            log.error("生成飞书免授权链接失败,跳过本条: sopId={}, videoId={}, externalId={}, chatId={}",
+                                    logVo.getSopId(), videoId, externalId, chatIdForFeishu);
+                            recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
+                                    companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
+                                    "生成飞书免授权链接失败");
+                            return;
                         }
                         setting.setLinkUrl(feishuLink);
                         setting.setIsBindUrl("2");
                         // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
                         fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
-                        log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
+                        log.error("生成飞书免授权链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
                         recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
                                 companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
-                                "生成飞书注册链接失败: " + e.getMessage());
+                                "生成飞书免授权链接失败: " + e.getMessage());
                         return;
                     }
                     break;
@@ -2339,8 +2454,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     }
 
     /**
-     * 群飞书(17):发课时只拼领取页,不调飞书建文档;领取时再按当前用户生成独立飞书链接
-     * 格式与飞书NEW一致:{realLinkDomainName}/?uuid={shortLink}&appid={feishuMpAppId}
+     * 拼飞书领取页(仅 contentType=18 使用)
+     * 格式:{realLinkDomainName}/?uuid={shortLink}&appid={feishuMpAppId}
      */
     private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
         if (StringUtils.isEmpty(shortLink) || config == null
@@ -2610,7 +2725,6 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
             if (!offered) {
                 log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
             }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
@@ -2626,7 +2740,6 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
             if (!offered) {
                 log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
             }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();

+ 26 - 18
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -109,7 +109,7 @@ public class FeiShuService {
 
     /**
      * @param directDocShard SOP 分片序号,null 表示非分片
-     * @param fsUserId       看课会员 ID,写入飞书 iframe 的 userId;群场景必须传本人 ID,避免多人共用同一文档身份
+     * @param fsUserId       兼容参数;免授权(17)批量复用时传 null,iframe 固定 userId=0
      */
     public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
@@ -190,7 +190,7 @@ public class FeiShuService {
     }
 
     /**
-     * @param directDocShard SOP 分片序号,null 时按短链隔离文档(同一短链当天可复用)
+     * @param directDocShard SOP 分片序号,null 时按销售+课程+账号+日期复用同一文档
      */
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
@@ -200,7 +200,10 @@ public class FeiShuService {
     }
 
     /**
-     * @param fsUserId 看课会员 ID,嵌入 iframe 的 userId;为空时按 0 处理(兼容旧调用)
+     * 生成飞书免授权看课链接(发课时直接嵌入播放页 iframe 文档)。
+     * contentType=17 一批客户共用链接:缓存按销售+课节+账号+天+分片复用;fsUserId 对免授权复用不生效(iframe 固定 0)。
+     *
+     * @param fsUserId 兼容旧调用,免授权批量场景传 null 即可
      */
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
@@ -409,20 +412,20 @@ public class FeiShuService {
     }
 
     /**
-     * 按短链 + 领取用户隔离免授权飞书文档:
-     * 私聊短链本身一人一条;群共享短链相同,必须再按 embedUserId 区分,否则 iframe 内 userId 会串号
+     * 同一销售、同一节课、同一飞书账号、同一天(及分片)内复用免授权飞书文档。
+     * contentType=17:一批客户共用一个飞书链接,iframe 固定 userId=0(免授权不按人隔离)
      */
     private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  Long videoId, String shortLink, int questionFlag,
                                                  String documentTitle, Integer directDocShard,
                                                  long embedUserId) throws Exception {
-        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId, directDocShard,
-                shortLink, embedUserId);
+        // 免授权批量复用:缓存维度不含 shortLink/userId;embedUserId 入参保留兼容,建文档时固定用 0
+        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId, directDocShard);
         String cached = redisCache.getCacheObject(cacheKey);
         if (StringUtils.isNotBlank(cached)) {
-            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shortLink={}, userId={}",
-                    companyUserId, videoId, feishuAccountId, shortLink, embedUserId);
+            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shard={}, docUrl={}",
+                    companyUserId, videoId, feishuAccountId, directDocShard, cached);
             return cached;
         }
 
@@ -461,7 +464,8 @@ public class FeiShuService {
                 return cached;
             }
 
-            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, embedUserId, shortLink, questionFlag);
+            // 免授权共用文档:iframe userId 固定 0(与历史非授权逻辑一致)
+            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
             String documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
             String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             int ttlSeconds = getDirectDocCacheTtlSeconds();
@@ -469,8 +473,8 @@ public class FeiShuService {
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
-            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shortLink={}, userId={}, docUrl={}",
-                    companyUserId, videoId, feishuAccountId, shortLink, embedUserId, resultUrl);
+            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, shard={}, shortLink={}, docUrl={}",
+                    companyUserId, videoId, feishuAccountId, directDocShard, shortLink, resultUrl);
             return resultUrl;
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
@@ -602,16 +606,20 @@ public class FeiShuService {
     }
 
     /**
-     * 免授权文档缓存:fs_course_link.link + embedUserId(发群共用一条 link,领取时按用户区分)
+     * 免授权文档缓存:销售 + 课节 + 账号 + 当天 [+ 分片]。
+     * 一批客户复用同一飞书链接(contentType=17),与 shortLink / userId 无关。
      */
     private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId,
-                                           Integer directDocShard, String shortLink, long embedUserId) {
-        // 与领取约定一致:短链标识 + 用户id;分片仅用于一键群发刷新场景
-        String linkPart = StringUtils.isNotBlank(shortLink) ? shortLink.trim() : "0";
+                                           Integer directDocShard) {
+        Long courseIdVal = courseId != null ? courseId : 0L;
+        String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
         if (directDocShard == null) {
-            return String.format("%s%s:%d", FEISHU_DIRECT_DOC_CACHE_PREFIX, linkPart, embedUserId);
+            return String.format("%s%d:%d:%d:%d:%s",
+                    FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
         }
-        return String.format("%s%s:%d:%d", FEISHU_DIRECT_DOC_CACHE_PREFIX, linkPart, embedUserId, directDocShard);
+        return String.format("%s%d:%d:%d:%d:%s:%d",
+                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey,
+                directDocShard);
     }
 
     private int getDirectDocCacheTtlSeconds() {

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatUserMapper.java

@@ -160,6 +160,9 @@ public interface QwGroupChatUserMapper
 
     List<QwGroupChatUser> selectQwGroupChatUserByChatIds(@Param("ids") String[] ids);
 
+    /** 只查 chat_id 和 user_id,走 unique_chat_user 覆盖索引,避免 select * 回表 */
+    List<QwGroupChatUser> selectChatIdAndUserIdByChatIds(@Param("ids") String[] ids);
+
     List<QwGroupChatUser> selectUserIsChat(@Param("externalUserId") String externalUserId);
 
     List<QwGroupChatUser> selectByChatId(SopUserLogsInfo sopUserLogsInfo);

+ 27 - 18
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -793,7 +793,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             case "16":
                                 createVoiceUrl(st, companyUserId, qwSop);
                                 break;
-                            //飞书看课(固定免授权)
+                            //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                             case "17":
                                 try {
                                     st.setFeishuNeedAuth(0);
@@ -815,7 +815,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     String shortCode = feishuH5Link.get("link");
                                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                                             companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
-                                            param.getSopId(), null, externalUserId, vo.getFsUserId(),
+                                            param.getSopId(), null, externalUserId, null,
                                             qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
                                             param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                     if (StringUtils.isNotEmpty(feishuLink)) {
@@ -1059,7 +1059,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                 }
                                 break;
-                            //飞书看课(发群:只发一条领取页;领取时按人建飞书链接,发课时不建文档
+                            //飞书看课(发群:固定免授权,一条群消息共用一个飞书文档链接
                             case "17":
                                 try {
                                     st.setFeishuNeedAuth(0);
@@ -1073,12 +1073,17 @@ 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 claimPageUrlGroup = buildFeishuClaimPageUrl(shortCodeGroup, config);
-                                    if (StringUtils.isNotEmpty(claimPageUrlGroup)) {
-                                        st.setLinkUrl(claimPageUrlGroup);
+                                    String feishuLinkGroup = resolveFeishuSendLinkSafely(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(),
+                                            companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), 0,
+                                            param.getSopId(), null, null, null,
+                                            qwUser.getQwUserId(), qwUser.getQwUserName(),
+                                            String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
+                                            param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
+                                    if (StringUtils.isNotEmpty(feishuLinkGroup)) {
+                                        st.setLinkUrl(feishuLinkGroup);
                                         fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                                     } else {
-                                        log.error("飞书发群领取页为空,仍写入发送记录: sopId={}, chatId={}", param.getSopId(), groupChat.getChatId());
+                                        log.error("飞书发群免授权链接为空,仍写入发送记录: sopId={}, chatId={}", param.getSopId(), groupChat.getChatId());
                                     }
                                 } catch (Exception e) {
                                     log.error("飞书发群链接生成异常,仍写入发送记录: sopId={}, chatId={}, err={}",
@@ -1406,7 +1411,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 throw new RuntimeException(e);
                             }
                             break;
-                        //飞书看课(固定免授权)
+                        //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                         case "17":
                             st.setFeishuNeedAuth(0);
                             Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
@@ -1437,7 +1442,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             String shortCode = feishuH5Link.get("link");
                             String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                                     companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
-                                    param.getSopId(), item.getUserLogsId(), item.getExternalId(), item.getFsUserId(),
+                                    param.getSopId(), item.getUserLogsId(), item.getExternalId(), null,
                                     qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                             if (StringUtils.isNotEmpty(feishuLink)) {
@@ -2255,7 +2260,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     log.info("处理订阅号文章类型(processSetting),保留字段: articleUrl={}, miniprogramPicUrl={}, miniprogramPage={}",
                             st.getArticleUrl(), st.getMiniprogramPicUrl(), st.getMiniprogramPage());
                     break;
-                //飞书看课(固定免授权)
+                //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                 case "17":
                     st.setFeishuNeedAuth(0);
                     Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
@@ -2283,7 +2288,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     String shortLink = feishuH5Link.get("link");
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                             companyUserIdLong, shortLink, st.getFeishuAccountId(), 0,
-                            item.getSopId(), item.getUserLogsId(), externalId, item.getFsUserId(),
+                            item.getSopId(), item.getUserLogsId(), externalId, null,
                             String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
                             param.getCorpId(), dataTime, item.getStartTime(), feishuShardAllocator);
                     if (StringUtils.isNotEmpty(feishuLink)) {
@@ -2345,7 +2350,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     }
 
     /**
-     * 群飞书(17):发课时只拼领取页,不调飞书建文档;领取时再按当前用户生成独立飞书链接
+     * 拼飞书领取页(仅 contentType=18 使用)
      */
     private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
         if (StringUtils.isEmpty(shortLink) || config == null
@@ -2952,6 +2957,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return miniAppId;
     }
 
+    /**
+     * contentType=17 专用:固定免授权,一批客户复用飞书文档;不走 18 的领取页。
+     */
     private String resolveFeishuSendLinkSafely(Long videoId, Long companyId, Long courseId,
                                                Long companyUserId, String shortLink, Long feishuAccountId,
                                                Integer feishuNeedAuth, String sopId, String userLogsId,
@@ -2960,30 +2968,31 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                Date sendTime, String elementTime,
                                                FeishuDirectDocShardAllocator feishuShardAllocator) {
         try {
+            // 17 已取消授权模式,无论入参如何一律免授权
             Integer directDocShard = null;
-            if (feishuShardAllocator != null && !FeiShuService.isAuthRequired(feishuNeedAuth)) {
+            if (feishuShardAllocator != null) {
                 if (feishuShardAllocator.markDirectDocCacheForRefresh(companyUserId, videoId, courseId, feishuAccountId)) {
                     feiShuService.invalidateDirectDocCache(companyUserId, videoId, courseId, feishuAccountId);
                 }
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
             }
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
-                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard, fsUserId);
+                    shortLink, feishuAccountId, 0, directDocShard, null);
             if (StringUtils.isEmpty(feishuLink)) {
-                log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
+                log.error("生成飞书免授权链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                         sopId, videoId, externalId);
                 recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
                         companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
-                        "生成飞书注册链接失败");
+                        "生成飞书免授权链接失败");
                 return null;
             }
             return feishuLink;
         } catch (Exception e) {
-            log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
+            log.error("生成飞书免授权链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                     sopId, videoId, externalId, e);
             recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
                     companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
-                    "生成飞书注册链接失败: " + e.getMessage());
+                    "生成飞书免授权链接失败: " + e.getMessage());
             return null;
         }
     }

+ 3 - 0
fs-service/src/main/resources/mapper/qw/QwGroupChatUserMapper.xml

@@ -59,6 +59,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectQwGroupChatUserByChatIds" resultType="com.fs.qw.domain.QwGroupChatUser">
         select * from qw_group_chat_user where is_out = 1 and type = 2 and chat_id in <foreach collection="ids" open="(" separator="," close=")" item="item">#{item}</foreach>
     </select>
+    <select id="selectChatIdAndUserIdByChatIds" resultType="com.fs.qw.domain.QwGroupChatUser">
+        select chat_id, user_id from qw_group_chat_user where is_out = 1 and type = 2 and chat_id in <foreach collection="ids" open="(" separator="," close=")" item="item">#{item}</foreach>
+    </select>
     <select id="selectUserIsChat" resultType="com.fs.qw.domain.QwGroupChatUser">
         select * from qw_group_chat_user a where a.user_id = #{externalUserId}
     </select>