吴树波 3 дней назад
Родитель
Сommit
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.live.mapper.LiveWatchLogMapper;
 import com.fs.qw.domain.*;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwGroupChatUserMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwGroupChatService;
 import com.fs.qw.service.IQwGroupChatService;
@@ -121,6 +122,14 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     /** his.config 任务级缓存,定时刷新,避免课程链路反复查库解析 */
     /** his.config 任务级缓存,定时刷新,避免课程链路反复查库解析 */
     private volatile FSSysConfig cachedHisConfig;
     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 机器且库连接有限,保守控制) */
     /** 用户日志并行度(8C 机器且库连接有限,保守控制) */
     private static final int USER_LOG_PARALLELISM = 4;
     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;
     private static final int WRITE_QUEUE_CAPACITY = 50000;
 
 
     /** 主写入消费者线程数(IO 等待型,略增吞吐,不把连接池打满) */
     /** 主写入消费者线程数(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
     @Autowired
     private QwExternalContactMapper qwExternalContactMapper;
     private QwExternalContactMapper qwExternalContactMapper;
 
 
+    @Autowired
+    private QwGroupChatUserMapper qwGroupChatUserMapper;
+
     /** 用户日志有限并行(避免同类 @Async 自调用失效) */
     /** 用户日志有限并行(避免同类 @Async 自调用失效) */
     @Autowired
     @Autowired
     @org.springframework.beans.factory.annotation.Qualifier("sopTaskExecutor")
     @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);
         String[] array = sopUserLogsVos.stream().map(SopUserLogsVo::getChatId).filter(StringUtils::isNotEmpty).toArray(String[]::new);
         Map<String, QwGroupChat> groupChatMap = new HashMap<>();
         Map<String, QwGroupChat> groupChatMap = new HashMap<>();
         if (array.length > 0) {
         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);
             List<String> groupChatUserIds = PubFun.listToNewList(qwGroupChatUserList, QwGroupChatUser::getUserId);
             if(!groupChatUserIds.isEmpty()){
             if(!groupChatUserIds.isEmpty()){
                 List<GroupUserExternalVo> userList = qwExternalContactMapper.selectByGroupUser(groupChatUserIds);
                 List<GroupUserExternalVo> userList = qwExternalContactMapper.selectByGroupUser(groupChatUserIds);
@@ -465,14 +485,29 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
         Map<String, List<SopUserLogsVo>> sopLogsGroupedById = sopUserLogsVos.stream()
                 .collect(Collectors.groupingBy(SopUserLogsVo::getSopId));
                 .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());
         log.info("共分组 {} 个 SOP ID 进行处理。", sopLogsGroupedById.size());
 
 
@@ -482,7 +517,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             List<SopUserLogsVo> userLogsVos = entry.getValue();
             List<SopUserLogsVo> userLogsVos = entry.getValue();
             try {
             try {
                 processSopGroup(sopId, userLogsVos, currentTime, groupChatMap, config, miniMap, companies,
                 processSopGroup(sopId, userLogsVos, currentTime, groupChatMap, config, miniMap, companies,
-                        deptMiniAppMap, companyUserDeptMap);
+                        deptMiniAppMap, companyUserDeptMap, qwUserMap, companyUserMap);
             } catch (Exception e) {
             } catch (Exception e) {
                 log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), 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,
     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,
                                      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 {
         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) {
         } catch (Exception e) {
             log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
             log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
         } finally {
         } finally {
@@ -517,7 +554,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
 
     private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
     private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
                                          QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                          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);
         QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
 
 
         if (ruleTimeVO == null) {
         if (ruleTimeVO == null) {
@@ -570,6 +608,21 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 infoMapByUserLogsId.size(),
                 infoMapByUserLogsId.size(),
                 System.currentTimeMillis() - preloadStart);
                 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());
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
         Semaphore userLogSemaphore = new Semaphore(USER_LOG_PARALLELISM);
         Semaphore userLogSemaphore = new Semaphore(USER_LOG_PARALLELISM);
         FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forAutoSop(config);
         FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forAutoSop(config);
@@ -586,7 +639,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 try {
                 try {
                     processUserLog(logVo, ruleTimeVO, rulesList, currentTime, groupChatMap, qwCompany.getMiniAppId(),
                     processUserLog(logVo, ruleTimeVO, rulesList, currentTime, groupChatMap, qwCompany.getMiniAppId(),
                             config, miniMap, companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator,
                             config, miniMap, companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator,
-                            infoMapByUserLogsId);
+                            infoMapByUserLogsId, qwUserMap, companyUserMap, finalSopContactMap);
                 } catch (Exception e) {
                 } catch (Exception e) {
                     log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
                     log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
                 } finally {
                 } finally {
@@ -617,10 +670,13 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                     String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                     String miniAppId,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,
                                     FeishuDirectDocShardAllocator feishuShardAllocator,
                                     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 {
         try {
             processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies,
             processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies,
-                    deptMiniAppMap, companyUserDeptMap, feishuShardAllocator, infoMapByUserLogsId);
+                    deptMiniAppMap, companyUserDeptMap, feishuShardAllocator, infoMapByUserLogsId,
+                    qwUserMap, companyUserMap, sopContactMap);
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
         } finally {
         } finally {
@@ -634,7 +690,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                 CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                 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,
                                 FeishuDirectDocShardAllocator feishuShardAllocator,
                                 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 {
         try {
 
 
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
@@ -681,7 +739,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
 
 
 
             //获取企业微信员工的称呼//从redis里或者从库里取
             //获取企业微信员工的称呼//从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){
             if (qwUserByRedis==null){
                 log.error("无企微员工信息 {} 跳过处理。:{}", logVo.getUserId(),logVo.getCorpId());
                 log.error("无企微员工信息 {} 跳过处理。:{}", logVo.getUserId(),logVo.getCorpId());
                 return;
                 return;
@@ -697,7 +760,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                 return;
                 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 (Objects.nonNull(companyUser)) {
                 if (!StringUtil.strIsNullOrEmpty(companyUser.getDomain())) {
                 if (!StringUtil.strIsNullOrEmpty(companyUser.getDomain())) {
                     logVo.setDomain(companyUser.getDomain().trim());
                     logVo.setDomain(companyUser.getDomain().trim());
@@ -899,7 +966,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                         insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
                         insertSopUserLogs(sopUserLogsInfos, logVo, sendTime, ruleTimeVO, content, qwUserId,
                                 companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
                                 companyUserId, companyId, qwUserByRedis.getWelcomeText(),qwUserByRedis.getQwUserName(),
                                 groupChatMap, miniAppId,config,miniMap, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap,
                                 groupChatMap, miniAppId,config,miniMap, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap,
-                                feishuShardAllocator);
+                                feishuShardAllocator, sopContactMap);
 
 
                     }
                     }
                 } catch (Exception e) {
                 } 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 拖垮优化器。
      * 按主键分批加载企微客户。单次 IN 不超过 CONTACT_IN_CHUNK_SIZE,避免大 IN 拖垮优化器。
      */
      */
@@ -1002,7 +1124,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                    Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
                                    Map<String, QwGroupChat> groupChatMap,String miniAppId,CourseConfig config,
                                    Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
                                    Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap, Integer sendMsgType,
                                    List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
                                    List<Company> companies, Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
-                                   FeishuDirectDocShardAllocator feishuShardAllocator) {
+                                   FeishuDirectDocShardAllocator feishuShardAllocator,
+                                   Map<Long, QwExternalContact> sopContactMap) {
         String formattedSendTime = sendTime.toInstant()
         String formattedSendTime = sendTime.toInstant()
                 .atZone(ZoneId.systemDefault())
                 .atZone(ZoneId.systemDefault())
                 .format(DATE_TIME_FORMATTER);
                 .format(DATE_TIME_FORMATTER);
@@ -1108,14 +1231,20 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             List<SopUserLogsInfo> skippedCustomers = new ArrayList<>();
             List<SopUserLogsInfo> skippedCustomers = new ArrayList<>();
 
 
             // 课程消息需要客户称呼等字段:按 externalId 分批 IN 预加载,避免逐条 selectById
             // 课程消息需要客户称呼等字段:按 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;
             Map<Long, QwExternalContact> finalContactMap = contactMap;
@@ -1838,16 +1967,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
 
                     break;
                     break;
                 case "17":
                 case "17":
-                    // 飞书看课链接(旧):固定免授权发送,不再按模板授权开关分支
+                    // 飞书看课(固定免授权):一批客户复用同一飞书文档链接;不再走授权/领取页(领取页是 contentType=18)
                     setting.setFeishuNeedAuth(0);
                     setting.setFeishuNeedAuth(0);
-                    // 飞书看课链接:失败仅记录日志并跳过本条,不阻塞整批 SOP 生成
+                    // 失败仅记录日志并跳过本条,不阻塞整批 SOP 生成
                     try {
                     try {
                         Long companyIdLong = Long.parseLong(companyId);
                         Long companyIdLong = Long.parseLong(companyId);
                         Long companyUserIdLong = Long.parseLong(companyUserId);
                         Long companyUserIdLong = Long.parseLong(companyUserId);
                         Long externalIdLong = null;
                         Long externalIdLong = null;
                         String chatIdForFeishu = null;
                         String chatIdForFeishu = null;
                         if (isGroupChat) {
                         if (isGroupChat) {
-                            // 群聊飞书发课:按群成员写看课记录(对齐小程序 case 4);发送仍只有一条群消息
+                            // 群聊飞书发课:按群成员写看课记录;群消息仍发一条共用飞书链接
                             try {
                             try {
                                 groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
                                 groupChat.getChatUserList().stream().filter(e -> e.getUserList() != null && !e.getUserList().isEmpty()).forEach(e -> {
                                     Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
                                     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,
                         Map<String, String> feiShuLinkMap = createFeiShuLinkByMiniApp(setting, sopLogs.getCorpId(), sendTime, courseId, videoId,
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                         String shortLink = feiShuLinkMap.get("link");
                         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.setLinkUrl(feishuLink);
                         setting.setIsBindUrl("2");
                         setting.setIsBindUrl("2");
                         // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
                         // 链接卡片标题/描述/封面为空时兜底,避免 PAD 发出空卡片
                         fillFeishuLinkCardDefaults(setting, videoId, courseId);
                         fillFeishuLinkCardDefaults(setting, videoId, courseId);
                     } catch (Exception e) {
                     } catch (Exception e) {
-                        log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
+                        log.error("生成飞书免授权链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                                 logVo.getSopId(), videoId, externalId, e);
                                 logVo.getSopId(), videoId, externalId, e);
                         recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
                         recordFeishuLinkGenerationFailed(logVo, sendTime, content.getTime(), qwUserId, qwUserName,
                                 companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
                                 companyUserId, companyId, logVo.getCorpId(), sopLogs.getExternalId(), fsUserId, videoId,
-                                "生成飞书注册链接失败: " + e.getMessage());
+                                "生成飞书免授权链接失败: " + e.getMessage());
                         return;
                         return;
                     }
                     }
                     break;
                     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) {
     private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
         if (StringUtils.isEmpty(shortLink) || config == null
         if (StringUtils.isEmpty(shortLink) || config == null
@@ -2610,7 +2725,6 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
             boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
             if (!offered) {
             if (!offered) {
                 log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
                 log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
             }
             }
         } catch (InterruptedException e) {
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             Thread.currentThread().interrupt();
@@ -2626,7 +2740,6 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
             boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
             if (!offered) {
             if (!offered) {
                 log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
                 log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
             }
             }
         } catch (InterruptedException e) {
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             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 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,
     public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
                                         Long companyUserId, String shortLink, Long feishuAccountId,
                                         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,
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
                                             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,
     public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
                                             Long companyUserId, String shortLink, Long feishuAccountId,
                                             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,
     private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  Long companyUserId, Long companyId, Long courseId,
                                                  Long videoId, String shortLink, int questionFlag,
                                                  Long videoId, String shortLink, int questionFlag,
                                                  String documentTitle, Integer directDocShard,
                                                  String documentTitle, Integer directDocShard,
                                                  long embedUserId) throws Exception {
                                                  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);
         String cached = redisCache.getCacheObject(cacheKey);
         if (StringUtils.isNotBlank(cached)) {
         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;
             return cached;
         }
         }
 
 
@@ -461,7 +464,8 @@ public class FeiShuService {
                 return cached;
                 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 documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
             String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
             int ttlSeconds = getDirectDocCacheTtlSeconds();
             int ttlSeconds = getDirectDocCacheTtlSeconds();
@@ -469,8 +473,8 @@ public class FeiShuService {
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
             feishuAccountMapper.incrementNumberUse(feishuAccountId);
             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;
             return resultUrl;
         } catch (InterruptedException e) {
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
             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,
     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) {
         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() {
     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);
     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> selectUserIsChat(@Param("externalUserId") String externalUserId);
 
 
     List<QwGroupChatUser> selectByChatId(SopUserLogsInfo sopUserLogsInfo);
     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":
                             case "16":
                                 createVoiceUrl(st, companyUserId, qwSop);
                                 createVoiceUrl(st, companyUserId, qwSop);
                                 break;
                                 break;
-                            //飞书看课(固定免授权)
+                            //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                             case "17":
                             case "17":
                                 try {
                                 try {
                                     st.setFeishuNeedAuth(0);
                                     st.setFeishuNeedAuth(0);
@@ -815,7 +815,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     String shortCode = feishuH5Link.get("link");
                                     String shortCode = feishuH5Link.get("link");
                                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                                             companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
                                             companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
-                                            param.getSopId(), null, externalUserId, vo.getFsUserId(),
+                                            param.getSopId(), null, externalUserId, null,
                                             qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
                                             qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
                                             param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                             param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                     if (StringUtils.isNotEmpty(feishuLink)) {
                                     if (StringUtils.isNotEmpty(feishuLink)) {
@@ -1059,7 +1059,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     throw new RuntimeException(e);
                                     throw new RuntimeException(e);
                                 }
                                 }
                                 break;
                                 break;
-                            //飞书看课(发群:只发一条领取页;领取时按人建飞书链接,发课时不建文档
+                            //飞书看课(发群:固定免授权,一条群消息共用一个飞书文档链接
                             case "17":
                             case "17":
                                 try {
                                 try {
                                     st.setFeishuNeedAuth(0);
                                     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());
                                     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 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());
                                         fillFeishuLinkCardDefaults(st, param.getVideoId().longValue(), param.getCourseId().longValue());
                                     } else {
                                     } else {
-                                        log.error("飞书发群领取页为空,仍写入发送记录: sopId={}, chatId={}", param.getSopId(), groupChat.getChatId());
+                                        log.error("飞书发群免授权链接为空,仍写入发送记录: sopId={}, chatId={}", param.getSopId(), groupChat.getChatId());
                                     }
                                     }
                                 } catch (Exception e) {
                                 } catch (Exception e) {
                                     log.error("飞书发群链接生成异常,仍写入发送记录: sopId={}, chatId={}, err={}",
                                     log.error("飞书发群链接生成异常,仍写入发送记录: sopId={}, chatId={}, err={}",
@@ -1406,7 +1411,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 throw new RuntimeException(e);
                                 throw new RuntimeException(e);
                             }
                             }
                             break;
                             break;
-                        //飞书看课(固定免授权)
+                        //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                         case "17":
                         case "17":
                             st.setFeishuNeedAuth(0);
                             st.setFeishuNeedAuth(0);
                             Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                             Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
@@ -1437,7 +1442,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             String shortCode = feishuH5Link.get("link");
                             String shortCode = feishuH5Link.get("link");
                             String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                             String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                                     companyUserIdLong, shortCode, st.getFeishuAccountId(), 0,
                                     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,
                                     qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                     param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                             if (StringUtils.isNotEmpty(feishuLink)) {
                             if (StringUtils.isNotEmpty(feishuLink)) {
@@ -2255,7 +2260,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     log.info("处理订阅号文章类型(processSetting),保留字段: articleUrl={}, miniprogramPicUrl={}, miniprogramPage={}",
                     log.info("处理订阅号文章类型(processSetting),保留字段: articleUrl={}, miniprogramPicUrl={}, miniprogramPage={}",
                             st.getArticleUrl(), st.getMiniprogramPicUrl(), st.getMiniprogramPage());
                             st.getArticleUrl(), st.getMiniprogramPicUrl(), st.getMiniprogramPage());
                     break;
                     break;
-                //飞书看课(固定免授权)
+                //飞书看课(固定免授权:一批客户复用同一飞书文档,不走领取页
                 case "17":
                 case "17":
                     st.setFeishuNeedAuth(0);
                     st.setFeishuNeedAuth(0);
                     Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
                     Integer videoId18 = param.getVideoId() != null ? param.getVideoId().intValue() : null;
@@ -2283,7 +2288,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     String shortLink = feishuH5Link.get("link");
                     String shortLink = feishuH5Link.get("link");
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                     String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
                             companyUserIdLong, shortLink, st.getFeishuAccountId(), 0,
                             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,
                             String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
                             param.getCorpId(), dataTime, item.getStartTime(), feishuShardAllocator);
                             param.getCorpId(), dataTime, item.getStartTime(), feishuShardAllocator);
                     if (StringUtils.isNotEmpty(feishuLink)) {
                     if (StringUtils.isNotEmpty(feishuLink)) {
@@ -2345,7 +2350,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     }
     }
 
 
     /**
     /**
-     * 群飞书(17):发课时只拼领取页,不调飞书建文档;领取时再按当前用户生成独立飞书链接
+     * 拼飞书领取页(仅 contentType=18 使用)
      */
      */
     private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
     private String buildFeishuClaimPageUrl(String shortLink, CourseConfig config) {
         if (StringUtils.isEmpty(shortLink) || config == null
         if (StringUtils.isEmpty(shortLink) || config == null
@@ -2952,6 +2957,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return miniAppId;
         return miniAppId;
     }
     }
 
 
+    /**
+     * contentType=17 专用:固定免授权,一批客户复用飞书文档;不走 18 的领取页。
+     */
     private String resolveFeishuSendLinkSafely(Long videoId, Long companyId, Long courseId,
     private String resolveFeishuSendLinkSafely(Long videoId, Long companyId, Long courseId,
                                                Long companyUserId, String shortLink, Long feishuAccountId,
                                                Long companyUserId, String shortLink, Long feishuAccountId,
                                                Integer feishuNeedAuth, String sopId, String userLogsId,
                                                Integer feishuNeedAuth, String sopId, String userLogsId,
@@ -2960,30 +2968,31 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                Date sendTime, String elementTime,
                                                Date sendTime, String elementTime,
                                                FeishuDirectDocShardAllocator feishuShardAllocator) {
                                                FeishuDirectDocShardAllocator feishuShardAllocator) {
         try {
         try {
+            // 17 已取消授权模式,无论入参如何一律免授权
             Integer directDocShard = null;
             Integer directDocShard = null;
-            if (feishuShardAllocator != null && !FeiShuService.isAuthRequired(feishuNeedAuth)) {
+            if (feishuShardAllocator != null) {
                 if (feishuShardAllocator.markDirectDocCacheForRefresh(companyUserId, videoId, courseId, feishuAccountId)) {
                 if (feishuShardAllocator.markDirectDocCacheForRefresh(companyUserId, videoId, courseId, feishuAccountId)) {
                     feiShuService.invalidateDirectDocCache(companyUserId, videoId, courseId, feishuAccountId);
                     feiShuService.invalidateDirectDocCache(companyUserId, videoId, courseId, feishuAccountId);
                 }
                 }
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
                 directDocShard = feishuShardAllocator.nextShard(companyUserId, videoId, courseId, feishuAccountId);
             }
             }
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
             String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
-                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard, fsUserId);
+                    shortLink, feishuAccountId, 0, directDocShard, null);
             if (StringUtils.isEmpty(feishuLink)) {
             if (StringUtils.isEmpty(feishuLink)) {
-                log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
+                log.error("生成飞书免授权链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                         sopId, videoId, externalId);
                         sopId, videoId, externalId);
                 recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
                 recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
                         companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
                         companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
-                        "生成飞书注册链接失败");
+                        "生成飞书免授权链接失败");
                 return null;
                 return null;
             }
             }
             return feishuLink;
             return feishuLink;
         } catch (Exception e) {
         } catch (Exception e) {
-            log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
+            log.error("生成飞书免授权链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
                     sopId, videoId, externalId, e);
                     sopId, videoId, externalId, e);
             recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
             recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
                     companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
                     companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
-                    "生成飞书注册链接失败: " + e.getMessage());
+                    "生成飞书免授权链接失败: " + e.getMessage());
             return null;
             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 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 * 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>
+    <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 id="selectUserIsChat" resultType="com.fs.qw.domain.QwGroupChatUser">
         select * from qw_group_chat_user a where a.user_id = #{externalUserId}
         select * from qw_group_chat_user a where a.user_id = #{externalUserId}
     </select>
     </select>