Selaa lähdekoodia

一键群发和企微task分片处理

xw 1 päivä sitten
vanhempi
commit
0666e7ff9c

+ 28 - 12
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -26,6 +26,7 @@ import com.fs.course.mapper.*;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.feishu.service.FeiShuService;
 import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.util.FeishuDirectDocShardAllocator;
 import com.fs.live.domain.LiveWatchLog;
 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.*;
@@ -461,9 +462,10 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
         }
 
 
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
+        FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forAutoSop(config);
         for (SopUserLogsVo logVo : userLogsVos) {
         for (SopUserLogsVo logVo : userLogsVos) {
             processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
             processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
-                    config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
+                    config,miniMap,companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator);
         }
         }
 
 
         // 等待所有用户日志处理完成
         // 等待所有用户日志处理完成
@@ -485,9 +487,10 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
     public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                     CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
                                     CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
                                     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) {
         try {
         try {
-            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
+            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator);
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
         } finally {
         } finally {
@@ -499,7 +502,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
     private void processUserLog(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                 LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
                                 LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,String miniAppId,
                                 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) {
         try {
         try {
 
 
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
             LocalDate startDate = LocalDate.parse(logVo.getStartTime(), DATE_FORMATTER);
@@ -756,7 +760,8 @@ 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);
 
 
                     }
                     }
                 } catch (Exception e) {
                 } catch (Exception e) {
@@ -802,7 +807,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                    String qwUserId,String companyUserId,String companyId,String welcomeText,String qwUserName,
                                    String qwUserId,String companyUserId,String companyId,String welcomeText,String qwUserName,
                                    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) {
         String formattedSendTime = sendTime.toInstant()
         String formattedSendTime = sendTime.toInstant()
                 .atZone(ZoneId.systemDefault())
                 .atZone(ZoneId.systemDefault())
                 .format(DATE_TIME_FORMATTER);
                 .format(DATE_TIME_FORMATTER);
@@ -882,7 +888,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null,null);
             QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null,null);
             handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
             handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
                     type, qwUserId, companyUserId, companyId, groupChat.getChatId(), welcomeText, qwUserName,
                     type, qwUserId, companyUserId, companyId, groupChat.getChatId(), welcomeText, qwUserName,
-                    null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap);
+                    null, true, miniAppId, groupChat,config, miniMap, null, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap,
+                    feishuShardAllocator);
             }
             }
 //            if (content.getIndex() == 0) {
 //            if (content.getIndex() == 0) {
 //                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null);
 //                QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, groupChat.getChatId(), groupChat.getName(), null, isOfficial, null);
@@ -931,7 +938,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                     QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId, isOfficial, contactId.getExternalId(),contactId.getIsDaysNotStudy());
                     QwSopLogs sopLogs = createBaseLog(formattedSendTime, logVo, ruleTimeVO, contactId.getExternalContactId(), externalUserName, fsUserId, isOfficial, contactId.getExternalId(),contactId.getIsDaysNotStudy());
                     handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
                     handleLogBasedOnType(sopLogs, content, logVo, sendTime, courseId, videoId,
                             type, qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId, false, miniAppId,
                             type, qwUserId, companyUserId, companyId, externalId, welcomeText, qwUserName, fsUserId, false, miniAppId,
-                            null,config, miniMap, grade, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap);
+                            null,config, miniMap, grade, sendMsgType,companies,liveId, deptMiniAppMap, companyUserDeptMap,
+                            feishuShardAllocator);
                 } catch (Exception e) {
                 } catch (Exception e) {
                     e.printStackTrace();
                     e.printStackTrace();
                     log.error("处理 externalContactId {} 时发生异常", contactId, e);
                     log.error("处理 externalContactId {} 时发生异常", contactId, e);
@@ -1091,7 +1099,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                       QwGroupChat groupChat,CourseConfig config,
                                       QwGroupChat groupChat,CourseConfig config,
                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                       Integer grade, Integer sendMsgType ,List<Company> companies ,Long liveId,
                                       Integer grade, Integer sendMsgType ,List<Company> companies ,Long liveId,
-                                      Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap) {
+                                      Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
+                                      FeishuDirectDocShardAllocator feishuShardAllocator) {
         switch (type) {
         switch (type) {
             case 1:
             case 1:
                 handleNormalMessage(sopLogs, content,companyUserId,companyId,isGroupChat,qwUserId,groupChat,externalId,logVo);
                 handleNormalMessage(sopLogs, content,companyUserId,companyId,isGroupChat,qwUserId,groupChat,externalId,logVo);
@@ -1099,7 +1108,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             case 2:
             case 2:
                 handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
                 handleCourseMessage(sopLogs, content, logVo, sendTime, courseId, videoId,
                         qwUserId, companyUserId, companyId, externalId, welcomeText,qwUserName, fsUserId,
                         qwUserId, companyUserId, companyId, externalId, welcomeText,qwUserName, fsUserId,
-                        isGroupChat, miniAppId, groupChat,config,miniMap, grade, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap);
+                        isGroupChat, miniAppId, groupChat,config,miniMap, grade, sendMsgType,companies, deptMiniAppMap, companyUserDeptMap,
+                        feishuShardAllocator);
                 break;
                 break;
             case 3:
             case 3:
                 handleOrderMessage(sopLogs, content);
                 handleOrderMessage(sopLogs, content);
@@ -1317,7 +1327,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                      String companyId, String externalId, String welcomeText, String qwUserName,
                                      String companyId, String externalId, String welcomeText, String qwUserName,
                                      Long fsUserId, boolean isGroupChat, String miniAppId, QwGroupChat groupChat,CourseConfig config,Map<Long,
                                      Long fsUserId, boolean isGroupChat, String miniAppId, QwGroupChat groupChat,CourseConfig config,Map<Long,
                     Map<Integer, List<CompanyMiniapp>>> miniMap,Integer grade, Integer sendMsgType,
                     Map<Integer, List<CompanyMiniapp>>> miniMap,Integer grade, 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) {
         QwExternalContact contact = null;
         QwExternalContact contact = null;
         if(logVo.getExternalId() != null){
         if(logVo.getExternalId() != null){
             contact = qwExternalContactMapper.selectById(logVo.getExternalId());
             contact = qwExternalContactMapper.selectById(logVo.getExternalId());
@@ -1613,8 +1624,13 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                                 qwUserId, companyUserId, companyId, externalIdLong, chatIdForFeishu, cachedCourseConfig);
                         String shortLink = feiShuLinkMap.get("link");
                         String shortLink = feiShuLinkMap.get("link");
 
 
+                        Integer directDocShard = null;
+                        if (feishuShardAllocator != null && !FeiShuService.isAuthRequired(setting.getFeishuNeedAuth())) {
+                            directDocShard = feishuShardAllocator.nextShard(companyUserIdLong, videoId, courseId,
+                                    setting.getFeishuAccountId());
+                        }
                         String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
                         String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyIdLong, courseId, companyUserIdLong,
-                                shortLink, setting.getFeishuAccountId(), setting.getFeishuNeedAuth());
+                                shortLink, setting.getFeishuAccountId(), setting.getFeishuNeedAuth(), directDocShard);
                         if (StringUtils.isEmpty(feishuLink)) {
                         if (StringUtils.isEmpty(feishuLink)) {
                             log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                             log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                                     logVo.getSopId(), videoId, externalId);
                                     logVo.getSopId(), videoId, externalId);

+ 11 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -123,6 +123,17 @@ public class CourseConfig implements Serializable {
      */
      */
     private Boolean pilotCourseSkipRepeatLimit;
     private Boolean pilotCourseSkipRepeatLimit;
 
 
+    /**
+     * 飞书免授权 SOP 一键群发:每个飞书文档承载的最大客户数,超出后轮换新文档。默认 30。
+     */
+    private Integer feishuDirectDocMaxCustomersPerShard;
+
+    /**
+     * 飞书免授权 SOP 自动定时发课:每个飞书文档承载的最大客户数,超出后轮换新文档。默认 50。
+     * 未配置时回退 feishuDirectDocMaxCustomersPerShard,仍无则使用 50。
+     */
+    private Integer feishuDirectDocMaxCustomersPerShardAuto;
+
 
 
     @Data
     @Data
     public static class DisabledTimeVo{
     public static class DisabledTimeVo{

+ 33 - 7
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -74,10 +74,21 @@ public class FeiShuService {
     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,
                                         Integer feishuNeedAuth) {
                                         Integer feishuNeedAuth) {
+        return resolveFeishuSendLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
+                feishuNeedAuth, null);
+    }
+
+    /**
+     * @param directDocShard SOP 群发/自动发课分片序号,null 表示非分片场景(沿用按天复用逻辑)
+     */
+    public String resolveFeishuSendLink(Long videoId, Long companyId, Long courseId,
+                                        Long companyUserId, String shortLink, Long feishuAccountId,
+                                        Integer feishuNeedAuth, Integer directDocShard) {
         if (isAuthRequired(feishuNeedAuth)) {
         if (isAuthRequired(feishuNeedAuth)) {
             return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
             return getFeishuRegisterLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
         }
         }
-        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId);
+        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId,
+                directDocShard);
     }
     }
 
 
     /**
     /**
@@ -145,6 +156,15 @@ public class FeiShuService {
      */
      */
     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) {
+        return getFeishuDirectCourseLink(videoId, companyId, courseId, companyUserId, shortLink, feishuAccountId, null);
+    }
+
+    /**
+     * @param directDocShard SOP 分片序号,null 时按销售+课程+账号+日期复用同一文档
+     */
+    public String getFeishuDirectCourseLink(Long videoId, Long companyId, Long courseId,
+                                            Long companyUserId, String shortLink, Long feishuAccountId,
+                                            Integer directDocShard) {
         CourseConfig config = getCourseConfig();
         CourseConfig config = getCourseConfig();
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
         if (!Boolean.TRUE.equals(config.getEnableFeishuNewLink())) {
             throw new CustomException("未开启飞书看课");
             throw new CustomException("未开启飞书看课");
@@ -166,7 +186,7 @@ public class FeiShuService {
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             try {
             try {
                 String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
                 String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
-                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle());
+                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle(), directDocShard);
                 updateCourseLinkFeishuAccount(shortLink, account.getId());
                 updateCourseLinkFeishuAccount(shortLink, account.getId());
                 return docUrl;
                 return docUrl;
             } catch (CustomException e) {
             } catch (CustomException e) {
@@ -297,8 +317,8 @@ public class FeiShuService {
     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) throws Exception {
-        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId);
+                                                 String documentTitle, Integer directDocShard) throws Exception {
+        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={}",
             log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}",
@@ -354,10 +374,16 @@ public class FeiShuService {
         }
         }
     }
     }
 
 
-    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 dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
         String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
-        return String.format("%s%d:%d:%d:%d:%s",
-                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseId, feishuAccountId, dateKey);
+        if (directDocShard == null) {
+            return String.format("%s%d:%d:%d:%d:%s",
+                    FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseId, feishuAccountId, dateKey);
+        }
+        return String.format("%s%d:%d:%d:%d:%s:%d",
+                FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseId, feishuAccountId, dateKey,
+                directDocShard);
     }
     }
 
 
     private int getDirectDocCacheTtlSeconds() {
     private int getDirectDocCacheTtlSeconds() {

+ 66 - 0
fs-service/src/main/java/com/fs/feishu/util/FeishuDirectDocShardAllocator.java

@@ -0,0 +1,66 @@
+package com.fs.feishu.util;
+
+import com.fs.course.config.CourseConfig;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * SOP群发/自动执行场景,飞书直链文档分片分配器,控制单个分片承载的客户数量,实现租户维度文档分片负载均衡
+ */
+public class FeishuDirectDocShardAllocator {
+
+    private static final int DEFAULT_MAX_CUSTOMERS_PER_SHARD_MASS_SEND = 30;
+    private static final int DEFAULT_MAX_CUSTOMERS_PER_SHARD_AUTO = 50;
+
+    private final int maxCustomersPerShard;
+    private final Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
+
+    private FeishuDirectDocShardAllocator(int maxCustomersPerShard) {
+        this.maxCustomersPerShard = maxCustomersPerShard > 0 ? maxCustomersPerShard : DEFAULT_MAX_CUSTOMERS_PER_SHARD_MASS_SEND;
+    }
+
+    /** 群发场景分配器 */
+    public static FeishuDirectDocShardAllocator forMassSend(CourseConfig config) {
+        return new FeishuDirectDocShardAllocator(resolveMaxCustomersPerShardForMassSend(config));
+    }
+
+    /** SOP自动执行场景分配器 */
+    public static FeishuDirectDocShardAllocator forAutoSop(CourseConfig config) {
+        return new FeishuDirectDocShardAllocator(resolveMaxCustomersPerShardForAuto(config));
+    }
+
+    public static int resolveMaxCustomersPerShardForMassSend(CourseConfig config) {
+        if (config == null || config.getFeishuDirectDocMaxCustomersPerShard() == null
+                || config.getFeishuDirectDocMaxCustomersPerShard() <= 0) {
+            return DEFAULT_MAX_CUSTOMERS_PER_SHARD_MASS_SEND;
+        }
+        return config.getFeishuDirectDocMaxCustomersPerShard();
+    }
+
+    public static int resolveMaxCustomersPerShardForAuto(CourseConfig config) {
+        if (config != null && config.getFeishuDirectDocMaxCustomersPerShardAuto() != null
+                && config.getFeishuDirectDocMaxCustomersPerShardAuto() > 0) {
+            return config.getFeishuDirectDocMaxCustomersPerShardAuto();
+        }
+        if (config != null && config.getFeishuDirectDocMaxCustomersPerShard() != null
+                && config.getFeishuDirectDocMaxCustomersPerShard() > 0) {
+            return config.getFeishuDirectDocMaxCustomersPerShard();
+        }
+        return DEFAULT_MAX_CUSTOMERS_PER_SHARD_AUTO;
+    }
+
+    /**
+     * 获取当前客户应当使用的分片编号,从 0 开始
+     */
+    public int nextShard(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
+        String key = buildKey(companyUserId, videoId, courseId, feishuAccountId);
+        int customerIndex = counters.computeIfAbsent(key, ignored -> new AtomicInteger(0)).getAndIncrement();
+        return customerIndex / maxCustomersPerShard;
+    }
+
+    private static String buildKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
+        return companyUserId + ":" + videoId + ":" + courseId + ":" + (feishuAccountId != null ? feishuAccountId : 0);
+    }
+}

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

@@ -33,6 +33,7 @@ import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.domain.FastGptChatReplaceWords;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
 import com.fs.fastGpt.mapper.FastGptChatReplaceWordsMapper;
 import com.fs.feishu.service.FeiShuService;
 import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.util.FeishuDirectDocShardAllocator;
 import com.fs.his.domain.FsUser;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.live.domain.LiveWatchLog;
 import com.fs.live.domain.LiveWatchLog;
@@ -540,6 +541,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         if (config == null) {
         if (config == null) {
             return R.error().put("msg","课程默认配置为空,请联系管理员");
             return R.error().put("msg","课程默认配置为空,请联系管理员");
         }
         }
+        FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forMassSend(config);
 
 
         if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
         if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
             return R.error().put("msg","企业编号为空,不能创建一键群发");
             return R.error().put("msg","企业编号为空,不能创建一键群发");
@@ -777,7 +779,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                         companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                         companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                         param.getSopId(), null, externalUserId, vo.getFsUserId(),
                                         param.getSopId(), null, externalUserId, vo.getFsUserId(),
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
-                                        param.getCorpId(), createTime, param.getStartTime());
+                                        param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                     st.setLinkUrl(feishuLink);
                                     st.setLinkUrl(feishuLink);
                                 }
                                 }
@@ -1006,7 +1008,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                         companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                         companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                         param.getSopId(), null, null, null,
                                         param.getSopId(), null, null, null,
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
                                         qwUser.getQwUserId(), qwUser.getQwUserName(), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
-                                        param.getCorpId(), createTime, param.getStartTime());
+                                        param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                     st.setLinkUrl(feishuLinkGroup);
                                     st.setLinkUrl(feishuLinkGroup);
                                 }
                                 }
@@ -1351,7 +1353,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                     companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                                     param.getSopId(), item.getUserLogsId(), item.getExternalId(), item.getFsUserId(),
                                     param.getSopId(), item.getUserLogsId(), item.getExternalId(), item.getFsUserId(),
                                     qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
                                     qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
-                                    param.getCorpId(), createTime, param.getStartTime());
+                                    param.getCorpId(), createTime, param.getStartTime(), feishuShardAllocator);
                             if (StringUtils.isNotEmpty(feishuLink)) {
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
                                 st.setLinkUrl(feishuLink);
                             }
                             }
@@ -1480,6 +1482,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         if (config == null) {
         if (config == null) {
             return R.error("课程默认配置为空,请联系管理员");
             return R.error("课程默认配置为空,请联系管理员");
         }
         }
+        FeishuDirectDocShardAllocator feishuShardAllocator = FeishuDirectDocShardAllocator.forMassSend(config);
 
 
         if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
         if (StringUtil.strIsNullOrEmpty(param.getCorpId())){
             return R.error("企业编号为空,不能创建一键群发");
             return R.error("企业编号为空,不能创建一键群发");
@@ -1547,7 +1550,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
 
                 if (qwUser.getCompanyUserId()!=null && qwUser.getCompanyId()!=null){
                 if (qwUser.getCompanyUserId()!=null && qwUser.getCompanyId()!=null){
                     List<QwSopLogs> sopLogsList = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
                     List<QwSopLogs> sopLogsList = processInsertSopUserLogsInfo(logs, qwUser, param, words, config, qwCompany, finalSort,
-                            finalSendType,miniMap,companies, deptMiniAppMap, companyUserDeptMap);
+                            finalSendType,miniMap,companies, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator);
 
 
                     //批量插入 发送记录
                     //批量插入 发送记录
                     if (!sopLogsList.isEmpty()) {
                     if (!sopLogsList.isEmpty()) {
@@ -1571,7 +1574,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                          SendUserLogsInfoMsgParam param,List<FastGptChatReplaceWords> words,
                                                          SendUserLogsInfoMsgParam param,List<FastGptChatReplaceWords> words,
                                                          CourseConfig config,QwCompany qwCompany,int finalSort,int finalSendType,
                                                          CourseConfig config,QwCompany qwCompany,int finalSort,int finalSendType,
                                                          Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,List<Company> companies,
                                                          Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,List<Company> companies,
-                                                         Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap ){
+                                                         Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
+                                                         FeishuDirectDocShardAllocator feishuShardAllocator ){
 
 
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 
 
@@ -1627,7 +1631,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 case 5:
                 case 5:
                 case 20:
                 case 20:
                     List<QwSopCourseFinishTempSetting.Setting> list = processSetting(item,qwUser, param, words, config, qwCompany,companyUserId,companyId,
                     List<QwSopCourseFinishTempSetting.Setting> list = processSetting(item,qwUser, param, words, config, qwCompany,companyUserId,companyId,
-                            contact,dataTime, finalDomainName,miniMap,companies,sopLogs, deptMiniAppMap, companyUserDeptMap);
+                            contact,dataTime, finalDomainName,miniMap,companies,sopLogs, deptMiniAppMap, companyUserDeptMap, feishuShardAllocator);
                     setting.setSetting(list);
                     setting.setSetting(list);
                     break;
                     break;
                 case 9:
                 case 9:
@@ -1693,7 +1697,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                                       QwExternalContact contact,Date dataTime,String domainName,
                                                                       QwExternalContact contact,Date dataTime,String domainName,
                                                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                                                       Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
                                                                       List<Company> companies,QwSopLogs sopLogs,
                                                                       List<Company> companies,QwSopLogs sopLogs,
-                                                                      Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap ){
+                                                                      Map<Long, CompanyDept> deptMiniAppMap, Map<Long, Long> companyUserDeptMap,
+                                                                      FeishuDirectDocShardAllocator feishuShardAllocator ){
         List<QwSopCourseFinishTempSetting.Setting> list = JSONArray.parseArray(param.getSetting(),QwSopCourseFinishTempSetting.Setting.class);
         List<QwSopCourseFinishTempSetting.Setting> list = JSONArray.parseArray(param.getSetting(),QwSopCourseFinishTempSetting.Setting.class);
 
 
         for (QwSopCourseFinishTempSetting.Setting st : list) {
         for (QwSopCourseFinishTempSetting.Setting st : list) {
@@ -1977,7 +1982,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                             companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
                             item.getSopId(), item.getUserLogsId(), externalId, item.getFsUserId(),
                             item.getSopId(), item.getUserLogsId(), externalId, item.getFsUserId(),
                             String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
                             String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
-                            param.getCorpId(), dataTime, item.getStartTime());
+                            param.getCorpId(), dataTime, item.getStartTime(), feishuShardAllocator);
                     if (StringUtils.isNotEmpty(feishuLink)) {
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String customerTitle = contact == null ? "同学" :
                         String customerTitle = contact == null ? "同学" :
@@ -2516,10 +2521,15 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                                Integer feishuNeedAuth, String sopId, String userLogsId,
                                                Integer feishuNeedAuth, String sopId, String userLogsId,
                                                Long externalId, Long fsUserId, String qwUserId, String qwUserName,
                                                Long externalId, Long fsUserId, String qwUserId, String qwUserName,
                                                String companyUserIdStr, String companyIdStr, String corpId,
                                                String companyUserIdStr, String companyIdStr, String corpId,
-                                               Date sendTime, String elementTime) {
+                                               Date sendTime, String elementTime,
+                                               FeishuDirectDocShardAllocator feishuShardAllocator) {
         try {
         try {
+            Integer directDocShard = null;
+            if (feishuShardAllocator != null && !FeiShuService.isAuthRequired(feishuNeedAuth)) {
+                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);
+                    shortLink, feishuAccountId, feishuNeedAuth, directDocShard);
             if (StringUtils.isEmpty(feishuLink)) {
             if (StringUtils.isEmpty(feishuLink)) {
                 log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                 log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
                         sopId, videoId, externalId);
                         sopId, videoId, externalId);