|
@@ -67,15 +67,22 @@ public class FeiShuService {
|
|
|
/** 当天已创建的免授权文档 cacheKey 索引(Redis Set),供失效时精确删除 */
|
|
/** 当天已创建的免授权文档 cacheKey 索引(Redis Set),供失效时精确删除 */
|
|
|
private static final String FEISHU_DIRECT_DOC_INDEX_PREFIX = "feishu:direct_doc:index:";
|
|
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 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;
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 抢锁最长等待。介于原 30s 与激进 3s 之间:兼顾飞书建文档耗时与 Redis 压力。
|
|
|
|
|
+ * 超时后快速失败,由上层记录失败日志,避免拖垮 Redis CPU。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final long DIRECT_DOC_LOCK_WAIT_MS = 8_000L;
|
|
|
|
|
+ /** 等待期间本地休眠间隔;仅在休眠后探测一次缓存/锁,不再 200ms 狂刷 */
|
|
|
|
|
+ private static final long DIRECT_DOC_LOCK_POLL_MS = 500L;
|
|
|
private static final long DIRECT_DOC_LOCK_TTL_MINUTES = 2L;
|
|
private static final long DIRECT_DOC_LOCK_TTL_MINUTES = 2L;
|
|
|
private static final int DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES = 3;
|
|
private static final int DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES = 3;
|
|
|
/**
|
|
/**
|
|
|
- * 索引缺失时的有界兜底:最大 shard 下标(含)。
|
|
|
|
|
- * 仅兼容上线索引前已写入、未登记到 Set 的旧缓存;有索引后按实际分片删除,不受此限制。
|
|
|
|
|
|
|
+ * 索引缺失时的分片探测上界(含)。分片从 0 连续分配,遇到连续空分片即停止。
|
|
|
|
|
+ * 仅兼容上线索引前旧缓存;有索引后按实际分片删除。
|
|
|
*/
|
|
*/
|
|
|
- private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 500;
|
|
|
|
|
|
|
+ private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 200;
|
|
|
|
|
+ /** 连续若干分片都不存在则停止探测(分片连续分配,无需继续往后扫) */
|
|
|
|
|
+ private static final int DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS = 2;
|
|
|
/** 批量 DEL 分批大小,避免单次命令 key 过多 */
|
|
/** 批量 DEL 分批大小,避免单次命令 key 过多 */
|
|
|
private static final int DIRECT_DOC_DELETE_BATCH_SIZE = 200;
|
|
private static final int DIRECT_DOC_DELETE_BATCH_SIZE = 200;
|
|
|
private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};
|
|
private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};
|
|
@@ -391,18 +398,23 @@ public class FeiShuService {
|
|
|
String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + cacheKey;
|
|
String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + cacheKey;
|
|
|
boolean locked = false;
|
|
boolean locked = false;
|
|
|
try {
|
|
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;
|
|
|
|
|
|
|
+ // 先抢一次锁;抢不到则本地 sleep,再少量探测,避免高频 GET+SETNX 打满 Redis
|
|
|
|
|
+ locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
|
|
|
|
|
+ DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
|
|
|
|
|
+ if (!locked) {
|
|
|
|
|
+ long deadline = System.currentTimeMillis() + DIRECT_DOC_LOCK_WAIT_MS;
|
|
|
|
|
+ while (System.currentTimeMillis() < deadline) {
|
|
|
|
|
+ Thread.sleep(DIRECT_DOC_LOCK_POLL_MS);
|
|
|
|
|
+ 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) {
|
|
if (!locked) {
|
|
@@ -440,10 +452,10 @@ public class FeiShuService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 一键群发补发前清除当天已缓存的免授权飞书文档(含各 shard 及 lock),避免复用已被风控的旧链接。
|
|
|
|
|
|
|
+ * 一键群发前清除「该销售 + 该课节 + 该飞书账号 + 当天」的免授权文档缓存(含分片与 lock),
|
|
|
|
|
+ * 避免复用可能已被风控的旧链接。不会清其他销售/其他课的缓存。
|
|
|
* <p>
|
|
* <p>
|
|
|
- * 优先读写路径维护的分片索引 Set 精确删除;索引为空时再有界兜底(兼容上线索引前的旧缓存)。
|
|
|
|
|
- * 全程不使用 SCAN,避免 Redis CPU 毛刺。
|
|
|
|
|
|
|
+ * 优先按索引 Set 精确删除;无索引时按连续分片探测后精确 DEL。全程不使用 SCAN/KEYS。
|
|
|
*/
|
|
*/
|
|
|
public void invalidateDirectDocCache(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
|
|
public void invalidateDirectDocCache(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
|
|
|
if (companyUserId == null || videoId == null) {
|
|
if (companyUserId == null || videoId == null) {
|
|
@@ -473,19 +485,55 @@ public class FeiShuService {
|
|
|
}
|
|
}
|
|
|
keysToDelete.add(indexKey);
|
|
keysToDelete.add(indexKey);
|
|
|
} else {
|
|
} 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);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 索引缺失(兼容旧缓存):只删真实存在的 key,分片从 0 连续探测,遇空即停
|
|
|
|
|
+ collectExistingDirectDocKeys(baseKey, keysToDelete);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (keysToDelete.isEmpty()) {
|
|
|
|
|
+ log.info("一键群发刷新飞书文档缓存:无命中 key, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
|
|
+ companyUserId, videoId, courseIdVal, resolvedAccountId);
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
long deleted = deleteDirectDocKeysInBatches(keysToDelete);
|
|
long deleted = deleteDirectDocKeysInBatches(keysToDelete);
|
|
|
- log.info("一键群发刷新飞书文档缓存:删除完成, usedIndex={}, deleted={}, tryKeys={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
|
|
- usedIndex, deleted, keysToDelete.size(), companyUserId, videoId, courseIdVal, resolvedAccountId);
|
|
|
|
|
|
|
+ log.info("一键群发刷新飞书文档缓存:已清除 {} 个 key, usedIndex={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
|
|
|
|
|
+ deleted, usedIndex, companyUserId, videoId, courseIdVal, resolvedAccountId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 无索引时的精确删除:EXISTS 探测 base + 连续 shard,仅收集存在的 doc/lock key。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void collectExistingDirectDocKeys(String baseKey, List<String> keysToDelete) {
|
|
|
|
|
+ addDirectDocKeyIfExists(baseKey, keysToDelete);
|
|
|
|
|
+ int missStreak = 0;
|
|
|
|
|
+ for (int shard = 0; shard <= DIRECT_DOC_INVALIDATE_MAX_SHARD; shard++) {
|
|
|
|
|
+ String shardKey = baseKey + ":" + shard;
|
|
|
|
|
+ boolean existed = addDirectDocKeyIfExists(shardKey, keysToDelete);
|
|
|
|
|
+ if (existed) {
|
|
|
|
|
+ missStreak = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ missStreak++;
|
|
|
|
|
+ if (missStreak >= DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return true 表示 doc 或 lock 至少有一个存在并已加入删除列表
|
|
|
|
|
+ */
|
|
|
|
|
+ private boolean addDirectDocKeyIfExists(String docKey, List<String> keysToDelete) {
|
|
|
|
|
+ String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + docKey;
|
|
|
|
|
+ boolean docExists = redisCache.hasKey(docKey);
|
|
|
|
|
+ boolean lockExists = redisCache.hasKey(lockKey);
|
|
|
|
|
+ if (docExists) {
|
|
|
|
|
+ keysToDelete.add(docKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (lockExists) {
|
|
|
|
|
+ keysToDelete.add(lockKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ return docExists || lockExists;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|