|
|
@@ -28,8 +28,9 @@ import java.time.LocalTime;
|
|
|
import java.time.ZoneId;
|
|
|
import java.time.format.DateTimeFormatter;
|
|
|
import java.time.temporal.ChronoUnit;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
import java.util.Date;
|
|
|
-import java.util.HashSet;
|
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
@@ -63,11 +64,20 @@ public class FeiShuService {
|
|
|
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:";
|
|
|
+ /** 当天已创建的免授权文档 cacheKey 索引(Redis Set),供失效时精确删除 */
|
|
|
+ private static final String FEISHU_DIRECT_DOC_INDEX_PREFIX = "feishu:direct_doc:index:";
|
|
|
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;
|
|
|
+ /**
|
|
|
+ * 索引缺失时的有界兜底:最大 shard 下标(含)。
|
|
|
+ * 仅兼容上线索引前已写入、未登记到 Set 的旧缓存;有索引后按实际分片删除,不受此限制。
|
|
|
+ */
|
|
|
+ private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 500;
|
|
|
+ /** 批量 DEL 分批大小,避免单次命令 key 过多 */
|
|
|
+ private static final int DIRECT_DOC_DELETE_BATCH_SIZE = 200;
|
|
|
private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};
|
|
|
|
|
|
/**
|
|
|
@@ -411,7 +421,10 @@ public class FeiShuService {
|
|
|
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);
|
|
|
+ int ttlSeconds = getDirectDocCacheTtlSeconds();
|
|
|
+ redisCache.setCacheObject(cacheKey, resultUrl, ttlSeconds, TimeUnit.SECONDS);
|
|
|
+ // 写入分片索引,供一键群发失效时按实际分片精确删除(不依赖 SCAN / 固定上界)
|
|
|
+ registerDirectDocCacheKey(cacheKey, companyUserId, videoId, courseId, feishuAccountId, ttlSeconds);
|
|
|
feishuAccountMapper.incrementNumberUse(feishuAccountId);
|
|
|
log.info("创建并缓存飞书免授权文档: companyUserId={}, videoId={}, accountId={}, docUrl={}",
|
|
|
companyUserId, videoId, feishuAccountId, resultUrl);
|
|
|
@@ -428,40 +441,97 @@ public class FeiShuService {
|
|
|
|
|
|
/**
|
|
|
* 一键群发补发前清除当天已缓存的免授权飞书文档(含各 shard 及 lock),避免复用已被风控的旧链接。
|
|
|
+ * <p>
|
|
|
+ * 优先读写路径维护的分片索引 Set 精确删除;索引为空时再有界兜底(兼容上线索引前的旧缓存)。
|
|
|
+ * 全程不使用 SCAN,避免 Redis CPU 毛刺。
|
|
|
*/
|
|
|
public void invalidateDirectDocCache(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
|
|
|
if (companyUserId == null || videoId == null) {
|
|
|
return;
|
|
|
}
|
|
|
Long resolvedAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);
|
|
|
+ Long courseIdVal = courseId != null ? courseId : 0L;
|
|
|
String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
|
|
|
- String docKeyPrefix = String.format("%s%d:%d:%d:%d:%s",
|
|
|
- FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId,
|
|
|
- courseId != null ? courseId : 0L, resolvedAccountId, dateKey);
|
|
|
- String lockKeyPrefix = FEISHU_DIRECT_DOC_LOCK_PREFIX + docKeyPrefix;
|
|
|
-
|
|
|
- Set<String> keysToDelete = new HashSet<>();
|
|
|
- keysToDelete.addAll(redisCache.scanKeys(docKeyPrefix + "*"));
|
|
|
- keysToDelete.addAll(redisCache.scanKeys(lockKeyPrefix + "*"));
|
|
|
- if (keysToDelete.isEmpty()) {
|
|
|
- log.info("一键群发刷新飞书文档缓存:无命中 key, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
- companyUserId, videoId, courseId, resolvedAccountId);
|
|
|
- return;
|
|
|
+ String baseKey = String.format("%s%d:%d:%d:%d:%s",
|
|
|
+ FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, resolvedAccountId, dateKey);
|
|
|
+ String indexKey = buildDirectDocIndexKey(companyUserId, videoId, courseIdVal, resolvedAccountId, dateKey);
|
|
|
+
|
|
|
+ ArrayList<String> keysToDelete = new ArrayList<>();
|
|
|
+ Set<?> indexedKeys = redisCache.getCacheSet(indexKey);
|
|
|
+ boolean usedIndex = indexedKeys != null && !indexedKeys.isEmpty();
|
|
|
+ if (usedIndex) {
|
|
|
+ for (Object member : indexedKeys) {
|
|
|
+ if (member == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String docKey = String.valueOf(member);
|
|
|
+ if (StringUtils.isBlank(docKey)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ keysToDelete.add(docKey);
|
|
|
+ keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + docKey);
|
|
|
+ }
|
|
|
+ keysToDelete.add(indexKey);
|
|
|
+ } else {
|
|
|
+ // 索引缺失:有界枚举兜底,覆盖无索引时期写入的缓存
|
|
|
+ keysToDelete.add(baseKey);
|
|
|
+ keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + baseKey);
|
|
|
+ for (int shard = 0; shard <= DIRECT_DOC_INVALIDATE_MAX_SHARD; shard++) {
|
|
|
+ String shardKey = baseKey + ":" + shard;
|
|
|
+ keysToDelete.add(shardKey);
|
|
|
+ keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + shardKey);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ long deleted = deleteDirectDocKeysInBatches(keysToDelete);
|
|
|
+ log.info("一键群发刷新飞书文档缓存:删除完成, usedIndex={}, deleted={}, tryKeys={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
+ usedIndex, deleted, keysToDelete.size(), companyUserId, videoId, courseIdVal, resolvedAccountId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将新创建的文档 cacheKey 登记到当天维度的索引 Set。
|
|
|
+ */
|
|
|
+ private void registerDirectDocCacheKey(String cacheKey, Long companyUserId, Long videoId, Long courseId,
|
|
|
+ Long feishuAccountId, int ttlSeconds) {
|
|
|
+ Long courseIdVal = courseId != null ? courseId : 0L;
|
|
|
+ String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
|
|
|
+ String indexKey = buildDirectDocIndexKey(companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
|
|
|
+ redisCache.setCacheSet(indexKey, Collections.singleton(cacheKey));
|
|
|
+ redisCache.expire(indexKey, ttlSeconds, TimeUnit.SECONDS);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildDirectDocIndexKey(Long companyUserId, Long videoId, Long courseIdVal,
|
|
|
+ Long feishuAccountId, String dateKey) {
|
|
|
+ return String.format("%s%d:%d:%d:%d:%s",
|
|
|
+ FEISHU_DIRECT_DOC_INDEX_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分批删除,避免单次 DEL 的 key 列表过大。
|
|
|
+ */
|
|
|
+ private long deleteDirectDocKeysInBatches(List<String> keysToDelete) {
|
|
|
+ if (keysToDelete == null || keysToDelete.isEmpty()) {
|
|
|
+ return 0L;
|
|
|
+ }
|
|
|
+ long deleted = 0L;
|
|
|
+ int total = keysToDelete.size();
|
|
|
+ for (int from = 0; from < total; from += DIRECT_DOC_DELETE_BATCH_SIZE) {
|
|
|
+ int to = Math.min(from + DIRECT_DOC_DELETE_BATCH_SIZE, total);
|
|
|
+ deleted += redisCache.deleteObject(keysToDelete.subList(from, to));
|
|
|
}
|
|
|
- redisCache.deleteObject(keysToDelete);
|
|
|
- log.info("一键群发刷新飞书文档缓存:已清除 {} 个 key, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
- keysToDelete.size(), companyUserId, videoId, courseId, resolvedAccountId);
|
|
|
+ return deleted;
|
|
|
}
|
|
|
|
|
|
private String buildDirectDocCacheKey(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId,
|
|
|
Integer directDocShard) {
|
|
|
String dateKey = LocalDate.now(ZoneId.systemDefault()).format(DIRECT_DOC_DATE_FORMAT);
|
|
|
+ long courseIdVal = courseId != null ? courseId : 0L;
|
|
|
if (directDocShard == null) {
|
|
|
return String.format("%s%d:%d:%d:%d:%s",
|
|
|
- FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseId, feishuAccountId, dateKey);
|
|
|
+ FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey);
|
|
|
}
|
|
|
return String.format("%s%d:%d:%d:%d:%s:%d",
|
|
|
- FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseId, feishuAccountId, dateKey,
|
|
|
+ FEISHU_DIRECT_DOC_CACHE_PREFIX, companyUserId, videoId, courseIdVal, feishuAccountId, dateKey,
|
|
|
directDocShard);
|
|
|
}
|
|
|
|