|
|
@@ -1,26 +1,32 @@
|
|
|
package com.fs.feishu.service;
|
|
|
|
|
|
-import com.fs.common.exception.CustomException;
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
import com.fs.feishu.domain.FeishuAccount;
|
|
|
import com.fs.feishu.mapper.FeishuAccountMapper;
|
|
|
import com.lark.oapi.Client;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.redisson.api.RLock;
|
|
|
+import org.redisson.api.RedissonClient;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
|
|
import javax.annotation.PostConstruct;
|
|
|
import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
import java.util.List;
|
|
|
-import java.util.stream.Collectors;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
/**
|
|
|
- * 飞书账号池管理:
|
|
|
- * 1. 启动时从数据库加载所有启用公共账号,构建 Client 列表
|
|
|
+ * 飞书账号池管理(分布式版本):
|
|
|
+ * 1. 启动时从数据库加载所有启用公共账号,写入 Redis 共享存储
|
|
|
* 2. 使用 Redis 循环队列实现跨实例的轮询负载均衡
|
|
|
* 3. 提供 Client + FeishuAccount 的包装对象,便于获取账号信息
|
|
|
- * 4. 支持禁用异常账号,同步更新数据库、内存和 Redis 索引
|
|
|
- * 5. 支持手动刷新账号池
|
|
|
+ * 4. 支持禁用异常账号,同步更新数据库和 Redis(所有实例即时感知)
|
|
|
+ * 5. 本地缓存 Client 对象(无状态 HTTP 配置),通过 appId 去重复用
|
|
|
+ * 6. 写操作(refresh / disableAccount)使用 Redisson 分布式锁保证多实例安全
|
|
|
*/
|
|
|
@Component
|
|
|
@Slf4j
|
|
|
@@ -32,20 +38,29 @@ public class FeishuClientPool {
|
|
|
@Autowired
|
|
|
private StringRedisTemplate stringRedisTemplate;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private RedissonClient redissonClient;
|
|
|
+
|
|
|
/**
|
|
|
- * 内存中的 Client 列表(volatile 保证可见性)
|
|
|
+ * Redis 中存储账号 JSON 列表的 key
|
|
|
*/
|
|
|
- private volatile List<Client> clients = new ArrayList<>();
|
|
|
+ private static final String ACCOUNTS_KEY = "feishu:public_accounts";
|
|
|
|
|
|
/**
|
|
|
- * 内存中的 FeishuAccount 列表(与 clients 顺序一致,同索引对应)
|
|
|
+ * Redis 中存储轮询索引队列的 key
|
|
|
*/
|
|
|
- private volatile List<FeishuAccount> accounts = new ArrayList<>();
|
|
|
+ private static final String POOL_KEY = "feishu:account_pool";
|
|
|
|
|
|
/**
|
|
|
- * Redis 中存储索引列表的 key
|
|
|
+ * 分布式锁 key
|
|
|
*/
|
|
|
- private static final String POOL_KEY = "feishu:account_pool";
|
|
|
+ private static final String LOCK_KEY = "feishu:pool:lock";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本地 Client 缓存,key = appId
|
|
|
+ * Client 是无状态的 HTTP 配置对象,本地缓存不影响多实例一致性
|
|
|
+ */
|
|
|
+ private final Map<String, Client> clientCache = new ConcurrentHashMap<>();
|
|
|
|
|
|
/**
|
|
|
* 初始化时加载账号池
|
|
|
@@ -55,36 +70,91 @@ public class FeishuClientPool {
|
|
|
refresh();
|
|
|
}
|
|
|
|
|
|
+ // ==================== 分布式锁工具方法 ====================
|
|
|
+
|
|
|
/**
|
|
|
- * 刷新账号池:从数据库加载所有启用状态的公共账号,重建内存列表和 Redis 索引
|
|
|
+ * 执行需要分布式锁保护的操作
|
|
|
*/
|
|
|
- public synchronized void refresh() {
|
|
|
- // 从数据库加载 status=1 的公共账号(假设公共账号有标志 isPublic=1)
|
|
|
- List<FeishuAccount> accountList = feishuAccountMapper.selectAllPublicAccounts();
|
|
|
- if (accountList.isEmpty()) {
|
|
|
- log.error("没有可用的公共飞书应用账号,账号池为空");
|
|
|
- // 清空内存和 Redis
|
|
|
- this.clients = new ArrayList<>();
|
|
|
- this.accounts = new ArrayList<>();
|
|
|
- resetPoolIndexes(0);
|
|
|
- return;
|
|
|
+ private void withLock(Runnable action) {
|
|
|
+ RLock lock = redissonClient.getLock(LOCK_KEY);
|
|
|
+ try {
|
|
|
+ if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
|
|
|
+ try {
|
|
|
+ action.run();
|
|
|
+ } finally {
|
|
|
+ lock.unlock();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ log.error("获取飞书账号池分布式锁失败(超时)");
|
|
|
+ }
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ log.error("获取飞书账号池分布式锁被中断", e);
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 Redis 读取公共账号 JSON 并解析为列表
|
|
|
+ */
|
|
|
+ private List<FeishuAccount> loadAccountsFromRedis() {
|
|
|
+ String json = stringRedisTemplate.opsForValue().get(ACCOUNTS_KEY);
|
|
|
+ if (json == null || json.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return JSONUtil.parseArray(json).toList(FeishuAccount.class);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("解析飞书公共账号 JSON 失败", e);
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将账号列表写入 Redis
|
|
|
+ */
|
|
|
+ private void saveAccountsToRedis(List<FeishuAccount> accounts) {
|
|
|
+ String json = JSONUtil.toJsonStr(accounts);
|
|
|
+ stringRedisTemplate.opsForValue().set(ACCOUNTS_KEY, json);
|
|
|
+ }
|
|
|
|
|
|
- // 构建 Client 列表
|
|
|
- List<Client> newClients = accountList.stream()
|
|
|
- .map(acc -> Client.newBuilder(acc.getAppId(), acc.getAppSecret())
|
|
|
+ /**
|
|
|
+ * 根据 appId 获取或构建 Client
|
|
|
+ */
|
|
|
+ private Client getOrCreateClient(FeishuAccount account) {
|
|
|
+ return clientCache.computeIfAbsent(account.getAppId(),
|
|
|
+ k -> Client.newBuilder(account.getAppId(), account.getAppSecret())
|
|
|
.logReqAtDebug(true)
|
|
|
- .build())
|
|
|
- .collect(Collectors.toList());
|
|
|
+ .build());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 账号池管理 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 刷新账号池:从数据库加载所有启用状态的公共账号,写入 Redis,重建索引
|
|
|
+ */
|
|
|
+ public void refresh() {
|
|
|
+ withLock(() -> {
|
|
|
+ // 1. 从数据库加载公共账号(phone/companyId/companyUserId 均为 NULL)
|
|
|
+ List<FeishuAccount> accountList = feishuAccountMapper.selectAllPublicAccounts();
|
|
|
+ if (accountList.isEmpty()) {
|
|
|
+ log.error("没有可用的公共飞书应用账号,账号池为空");
|
|
|
+ stringRedisTemplate.delete(ACCOUNTS_KEY);
|
|
|
+ resetPoolIndexes(0);
|
|
|
+ clientCache.clear();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 写入 Redis
|
|
|
+ saveAccountsToRedis(accountList);
|
|
|
|
|
|
- // 更新内存
|
|
|
- this.clients = newClients;
|
|
|
- this.accounts = accountList;
|
|
|
+ // 3. 重建轮询索引队列
|
|
|
+ resetPoolIndexes(accountList.size());
|
|
|
|
|
|
- // 重置 Redis 索引
|
|
|
- resetPoolIndexes(newClients.size());
|
|
|
+ // 4. 清空本地 Client 缓存(账号可能有变更,下次按需重建)
|
|
|
+ clientCache.clear();
|
|
|
|
|
|
- log.info("飞书账号池刷新完成,当前可用账号数: {}", newClients.size());
|
|
|
+ log.info("飞书账号池刷新完成,当前可用账号数: {}", accountList.size());
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -106,6 +176,8 @@ public class FeishuClientPool {
|
|
|
/**
|
|
|
* 获取一个可用账号的包装对象(包含 Client 和 FeishuAccount)
|
|
|
* 使用 Redis 循环队列实现轮询,保证多实例负载均衡
|
|
|
+ * <p>
|
|
|
+ * 每次从 Redis 读取最新的账号列表,确保任意实例禁用账号后其他实例能即时感知
|
|
|
*/
|
|
|
public ClientWrapper getClientWrapper() {
|
|
|
while (true) {
|
|
|
@@ -116,10 +188,7 @@ public class FeishuClientPool {
|
|
|
// 队列为空,可能是账号池被清空或尚未初始化,尝试刷新
|
|
|
log.warn("账号池索引为空,尝试重新初始化");
|
|
|
refresh();
|
|
|
- // 如果刷新后仍为空,抛出异常
|
|
|
- if (clients.isEmpty()) {
|
|
|
- throw new CustomException("当前没有可用的飞书公共账号");
|
|
|
- }
|
|
|
+ // 刷新后重新开始循环
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
@@ -131,80 +200,92 @@ public class FeishuClientPool {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- // 2. 获取当前内存快照
|
|
|
- List<Client> currentClients = this.clients;
|
|
|
- List<FeishuAccount> currentAccounts = this.accounts;
|
|
|
+ // 2. 从 Redis 读取最新的账号列表(保证多实例一致性)
|
|
|
+ List<FeishuAccount> accountList = loadAccountsFromRedis();
|
|
|
+
|
|
|
+ // 3. 如果 Redis 中也没有数据,尝试刷新
|
|
|
+ if (accountList.isEmpty()) {
|
|
|
+ log.warn("Redis 中无公共账号数据,尝试重新初始化");
|
|
|
+ refresh();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
- // 3. 检查索引有效性
|
|
|
- if (index >= currentClients.size()) {
|
|
|
- log.warn("索引 {} 越界(当前账号数 {}),丢弃并重试", index, currentClients.size());
|
|
|
+ // 4. 检查索引有效性
|
|
|
+ if (index >= accountList.size()) {
|
|
|
+ log.warn("索引 {} 越界(当前账号数 {}),丢弃并重试", index, accountList.size());
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- // 4. 返回包装对象
|
|
|
- return new ClientWrapper(currentClients.get(index), currentAccounts.get(index));
|
|
|
+ // 5. 获取账号,构建或从缓存获取 Client
|
|
|
+ FeishuAccount account = accountList.get(index);
|
|
|
+ Client client = getOrCreateClient(account);
|
|
|
+
|
|
|
+ return new ClientWrapper(client, account);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 禁用指定账号(根据 FeishuAccount 对象),将其从池中移除
|
|
|
- * 同步方法保证内存和 Redis 的一致性
|
|
|
+ * 使用分布式锁保证数据库和 Redis 的跨实例一致性
|
|
|
*
|
|
|
- * @param account 要禁用的账号(必须包含 ID)
|
|
|
+ * @param account 要禁用的账号(必须包含 ID 和 appId)
|
|
|
* @param errorMsg 禁用原因,会写入数据库
|
|
|
*/
|
|
|
- public synchronized void disableAccount(FeishuAccount account, String errorMsg) {
|
|
|
+ public void disableAccount(FeishuAccount account, String errorMsg) {
|
|
|
if (account == null || account.getId() == null) {
|
|
|
log.warn("禁用账号失败:账号或ID为空");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- Long accountId = account.getId();
|
|
|
- // 在 accounts 列表中查找匹配的索引(使用 ID 比较,避免对象引用问题)
|
|
|
- int indexToRemove = -1;
|
|
|
- for (int i = 0; i < accounts.size(); i++) {
|
|
|
- if (accounts.get(i).getId().equals(accountId)) {
|
|
|
- indexToRemove = i;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (indexToRemove == -1) {
|
|
|
- log.warn("账号 ID {} 不在当前池中,可能已被移除", accountId);
|
|
|
- return;
|
|
|
- }
|
|
|
+ withLock(() -> {
|
|
|
+ Long accountId = account.getId();
|
|
|
+ String appId = account.getAppId();
|
|
|
|
|
|
- // 更新数据库状态为禁用(status=0),并记录错误信息
|
|
|
- feishuAccountMapper.updateStatusAndErrorMsg(accountId, 0, errorMsg);
|
|
|
+ // 1. 更新数据库状态为禁用(status=0),并记录错误信息
|
|
|
+ feishuAccountMapper.updateStatusAndErrorMsg(accountId, 0, errorMsg);
|
|
|
|
|
|
- // 从内存列表中移除(注意:不能直接修改原列表,要创建新列表避免并发问题)
|
|
|
- List<Client> newClients = new ArrayList<>(clients);
|
|
|
- List<FeishuAccount> newAccounts = new ArrayList<>(accounts);
|
|
|
- newClients.remove(indexToRemove);
|
|
|
- newAccounts.remove(indexToRemove);
|
|
|
+ // 2. 从 Redis 账号列表中移除
|
|
|
+ List<FeishuAccount> accountList = loadAccountsFromRedis();
|
|
|
+ boolean removed = accountList.removeIf(a -> a.getId().equals(accountId));
|
|
|
+ if (!removed) {
|
|
|
+ log.warn("账号 ID {} 不在 Redis 账号池中,可能已被移除", accountId);
|
|
|
+ } else {
|
|
|
+ saveAccountsToRedis(accountList);
|
|
|
+ }
|
|
|
|
|
|
- // 更新 volatile 引用
|
|
|
- this.clients = newClients;
|
|
|
- this.accounts = newAccounts;
|
|
|
+ // 3. 重建索引队列
|
|
|
+ resetPoolIndexes(accountList.size());
|
|
|
|
|
|
- // 重置 Redis 索引
|
|
|
- resetPoolIndexes(newClients.size());
|
|
|
+ // 4. 从本地 Client 缓存中移除
|
|
|
+ if (appId != null) {
|
|
|
+ clientCache.remove(appId);
|
|
|
+ }
|
|
|
|
|
|
- log.warn("飞书账号[{}]已被自动禁用,原因: {}", account.getAppId(), errorMsg);
|
|
|
+ log.warn("飞书公共账号[{}]已被禁用,原因: {}", appId, errorMsg);
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 获取当前可用账号数量的快照
|
|
|
+ * 获取当前可用账号数量
|
|
|
*/
|
|
|
public int getAvailableCount() {
|
|
|
- return clients.size();
|
|
|
+ List<FeishuAccount> accountList = loadAccountsFromRedis();
|
|
|
+ return accountList.size();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取所有可用 Client 的快照(只读,用于遍历)
|
|
|
*/
|
|
|
public List<Client> getAllClients() {
|
|
|
- return new ArrayList<>(clients);
|
|
|
+ List<FeishuAccount> accountList = loadAccountsFromRedis();
|
|
|
+ if (accountList.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ List<Client> clients = new ArrayList<>(accountList.size());
|
|
|
+ for (FeishuAccount account : accountList) {
|
|
|
+ clients.add(getOrCreateClient(account));
|
|
|
+ }
|
|
|
+ return clients;
|
|
|
}
|
|
|
|
|
|
// ==================== 内部包装类 ====================
|
|
|
@@ -229,4 +310,4 @@ public class FeishuClientPool {
|
|
|
return account;
|
|
|
}
|
|
|
}
|
|
|
-}
|
|
|
+}
|