Explorar o código

优化飞书免受权发课

xw hai 2 días
pai
achega
34ac55c1de

+ 139 - 6
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -22,6 +22,12 @@ import org.springframework.stereotype.Component;
 
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
 import java.util.Date;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -53,6 +59,14 @@ public class FeiShuService {
     private IFeishuAccountService feishuAccountService;
 
     private static final String FEISHU_DOC_URL_PREFIX = "https://www.feishu.cn/docx/";
+    private static final String FEISHU_DIRECT_DOC_CACHE_PREFIX = "feishu:direct_doc:";
+    private static final String FEISHU_DIRECT_DOC_LOCK_PREFIX = "feishu:direct_doc:lock:";
+    private static final DateTimeFormatter DIRECT_DOC_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
+    private static final long DIRECT_DOC_LOCK_WAIT_MS = 30_000L;
+    private static final long DIRECT_DOC_LOCK_POLL_MS = 200L;
+    private static final long DIRECT_DOC_LOCK_TTL_MINUTES = 2L;
+    private static final int DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES = 3;
+    private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};
 
     /**
      * 按发课配置生成飞书文档链接:需要授权走注册链,免授权直接生成看课文档。
@@ -151,13 +165,10 @@ public class FeiShuService {
         for (FeishuAccount account : accounts) {
             FeishuClientHolder holder = clientPool.getClient(companyUserId, account.getId());
             try {
-                String documentId = docApiService.createDocument(holder, userCourseVideo.getTitle());
-                String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
-                docApiService.createIframeBlock(holder, documentId, iframeUrl);
-                docApiService.changeDocumentPermissions(holder, documentId);
-                feishuAccountMapper.incrementNumberUse(account.getId());
+                String docUrl = getOrCreateDirectCourseDocUrl(holder, account.getId(), companyUserId, companyId,
+                        courseId, videoId, shortLink, questionFlag, userCourseVideo.getTitle());
                 updateCourseLinkFeishuAccount(shortLink, account.getId());
-                return FEISHU_DOC_URL_PREFIX + documentId;
+                return docUrl;
             } catch (CustomException e) {
                 lastError = e;
                 if (isAccountUnavailable(e)) {
@@ -280,6 +291,128 @@ public class FeiShuService {
         }
     }
 
+    /**
+     * 同一销售、同一节课、同一飞书账号、同一天内复用免授权飞书文档,降低并发创建导致的 folder locked。
+     */
+    private String getOrCreateDirectCourseDocUrl(FeishuClientHolder holder, Long feishuAccountId,
+                                                 Long companyUserId, Long companyId, Long courseId,
+                                                 Long videoId, String shortLink, int questionFlag,
+                                                 String documentTitle) throws Exception {
+        String cacheKey = buildDirectDocCacheKey(companyUserId, videoId, courseId, feishuAccountId);
+        String cached = redisCache.getCacheObject(cacheKey);
+        if (StringUtils.isNotBlank(cached)) {
+            log.debug("复用飞书免授权文档: companyUserId={}, videoId={}, accountId={}",
+                    companyUserId, videoId, feishuAccountId);
+            return cached;
+        }
+
+        String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + cacheKey;
+        boolean locked = false;
+        try {
+            long deadline = System.currentTimeMillis() + DIRECT_DOC_LOCK_WAIT_MS;
+            while (System.currentTimeMillis() < deadline) {
+                cached = redisCache.getCacheObject(cacheKey);
+                if (StringUtils.isNotBlank(cached)) {
+                    return cached;
+                }
+                locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
+                        DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
+                if (locked) {
+                    break;
+                }
+                Thread.sleep(DIRECT_DOC_LOCK_POLL_MS);
+            }
+
+            if (!locked) {
+                cached = redisCache.getCacheObject(cacheKey);
+                if (StringUtils.isNotBlank(cached)) {
+                    return cached;
+                }
+                throw new CustomException("飞书文档生成繁忙,请稍后重试");
+            }
+
+            cached = redisCache.getCacheObject(cacheKey);
+            if (StringUtils.isNotBlank(cached)) {
+                return cached;
+            }
+
+            String iframeUrl = buildCoursePageLink(companyId, companyUserId, courseId, videoId, 0L, shortLink, questionFlag);
+            String documentId = createDirectCourseDocumentWithRetry(holder, documentTitle, iframeUrl);
+            String resultUrl = FEISHU_DOC_URL_PREFIX + documentId;
+            redisCache.setCacheObject(cacheKey, resultUrl, getDirectDocCacheTtlSeconds(), TimeUnit.SECONDS);
+            feishuAccountMapper.incrementNumberUse(feishuAccountId);
+            log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, docUrl={}",
+                    companyUserId, videoId, feishuAccountId, resultUrl);
+            return resultUrl;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new CustomException("飞书文档生成被中断", e);
+        } finally {
+            if (locked) {
+                redisCache.deleteObject(lockKey);
+            }
+        }
+    }
+
+    private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
+        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);
+    }
+
+    private int getDirectDocCacheTtlSeconds() {
+        LocalDateTime expireAt = LocalDate.now(ZoneId.systemDefault()).plusDays(1).atTime(LocalTime.of(23, 59, 59));
+        long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(ZoneId.systemDefault()), expireAt);
+        seconds = Math.max(seconds, 3600L);
+        return (int) Math.min(seconds, Integer.MAX_VALUE);
+    }
+
+    private String createDirectCourseDocumentWithRetry(FeishuClientHolder holder, String title, String iframeUrl)
+            throws Exception {
+        Exception lastError = null;
+        for (int attempt = 0; attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES; attempt++) {
+            try {
+                String documentId = docApiService.createDocument(holder, title);
+                docApiService.createIframeBlock(holder, documentId, iframeUrl);
+                docApiService.changeDocumentPermissions(holder, documentId);
+                return documentId;
+            } catch (CustomException e) {
+                lastError = e;
+                if (isFolderLockedError(e) && attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES - 1) {
+                    log.warn("飞书创建文档 folder locked,第{}次重试: accountId={}",
+                            attempt + 1, holder.getAccountId());
+                    Thread.sleep(DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS[attempt]);
+                    continue;
+                }
+                throw e;
+            } catch (Exception e) {
+                lastError = e;
+                if (isFolderLockedError(e) && attempt < DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES - 1) {
+                    log.warn("飞书创建文档 folder locked,第{}次重试: accountId={}",
+                            attempt + 1, holder.getAccountId());
+                    Thread.sleep(DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS[attempt]);
+                    continue;
+                }
+                throw e;
+            }
+        }
+        if (lastError instanceof CustomException) {
+            throw (CustomException) lastError;
+        }
+        throw lastError;
+    }
+
+    private boolean isFolderLockedError(Throwable error) {
+        if (error == null) {
+            return false;
+        }
+        String message = error.getMessage();
+        if (message != null && message.toLowerCase().contains("folder locked")) {
+            return true;
+        }
+        return isFolderLockedError(error.getCause());
+    }
+
     private void updateCourseLinkFeishuAccount(String shortLink, Long feishuAccountId) {
         FsCourseLink link = courseLinkMapper.selectFsCourseLinkByLink(shortLink);
         if (link == null) {

+ 88 - 12
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -61,6 +61,7 @@ import com.fs.sop.params.SopUserLogsParamByDate;
 import com.fs.sop.service.IQwSopLogsService;
 import com.fs.sop.service.IQwSopService;
 import com.fs.sop.service.IQwSopTempVoiceService;
+import com.fs.sop.service.ISopGenerationFailedLogService;
 import com.fs.sop.service.ISopUserLogsInfoService;
 import com.fs.sop.service.ISopUserLogsService;
 import com.fs.sop.vo.ExtCourseSopWatchLogVO;
@@ -104,6 +105,8 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     private static final String appLink = "https://jump.ylrztop.com/jumpapp/pages/index/index?link=";
     private static final String registeredRealLink = "/pages_course/register.html?link=";
     private static final String feishuMiniappLink = "/pages_course/video?course=";
+    private static final DateTimeFormatter FEISHU_FAIL_SEND_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 //    private static final String miniappRealLink = "/pages/index/index?course=";
 
     @Autowired
@@ -205,6 +208,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     @Autowired
     private FeiShuService feiShuService;
 
+    @Autowired
+    private ISopGenerationFailedLogService sopGenerationFailedLogService;
+
 
     @Override
     public void save(SopUserLogsInfo sopUserLogsInfo) {
@@ -767,11 +773,13 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, String.valueOf(companyIdLong), externalUserId, config);
                                 String shortCode = feishuH5Link.get("link");
-                                String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth());
+                                String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
+                                        companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                        param.getSopId(), null, externalUserId, vo.getFsUserId(),
+                                        qwUser.getQwUserId(), qwUser.getQwUserName(), companyUserId, companyId,
+                                        param.getCorpId(), createTime, param.getStartTime());
                                 if (StringUtils.isNotEmpty(feishuLink)) {
                                     st.setLinkUrl(feishuLink);
-                                } else {
-                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                                 }
                                 break;
                             //群公告
@@ -994,11 +1002,13 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 }
                                 Map<String, String> feishuH5LinkGroup = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18Group, videoId18Group, String.valueOf(qwUser.getId()), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup), null, config, groupChat.getChatId());
                                 String shortCodeGroup = feishuH5LinkGroup.get("link");
-                                String feishuLinkGroup = feiShuService.resolveFeishuSendLink(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(), companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth());
+                                String feishuLinkGroup = resolveFeishuSendLinkSafely(videoId18Group.longValue(), companyIdLongGroup, courseId18Group.longValue(),
+                                        companyUserIdLongGroup, shortCodeGroup, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                        param.getSopId(), null, null, null,
+                                        qwUser.getQwUserId(), qwUser.getQwUserName(), String.valueOf(companyUserIdLongGroup), String.valueOf(companyIdLongGroup),
+                                        param.getCorpId(), createTime, param.getStartTime());
                                 if (StringUtils.isNotEmpty(feishuLinkGroup)) {
                                     st.setLinkUrl(feishuLinkGroup);
-                                } else {
-                                    log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                                 }
                                 break;
                             //群公告
@@ -1337,11 +1347,13 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             }
                             Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), createTime, courseId18, videoId18, qwUserId, companyUserId, companyId, item.getExternalId(), config);
                             String shortCode = feishuH5Link.get("link");
-                            String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth());
+                            String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
+                                    companyUserIdLong, shortCode, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                                    param.getSopId(), item.getUserLogsId(), item.getExternalId(), item.getFsUserId(),
+                                    qwUserId, qwUser.getQwUserName(), companyUserId, companyId,
+                                    param.getCorpId(), createTime, param.getStartTime());
                             if (StringUtils.isNotEmpty(feishuLink)) {
                                 st.setLinkUrl(feishuLink);
-                            } else {
-                                log.error("生成飞书注册链接失败,sopId={}", param.getSopId());
                             }
                             break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
@@ -1961,7 +1973,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     }
                     Map<String, String> feishuH5Link = createFeishuH5Link(st, param.getCorpId(), dataTime, courseId18, videoId18, String.valueOf(qwUser.getId()), companyUserId, companyId, externalId, config);
                     String shortLink = feishuH5Link.get("link");
-                    String feishuLink = feiShuService.resolveFeishuSendLink(videoId18.longValue(), companyIdLong, courseId18.longValue(), companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth());
+                    String feishuLink = resolveFeishuSendLinkSafely(videoId18.longValue(), companyIdLong, courseId18.longValue(),
+                            companyUserIdLong, shortLink, st.getFeishuAccountId(), st.getFeishuNeedAuth(),
+                            item.getSopId(), item.getUserLogsId(), externalId, item.getFsUserId(),
+                            String.valueOf(qwUser.getId()), qwUser.getQwUserName(), companyUserId, companyId,
+                            param.getCorpId(), dataTime, item.getStartTime());
                     if (StringUtils.isNotEmpty(feishuLink)) {
                         String txt18 = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
                         String customerTitle = contact == null ? "同学" :
@@ -1977,8 +1993,6 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     .replaceAll("#客户称呼#", customerTitle));
                         }
                         st.setLinkUrl(feishuLink);
-                    } else {
-                        log.error("生成飞书注册链接失败,sopId={}", item.getSopId());
                     }
                     break;
 
@@ -2497,4 +2511,66 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return miniAppId;
     }
 
+    private String resolveFeishuSendLinkSafely(Long videoId, Long companyId, Long courseId,
+                                               Long companyUserId, String shortLink, Long feishuAccountId,
+                                               Integer feishuNeedAuth, String sopId, String userLogsId,
+                                               Long externalId, Long fsUserId, String qwUserId, String qwUserName,
+                                               String companyUserIdStr, String companyIdStr, String corpId,
+                                               Date sendTime, String elementTime) {
+        try {
+            String feishuLink = feiShuService.resolveFeishuSendLink(videoId, companyId, courseId, companyUserId,
+                    shortLink, feishuAccountId, feishuNeedAuth);
+            if (StringUtils.isEmpty(feishuLink)) {
+                log.error("生成飞书注册链接失败,跳过本条: sopId={}, videoId={}, externalId={}",
+                        sopId, videoId, externalId);
+                recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
+                        companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
+                        "生成飞书注册链接失败");
+                return null;
+            }
+            return feishuLink;
+        } catch (Exception e) {
+            log.error("生成飞书注册链接异常,跳过本条: sopId={}, videoId={}, externalId={}",
+                    sopId, videoId, externalId, e);
+            recordFeishuLinkGenerationFailed(sopId, userLogsId, externalId, fsUserId, qwUserId, qwUserName,
+                    companyUserIdStr, companyIdStr, corpId, sendTime, elementTime, videoId,
+                    "生成飞书注册链接失败: " + e.getMessage());
+            return null;
+        }
+    }
+
+    private void recordFeishuLinkGenerationFailed(String sopId, String userLogsId, Long externalId, Long fsUserId,
+                                                  String qwUserId, String qwUserName, String companyUserId,
+                                                  String companyId, String corpId, Date sendTime, String elementTime,
+                                                  Long videoId, String failReason) {
+        try {
+            SopGenerationFailedLog failedLog = new SopGenerationFailedLog();
+            failedLog.setSopId(sopId);
+            failedLog.setUserLogsId(userLogsId);
+            failedLog.setExternalId(externalId);
+            failedLog.setFsUserId(fsUserId);
+            failedLog.setQwUserId(qwUserId);
+            failedLog.setQwUserName(qwUserName);
+            failedLog.setCompanyUserId(companyUserId);
+            failedLog.setCompanyId(companyId);
+            failedLog.setCorpId(corpId);
+            failedLog.setFailType(SopGenerationFailedLog.FailType.OTHER.getCode());
+            String reason = failReason;
+            if (videoId != null) {
+                reason = failReason + " (videoId=" + videoId + ")";
+            }
+            failedLog.setFailReason(reason);
+            failedLog.setIsRetry(SopGenerationFailedLog.RetryStatus.NOT_RETRY.getCode());
+            failedLog.setDayNum(0L);
+            if (sendTime != null) {
+                failedLog.setSendTime(sendTime.toInstant().atZone(ZoneId.systemDefault()).format(FEISHU_FAIL_SEND_TIME_FORMATTER));
+            }
+            failedLog.setElementTime(elementTime);
+            sopGenerationFailedLogService.batchInsert(Collections.singletonList(failedLog));
+        } catch (Exception e) {
+            log.error("记录飞书链接生成失败日志异常: sopId={}, externalId={}, error={}",
+                    sopId, externalId, e.getMessage(), e);
+        }
+    }
+
 }