diff --git a/src/main/java/cn/isliu/core/client/FeishuClient.java b/src/main/java/cn/isliu/core/client/FeishuClient.java
index cce848e..16ad157 100644
--- a/src/main/java/cn/isliu/core/client/FeishuClient.java
+++ b/src/main/java/cn/isliu/core/client/FeishuClient.java
@@ -4,11 +4,13 @@ import com.lark.oapi.Client;
import com.lark.oapi.core.enums.AppType;
import com.lark.oapi.service.drive.DriveService;
import com.lark.oapi.service.sheets.SheetsService;
-import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import java.util.concurrent.TimeUnit;
+import cn.isliu.core.ratelimit.DocumentLockRegistry;
+import cn.isliu.core.ratelimit.FeishuApiExecutor;
+import cn.isliu.core.ratelimit.FeishuRateLimiterManager;
import cn.isliu.core.service.*;
/**
@@ -23,6 +25,9 @@ public class FeishuClient {
private final String appId;
private final String appSecret;
private final boolean closeOfficialPool;
+ private final FeishuRateLimiterManager rateLimiterManager;
+ private final DocumentLockRegistry documentLockRegistry;
+ private final FeishuApiExecutor apiExecutor;
// 自定义服务,处理官方SDK未覆盖的API
private volatile CustomSheetService customSheetService;
@@ -34,11 +39,7 @@ public class FeishuClient {
private volatile CustomFileService customFileService;
private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient) {
- this.appId = appId;
- this.appSecret = appSecret;
- this.officialClient = officialClient;
- this.httpClient = httpClient;
- this.closeOfficialPool = false;
+ this(appId, appSecret, officialClient, httpClient, false);
}
private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient, boolean closeOfficialPool) {
@@ -47,6 +48,9 @@ public class FeishuClient {
this.officialClient = officialClient;
this.httpClient = httpClient;
this.closeOfficialPool = closeOfficialPool;
+ this.rateLimiterManager = new FeishuRateLimiterManager();
+ this.documentLockRegistry = new DocumentLockRegistry();
+ this.apiExecutor = new FeishuApiExecutor(rateLimiterManager, documentLockRegistry);
}
@@ -236,6 +240,18 @@ public class FeishuClient {
return closeOfficialPool;
}
+ public FeishuApiExecutor apiExecutor() {
+ return apiExecutor;
+ }
+
+ public FeishuRateLimiterManager rateLimiterManager() {
+ return rateLimiterManager;
+ }
+
+ public DocumentLockRegistry documentLockRegistry() {
+ return documentLockRegistry;
+ }
+
/**
* FeishuClient构建器
*/
diff --git a/src/main/java/cn/isliu/core/ratelimit/ApiOperation.java b/src/main/java/cn/isliu/core/ratelimit/ApiOperation.java
new file mode 100644
index 0000000..e9482e4
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/ApiOperation.java
@@ -0,0 +1,114 @@
+package cn.isliu.core.ratelimit;
+
+import java.time.Duration;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * 飞书 API 操作枚举
+ *
+ *
枚举定义了不同 API 行为的频控规则,便于统一限流管理。
+ */
+public enum ApiOperation {
+
+ CREATE_SPREADSHEET("创建表格", Duration.ofMinutes(1), 20, false),
+ UPDATE_SPREADSHEET_PROPERTIES("修改电子表格属性", Duration.ofMinutes(1), 20, true),
+ GET_SPREADSHEET("获取电子表格信息", Duration.ofMinutes(1), 100, false),
+ SHEET_OPERATION("操作工作表", Duration.ofSeconds(1), 100, true),
+ UPDATE_SHEET_PROPERTIES("更新工作表属性", Duration.ofSeconds(1), 100, true),
+ MOVE_DIMENSION("移动行列", Duration.ofMinutes(1), 100, true),
+ INSERT_DATA("插入数据", Duration.ofSeconds(1), 100, true),
+ APPEND_DATA("追加数据", Duration.ofSeconds(1), 100, true),
+ INSERT_ROWS_COLUMNS("插入行列", Duration.ofSeconds(1), 100, true),
+ ADD_ROWS_COLUMNS("增加行列", Duration.ofSeconds(1), 100, true),
+ UPDATE_ROWS_COLUMNS("更新行列", Duration.ofSeconds(1), 100, true),
+ DELETE_ROWS_COLUMNS("删除行列", Duration.ofSeconds(1), 100, true),
+ READ_SINGLE_RANGE("读取单个范围", Duration.ofSeconds(1), 100, false),
+ READ_MULTI_RANGE("读取多个范围", Duration.ofSeconds(1), 100, false),
+ WRITE_SINGLE_RANGE("向单个范围写入数据", Duration.ofSeconds(1), 100, true),
+ WRITE_MULTI_RANGE("向多个范围写入数据", Duration.ofSeconds(1), 100, true),
+ SET_CELL_STYLE("设置单元格样式", Duration.ofSeconds(1), 100, true),
+ BATCH_SET_CELL_STYLE("批量设置单元格样式", Duration.ofSeconds(1), 100, true),
+ MERGE_CELLS("合并单元格", Duration.ofSeconds(1), 100, true),
+ SPLIT_CELLS("拆分单元格", Duration.ofSeconds(1), 100, true),
+ WRITE_IMAGE("写入图片", Duration.ofSeconds(1), 100, true),
+ FIND_CELLS("查找单元格", Duration.ofMinutes(1), 100, false),
+ REPLACE_CELLS("替换单元格", Duration.ofMinutes(1), 20, true),
+ CREATE_CONDITIONAL_FORMAT("创建条件格式", Duration.ofSeconds(1), 100, true),
+ GET_CONDITIONAL_FORMAT("获取条件格式", Duration.ofSeconds(1), 100, true),
+ UPDATE_CONDITIONAL_FORMAT("更新条件格式", Duration.ofSeconds(1), 100, true),
+ DELETE_CONDITIONAL_FORMAT("删除条件格式", Duration.ofSeconds(1), 100, true),
+ ADD_PROTECTED_RANGE("增加保护范围", Duration.ofSeconds(1), 100, true),
+ GET_PROTECTED_RANGE("获取保护范围", Duration.ofSeconds(1), 100, true),
+ UPDATE_PROTECTED_RANGE("修改保护范围", Duration.ofSeconds(1), 100, true),
+ DELETE_PROTECTED_RANGE("删除保护范围", Duration.ofSeconds(1), 100, true),
+ SET_DROPDOWN("设置下拉列表", Duration.ofSeconds(1), 100, true),
+ DELETE_DROPDOWN("删除下拉列表设置", Duration.ofSeconds(1), 100, true),
+ UPDATE_DROPDOWN("更新下拉列表设置", Duration.ofSeconds(1), 100, true),
+ QUERY_DROPDOWN("查询下拉列表设置", Duration.ofSeconds(1), 100, true),
+ GET_FILTER("获取筛选", Duration.ofMinutes(1), 100, false),
+ CREATE_FILTER("创建筛选", Duration.ofMinutes(1), 20, true),
+ UPDATE_FILTER("更新筛选", Duration.ofMinutes(1), 20, true),
+ DELETE_FILTER("删除筛选", Duration.ofMinutes(1), 100, true),
+ CREATE_FILTER_VIEW("创建筛选视图", Duration.ofMinutes(1), 100, true),
+ GET_FILTER_VIEW("获取筛选视图", Duration.ofMinutes(1), 100, true),
+ QUERY_FILTER_VIEW("查询筛选视图", Duration.ofMinutes(1), 100, true),
+ UPDATE_FILTER_VIEW("更新筛选视图", Duration.ofMinutes(1), 100, true),
+ DELETE_FILTER_VIEW("删除筛选视图", Duration.ofMinutes(1), 100, true),
+ CREATE_FILTER_CONDITION("创建筛选条件", Duration.ofMinutes(1), 100, true),
+ GET_FILTER_CONDITION("获取筛选条件", Duration.ofMinutes(1), 100, true),
+ QUERY_FILTER_CONDITION("查询筛选条件", Duration.ofMinutes(1), 100, true),
+ UPDATE_FILTER_CONDITION("更新筛选条件", Duration.ofMinutes(1), 100, true),
+ DELETE_FILTER_CONDITION("删除筛选条件", Duration.ofMinutes(1), 100, true),
+ CREATE_FLOATING_IMAGE("创建浮动图片", Duration.ofMinutes(1), 100, true),
+ GET_FLOATING_IMAGE("获取浮动图片", Duration.ofMinutes(1), 100, true),
+ QUERY_FLOATING_IMAGE("查询浮动图片", Duration.ofMinutes(1), 100, true),
+ UPDATE_FLOATING_IMAGE("更新浮动图片", Duration.ofMinutes(1), 100, true),
+ DELETE_FLOATING_IMAGE("删除浮动图片", Duration.ofMinutes(1), 100, true),
+ GENERIC_OPERATION("通用操作", Duration.ofSeconds(1), 50, false);
+
+ private static final Map CACHE = new EnumMap<>(ApiOperation.class);
+
+ static {
+ for (ApiOperation operation : values()) {
+ CACHE.put(operation, RateLimitRule.builder()
+ .operation(operation)
+ .window(operation.window)
+ .permits(operation.permits)
+ .requireDocumentLock(operation.requireDocumentLock)
+ .allow429Retry(operation.requireDocumentLock || operation.allow429Retry)
+ .build());
+ }
+ }
+
+ private final String description;
+ private final Duration window;
+ private final int permits;
+ private final boolean requireDocumentLock;
+ private final boolean allow429Retry;
+
+ ApiOperation(String description, Duration window, int permits, boolean requireDocumentLock) {
+ this(description, window, permits, requireDocumentLock, true);
+ }
+
+ ApiOperation(String description,
+ Duration window,
+ int permits,
+ boolean requireDocumentLock,
+ boolean allow429Retry) {
+ this.description = description;
+ this.window = window;
+ this.permits = permits;
+ this.requireDocumentLock = requireDocumentLock;
+ this.allow429Retry = allow429Retry;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public RateLimitRule getRule() {
+ return CACHE.get(this);
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/ratelimit/DocumentLockRegistry.java b/src/main/java/cn/isliu/core/ratelimit/DocumentLockRegistry.java
new file mode 100644
index 0000000..4bc4c6b
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/DocumentLockRegistry.java
@@ -0,0 +1,32 @@
+package cn.isliu.core.ratelimit;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 文档级别锁注册表
+ *
+ * 对于“单个文档只能串行调用”的操作,通过文档 token 获取同一把锁。
+ */
+public class DocumentLockRegistry {
+
+ private final ConcurrentMap lockMap = new ConcurrentHashMap<>();
+
+ public ReentrantLock acquireLock(String spreadsheetToken) {
+ if (spreadsheetToken == null || spreadsheetToken.isEmpty()) {
+ return null;
+ }
+ return lockMap.computeIfAbsent(spreadsheetToken, key -> new ReentrantLock(true));
+ }
+
+ public void releaseLock(String spreadsheetToken, ReentrantLock lock) {
+ if (spreadsheetToken == null || lock == null) {
+ return;
+ }
+ if (!lock.isLocked()) {
+ lockMap.remove(spreadsheetToken, lock);
+ }
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java b/src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java
new file mode 100644
index 0000000..87192c4
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java
@@ -0,0 +1,160 @@
+package cn.isliu.core.ratelimit;
+
+import cn.isliu.core.enums.ErrorCode;
+import cn.isliu.core.exception.FsHelperException;
+import cn.isliu.core.logging.FsLogger;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 飞书 API 调用执行器
+ *
+ * 统一处理限流、429 重试、串行锁等逻辑。
+ */
+public class FeishuApiExecutor {
+
+ private static final int MAX_RETRY = 3;
+ private static final long BASE_BACKOFF_MILLIS = 200L;
+
+ private final FeishuRateLimiterManager limiterManager;
+ private final DocumentLockRegistry documentLockRegistry;
+
+ public FeishuApiExecutor(FeishuRateLimiterManager limiterManager,
+ DocumentLockRegistry documentLockRegistry) {
+ this.limiterManager = limiterManager;
+ this.documentLockRegistry = documentLockRegistry;
+ }
+
+ public T execute(String tenantKey,
+ ApiOperation operation,
+ String spreadsheetToken,
+ CheckedCallable action) throws Exception {
+
+ RateLimitRule rule = operation != null ? operation.getRule() : ApiOperation.GENERIC_OPERATION.getRule();
+ ApiOperation op = operation != null ? operation : ApiOperation.GENERIC_OPERATION;
+
+ limiterManager.getLimiter(tenantKey, rule).acquire();
+
+ ReentrantLock lock = null;
+ if (rule.isRequireDocumentLock()) {
+ lock = documentLockRegistry.acquireLock(spreadsheetToken);
+ if (lock != null) {
+ lock.lock();
+ }
+ }
+
+ try {
+ return executeWithRetry(tenantKey, op, rule, action);
+ } finally {
+ if (lock != null) {
+ lock.unlock();
+ documentLockRegistry.releaseLock(spreadsheetToken, lock);
+ }
+ }
+ }
+
+ private T executeWithRetry(String tenantKey,
+ ApiOperation operation,
+ RateLimitRule rule,
+ CheckedCallable action) throws Exception {
+ int attempt = 0;
+ long backoff = BASE_BACKOFF_MILLIS;
+
+ while (true) {
+ attempt++;
+ try {
+ return action.call();
+ } catch (FsHelperException ex) {
+ if (rule.isAllow429Retry() && isRateLimitException(ex) && attempt <= MAX_RETRY) {
+ long waitMillis = resolveWaitMillis(ex, backoff, attempt);
+ FsLogger.warn("【飞书表格】触发限流,operation:{},attempt:{},等待{}ms",
+ operation.name(), attempt, waitMillis);
+ sleepQuietly(waitMillis);
+ adjustLimiter(tenantKey, operation, waitMillis);
+ continue;
+ }
+ throw ex;
+ } catch (IOException io) {
+ throw io;
+ } catch (Exception ex) {
+ if (ex instanceof RuntimeException) {
+ throw ex;
+ }
+ throw new FsHelperException(ErrorCode.API_CALL_FAILED, "飞书 API 调用异常", null, ex);
+ }
+ }
+ }
+
+ private boolean isRateLimitException(FsHelperException ex) {
+ if (ex == null) {
+ return false;
+ }
+ Object status = ex.getContextValue("httpStatus");
+ if (status instanceof Number && ((Number) status).intValue() == 429) {
+ return true;
+ }
+ if (status instanceof String) {
+ try {
+ if (Integer.parseInt((String) status) == 429) {
+ return true;
+ }
+ } catch (NumberFormatException ignore) {
+ // ignore
+ }
+ }
+ return ex.getMessage() != null && ex.getMessage().contains("429");
+ }
+
+ private long resolveWaitMillis(FsHelperException ex, long baseBackoff, int attempt) {
+ Object reset = ex.getContextValue("x-ogw-ratelimit-reset");
+ if (reset instanceof Number) {
+ return Duration.ofSeconds(((Number) reset).longValue()).toMillis();
+ }
+ if (reset instanceof String) {
+ try {
+ long seconds = Long.parseLong((String) reset);
+ if (seconds > 0) {
+ return Duration.ofSeconds(seconds).toMillis();
+ }
+ } catch (NumberFormatException ignore) {
+ // ignore
+ }
+ }
+ long exponential = (long) (baseBackoff * Math.pow(2, Math.max(0, attempt - 1)));
+ return Math.min(TimeUnit.SECONDS.toMillis(10), exponential);
+ }
+
+ private void sleepQuietly(long millis) {
+ if (millis <= 0) {
+ return;
+ }
+ try {
+ TimeUnit.MILLISECONDS.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("限流等待被中断")
+ .cause(e)
+ .build();
+ }
+ }
+
+ private void adjustLimiter(String tenantKey, ApiOperation operation, long waitMillis) {
+ if (waitMillis <= 0) {
+ return;
+ }
+ double permitsPerSecond = 1000.0d / waitMillis;
+ limiterManager.adjustRate(tenantKey, operation, permitsPerSecond);
+ }
+
+ @FunctionalInterface
+ public interface CheckedCallable extends Callable {
+ @Override
+ T call() throws Exception;
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/ratelimit/FeishuApiOperationResolver.java b/src/main/java/cn/isliu/core/ratelimit/FeishuApiOperationResolver.java
new file mode 100644
index 0000000..ab7a442
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/FeishuApiOperationResolver.java
@@ -0,0 +1,118 @@
+package cn.isliu.core.ratelimit;
+
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 根据请求信息推断 API 操作类型
+ */
+public final class FeishuApiOperationResolver {
+
+ private static final Pattern SPREADSHEET_TOKEN_PATTERN =
+ Pattern.compile("/sheets/(?:v2|v3)/spreadsheets/([^/]+)");
+
+ private FeishuApiOperationResolver() {
+ }
+
+ public static ApiOperation resolve(Request request) {
+ if (request == null || request.url() == null) {
+ return ApiOperation.GENERIC_OPERATION;
+ }
+ String path = request.url().encodedPath();
+ String method = request.method().toUpperCase(Locale.ROOT);
+
+ if (path == null) {
+ return ApiOperation.GENERIC_OPERATION;
+ }
+
+ if (path.contains("/values_prepend")) {
+ return ApiOperation.INSERT_DATA;
+ }
+ if (path.contains("/values_append")) {
+ return ApiOperation.APPEND_DATA;
+ }
+ if (path.contains("/values_image")) {
+ return ApiOperation.WRITE_IMAGE;
+ }
+ if (path.contains("/values_batch_update")) {
+ return ApiOperation.WRITE_MULTI_RANGE;
+ }
+ if (path.contains("/values_batch_get")) {
+ return ApiOperation.READ_MULTI_RANGE;
+ }
+ if (path.contains("/values/") && "GET".equals(method)) {
+ return ApiOperation.READ_SINGLE_RANGE;
+ }
+ if (path.endsWith("/values") && "PUT".equals(method)) {
+ return ApiOperation.WRITE_SINGLE_RANGE;
+ }
+ if (path.contains("/merge_cells")) {
+ return ApiOperation.MERGE_CELLS;
+ }
+ if (path.contains("/unmerge_cells")) {
+ return ApiOperation.SPLIT_CELLS;
+ }
+ if (path.contains("/styles_batch_update")) {
+ return ApiOperation.BATCH_SET_CELL_STYLE;
+ }
+ if (path.endsWith("/style")) {
+ return ApiOperation.SET_CELL_STYLE;
+ }
+ if (path.contains("/sheets_batch_update")) {
+ return ApiOperation.SHEET_OPERATION;
+ }
+ if (path.contains("/dimension_range")) {
+ if ("POST".equals(method)) {
+ return ApiOperation.ADD_ROWS_COLUMNS;
+ }
+ if ("PUT".equals(method)) {
+ return ApiOperation.UPDATE_ROWS_COLUMNS;
+ }
+ if ("DELETE".equals(method)) {
+ return ApiOperation.DELETE_ROWS_COLUMNS;
+ }
+ }
+ if (path.contains("/insert_dimension_range")) {
+ return ApiOperation.INSERT_ROWS_COLUMNS;
+ }
+ if (path.contains("/dataValidation")) {
+ if ("GET".equals(method)) {
+ return ApiOperation.QUERY_DROPDOWN;
+ }
+ if ("POST".equals(method)) {
+ return ApiOperation.SET_DROPDOWN;
+ }
+ if ("PUT".equals(method)) {
+ return ApiOperation.UPDATE_DROPDOWN;
+ }
+ if ("DELETE".equals(method)) {
+ return ApiOperation.DELETE_DROPDOWN;
+ }
+ }
+ if (path.contains("/protected_dimension")) {
+ return ApiOperation.ADD_PROTECTED_RANGE;
+ }
+
+ return ApiOperation.GENERIC_OPERATION;
+ }
+
+ public static String extractSpreadsheetToken(Request request) {
+ if (request == null) {
+ return null;
+ }
+ HttpUrl url = request.url();
+ if (url == null) {
+ return null;
+ }
+ Matcher matcher = SPREADSHEET_TOKEN_PATTERN.matcher(url.encodedPath());
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ return null;
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/ratelimit/FeishuRateLimiterManager.java b/src/main/java/cn/isliu/core/ratelimit/FeishuRateLimiterManager.java
new file mode 100644
index 0000000..f316aca
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/FeishuRateLimiterManager.java
@@ -0,0 +1,46 @@
+package cn.isliu.core.ratelimit;
+
+import com.google.common.util.concurrent.RateLimiter;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 飞书频控管理器
+ *
+ * 基于租户/应用维度缓存 {@link RateLimiter},实现线程安全的限流。
+ */
+public class FeishuRateLimiterManager {
+
+ private final Map limiterCache = new ConcurrentHashMap<>();
+
+ public RateLimiter getLimiter(String tenantKey, RateLimitRule rule) {
+ String cacheKey = tenantKey + ":" + rule.getOperation().name();
+ return limiterCache.computeIfAbsent(cacheKey, key -> createLimiter(rule));
+ }
+
+ public void adjustRate(String tenantKey, ApiOperation operation, double permitsPerSecond) {
+ if (permitsPerSecond <= 0) {
+ return;
+ }
+ String cacheKey = tenantKey + ":" + operation.name();
+ RateLimiter limiter = limiterCache.get(cacheKey);
+ if (limiter != null) {
+ limiter.setRate(Math.max(0.1d, permitsPerSecond));
+ }
+ }
+
+ private RateLimiter createLimiter(RateLimitRule rule) {
+ double permitsPerSecond = calculatePermitsPerSecond(rule);
+ return RateLimiter.create(permitsPerSecond);
+ }
+
+ private double calculatePermitsPerSecond(RateLimitRule rule) {
+ double seconds = rule.getWindow().toMillis() / 1000.0d;
+ if (seconds <= 0) {
+ seconds = 1;
+ }
+ return Math.max(0.1d, rule.getPermits() / seconds);
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java b/src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java
new file mode 100644
index 0000000..b4b5fb4
--- /dev/null
+++ b/src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java
@@ -0,0 +1,94 @@
+package cn.isliu.core.ratelimit;
+
+import java.time.Duration;
+
+/**
+ * 频控规则定义
+ *
+ * 描述单个飞书 API 操作的频控窗口、令牌数以及串行约束等信息。
+ */
+public class RateLimitRule {
+
+ private final ApiOperation operation;
+ private final Duration window;
+ private final int permits;
+ private final boolean requireDocumentLock;
+ private final boolean allow429Retry;
+
+ private RateLimitRule(Builder builder) {
+ this.operation = builder.operation;
+ this.window = builder.window;
+ this.permits = builder.permits;
+ this.requireDocumentLock = builder.requireDocumentLock;
+ this.allow429Retry = builder.allow429Retry;
+ }
+
+ public ApiOperation getOperation() {
+ return operation;
+ }
+
+ public Duration getWindow() {
+ return window;
+ }
+
+ public int getPermits() {
+ return permits;
+ }
+
+ public boolean isRequireDocumentLock() {
+ return requireDocumentLock;
+ }
+
+ public boolean isAllow429Retry() {
+ return allow429Retry;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private ApiOperation operation;
+ private Duration window = Duration.ofSeconds(1);
+ private int permits = 1;
+ private boolean requireDocumentLock;
+ private boolean allow429Retry = true;
+
+ public Builder operation(ApiOperation operation) {
+ this.operation = operation;
+ return this;
+ }
+
+ public Builder window(Duration window) {
+ if (window != null && !window.isZero() && !window.isNegative()) {
+ this.window = window;
+ }
+ return this;
+ }
+
+ public Builder permits(int permits) {
+ if (permits > 0) {
+ this.permits = permits;
+ }
+ return this;
+ }
+
+ public Builder requireDocumentLock(boolean requireDocumentLock) {
+ this.requireDocumentLock = requireDocumentLock;
+ return this;
+ }
+
+ public Builder allow429Retry(boolean allow429Retry) {
+ this.allow429Retry = allow429Retry;
+ return this;
+ }
+
+ public RateLimitRule build() {
+ if (operation == null) {
+ throw new IllegalArgumentException("operation must not be null");
+ }
+ return new RateLimitRule(this);
+ }
+ }
+}
+
diff --git a/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java b/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java
index 3679b97..f3e6d6b 100644
--- a/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java
+++ b/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java
@@ -1,9 +1,13 @@
package cn.isliu.core.service;
import cn.isliu.core.client.FeishuClient;
+import cn.isliu.core.enums.ErrorCode;
import cn.isliu.core.exception.FsHelperException;
+import cn.isliu.core.logging.FsLogger;
+import cn.isliu.core.ratelimit.ApiOperation;
+import cn.isliu.core.ratelimit.FeishuApiExecutor;
+import cn.isliu.core.ratelimit.FeishuApiOperationResolver;
import com.google.gson.Gson;
-import com.google.gson.JsonObject;
import com.lark.oapi.core.utils.Jsons;
import okhttp3.*;
@@ -19,6 +23,8 @@ public abstract class AbstractFeishuApiService {
protected final OkHttpClient httpClient;
protected final Gson gson;
protected final TenantTokenManager tokenManager;
+ protected final FeishuApiExecutor apiExecutor;
+ protected final String tenantKey;
protected static final String BASE_URL = "https://open.feishu.cn/open-apis";
protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
@@ -33,6 +39,8 @@ public abstract class AbstractFeishuApiService {
this.httpClient = feishuClient.getHttpClient();
this.gson = Jsons.DEFAULT;
this.tokenManager = new TenantTokenManager(feishuClient);
+ this.apiExecutor = feishuClient.apiExecutor();
+ this.tenantKey = feishuClient.getAppId();
}
/**
@@ -77,8 +85,41 @@ public abstract class AbstractFeishuApiService {
* @throws IOException 请求异常
*/
protected T executeRequest(Request request, Class responseClass) throws IOException {
+ return executeRequest(null, request, responseClass);
+ }
+
+ protected T executeRequest(String spreadsheetToken, Request request, Class responseClass) throws IOException {
+ ApiOperation operation = FeishuApiOperationResolver.resolve(request);
+ String docToken = spreadsheetToken != null ? spreadsheetToken
+ : FeishuApiOperationResolver.extractSpreadsheetToken(request);
+ try {
+ return apiExecutor.execute(tenantKey, operation, docToken,
+ () -> doExecuteRequest(request, responseClass));
+ } catch (FsHelperException | IOException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ if (ex instanceof RuntimeException) {
+ throw (RuntimeException) ex;
+ }
+ throw new IOException("飞书接口调用失败", ex);
+ }
+ }
+
+ private T doExecuteRequest(Request request, Class responseClass) throws IOException {
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
+ if (response.code() == 429) {
+ FsHelperException exception = FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("飞书接口触发频控限制 (429)")
+ .context("httpStatus", response.code())
+ .context("x-ogw-ratelimit-limit", response.header("x-ogw-ratelimit-limit"))
+ .context("x-ogw-ratelimit-reset", response.header("x-ogw-ratelimit-reset"))
+ .context("requestId", response.header("X-Tt-Logid"))
+ .build();
+ FsLogger.warn("飞书接口频控:url={}, headers={}", request.url(),
+ response.headers().toMultimap());
+ throw exception;
+ }
throw new IOException("Request failed: " + response);
}
@@ -86,4 +127,9 @@ public abstract class AbstractFeishuApiService {
return gson.fromJson(responseBody, responseClass);
}
}
+
+ protected T executeWithOperation(ApiOperation operation, String spreadsheetToken,
+ FeishuApiExecutor.CheckedCallable action) throws Exception {
+ return apiExecutor.execute(tenantKey, operation, spreadsheetToken, action);
+ }
}
\ No newline at end of file
diff --git a/src/main/java/cn/isliu/core/utils/FsApiUtil.java b/src/main/java/cn/isliu/core/utils/FsApiUtil.java
index 4cd4949..c2c5780 100644
--- a/src/main/java/cn/isliu/core/utils/FsApiUtil.java
+++ b/src/main/java/cn/isliu/core/utils/FsApiUtil.java
@@ -12,6 +12,8 @@ import cn.isliu.core.exception.FsHelperException;
import cn.isliu.core.logging.FsLogger;
import cn.isliu.core.pojo.ApiResponse;
import cn.isliu.core.pojo.RootFolderMetaResponse;
+import cn.isliu.core.ratelimit.ApiOperation;
+import cn.isliu.core.ratelimit.FeishuApiExecutor;
import cn.isliu.core.service.*;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
@@ -42,6 +44,13 @@ public class FsApiUtil {
}
};
+ private static T executeOfficial(FeishuClient client,
+ ApiOperation operation,
+ String spreadsheetToken,
+ FeishuApiExecutor.CheckedCallable action) throws Exception {
+ return client.apiExecutor().execute(client.getAppId(), operation, spreadsheetToken, action);
+ }
+
/**
* 获取工作表数据
*
@@ -94,32 +103,37 @@ public class FsApiUtil {
*/
public static Sheet getSheetMetadata(String sheetId, FeishuClient client, String spreadsheetToken) {
try {
- QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder()
- .spreadsheetToken(spreadsheetToken)
- .build();
+ return executeOfficial(client, ApiOperation.GET_SPREADSHEET, spreadsheetToken, () -> {
+ QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder()
+ .spreadsheetToken(spreadsheetToken)
+ .build();
- QuerySpreadsheetSheetResp resp = client.sheets().v3().spreadsheetSheet().query(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
+ QuerySpreadsheetSheetResp resp = client.sheets().v3().spreadsheetSheet().query(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
- // 处理服务端错误
- if (resp.success()) {
- // 修复参数转换遗漏问题 - 直接使用添加了注解的类进行转换
- SheetMeta sheetMeta = gson.fromJson(gson.toJson(resp.getData()), SheetMeta.class);
- List sheets = sheetMeta.getSheets();
+ if (resp.success()) {
+ SheetMeta sheetMeta = gson.fromJson(gson.toJson(resp.getData()), SheetMeta.class);
+ List sheets = sheetMeta.getSheets();
+ AtomicReference sheet = new AtomicReference<>();
+ sheets.forEach(s -> {
+ if (s.getSheetId().equals(sheetId)) {
+ sheet.set(s);
+ }
+ });
+ return sheet.get();
+ }
- AtomicReference sheet = new AtomicReference<>();
- sheets.forEach(s -> {
- if (s.getSheetId().equals(sheetId)) {
- sheet.set(s);
- }
- });
-
- return sheet.get();
- } else {
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 获取Sheet元数据触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常!错误信息:" + gson.toJson(resp));
throw new FsHelperException("【飞书表格】 获取Sheet元数据异常!错误信息:" + resp.getMsg());
- }
-
+ });
+ } catch (FsHelperException ex) {
+ throw ex;
} catch (Exception e) {
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常!错误信息:" + e.getMessage(), "getSheetMeta", e);
throw new FsHelperException("【飞书表格】 获取Sheet元数据异常!");
@@ -194,24 +208,32 @@ public class FsApiUtil {
public static CreateFolderFileRespBody createFolder(String folderName, String folderToken, FeishuClient client) {
try {
- // 创建请求对象
- CreateFolderFileReq req = CreateFolderFileReq.newBuilder()
- .createFolderFileReqBody(CreateFolderFileReqBody.newBuilder()
- .name(folderName)
- .folderToken(folderToken)
- .build())
- .build();
+ return executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
+ CreateFolderFileReq req = CreateFolderFileReq.newBuilder()
+ .createFolderFileReqBody(CreateFolderFileReqBody.newBuilder()
+ .name(folderName)
+ .folderToken(folderToken)
+ .build())
+ .build();
- // 发起请求
- CreateFolderFileResp resp = client.drive().v1().file().createFolder(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
- if (resp.success()) {
- FsLogger.info("【飞书表格】 创建文件夹成功! {}", gson.toJson(resp));
- return resp.getData();
- } else {
+ CreateFolderFileResp resp = client.drive().v1().file().createFolder(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
+ if (resp.success()) {
+ FsLogger.info("【飞书表格】 创建文件夹成功! {}", gson.toJson(resp));
+ return resp.getData();
+ }
+
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 创建文件夹触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
FsLogger.warn("【飞书表格】 创建文件夹失败!参数:{},错误信息:{}", String.format("folderName: %s, folderToken: %s", folderName, folderToken), resp.getMsg());
throw new FsHelperException("【飞书表格】 创建文件夹失败!");
- }
+ });
+ } catch (FsHelperException ex) {
+ throw ex;
} catch (Exception e) {
FsLogger.warn("【飞书表格】 创建文件夹异常!参数:{},错误信息:{}", String.format("folderName: %s, folderToken: %s", folderName, folderToken), e.getMessage(), e);
throw new FsHelperException("【飞书表格】 创建文件夹异常!");
@@ -220,22 +242,33 @@ public class FsApiUtil {
public static CreateSpreadsheetRespBody createTable(String tableName, String folderToken, FeishuClient client) {
try {
- CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder()
- .spreadsheet(Spreadsheet.newBuilder()
- .title(tableName)
- .folderToken(folderToken)
- .build())
- .build();
+ return executeOfficial(client, ApiOperation.CREATE_SPREADSHEET, null, () -> {
+ CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder()
+ .spreadsheet(Spreadsheet.newBuilder()
+ .title(tableName)
+ .folderToken(folderToken)
+ .build())
+ .build();
- CreateSpreadsheetResp resp = client.sheets().v3().spreadsheet().create(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
- if (resp.success()) {
- FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp));
- return resp.getData();
- } else {
+ CreateSpreadsheetResp resp = client.sheets().v3().spreadsheet().create(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
+
+ if (resp.success()) {
+ FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp));
+ return resp.getData();
+ }
+
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 创建表格触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
FsLogger.warn("【飞书表格】 创建表格失败!错误信息:{}", gson.toJson(resp));
throw new FsHelperException("【飞书表格】 创建表格异常!");
- }
+ });
+ } catch (FsHelperException ex) {
+ throw ex;
} catch (Exception e) {
FsLogger.warn("【飞书表格】 创建表格异常!参数:{},错误信息:{}", String.format("tableName:%s, folderToken:%s", tableName, folderToken), e.getMessage(), e);
throw new FsHelperException("【飞书表格】 创建表格异常!");
@@ -391,20 +424,30 @@ public class FsApiUtil {
*/
public static void downloadMaterial(String fileToken, String outputPath, FeishuClient client, String extra) {
try {
- DownloadMediaReq req = DownloadMediaReq.newBuilder()
- .fileToken(fileToken)
-// .extra("无")
- .build();
+ executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
+ DownloadMediaReq req = DownloadMediaReq.newBuilder()
+ .fileToken(fileToken)
+ .build();
- // 发起请求
- DownloadMediaResp resp = client.drive().v1().media().download(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
+ DownloadMediaResp resp = client.drive().v1().media().download(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
+
+ if (!resp.success()) {
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 下载素材触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
+ FsLogger.warn("【飞书表格】 下载素材失败!参数:{},错误信息:{}", fileToken, gson.toJson(resp));
+ throw new FsHelperException("【飞书表格】 下载素材失败!");
+ }
- // 处理服务端错误
- if (resp.success()) {
resp.writeFile(outputPath);
- }
-
+ return null;
+ });
+ } catch (FsHelperException ex) {
+ throw ex;
} catch (Exception e) {
FsLogger.warn("【飞书表格】 下载素材异常!参数:{},错误信息:{}", fileToken, e.getMessage());
throw new FsHelperException("【飞书表格】 下载素材异常!");
@@ -414,18 +457,27 @@ public class FsApiUtil {
public static String downloadTmpMaterialUrl(String fileToken, FeishuClient client) {
String tmpUrl = "";
try {
- BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder()
- .fileTokens(new String[]{fileToken})
- .build();
+ return executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
+ BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder()
+ .fileTokens(new String[]{fileToken})
+ .build();
- BatchGetTmpDownloadUrlMediaResp resp = client.drive().v1().media().batchGetTmpDownloadUrl(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
+ BatchGetTmpDownloadUrlMediaResp resp = client.drive().v1().media().batchGetTmpDownloadUrl(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
- if (resp.success()) {
- return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl();
- } else {
+ if (resp.success()) {
+ return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl();
+ }
+
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 获取临时下载地址触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
FsLogger.warn("【飞书表格】 获取临时下载地址失败!参数:{},错误信息:{}", fileToken, gson.toJson(resp));
- }
+ return "";
+ });
} catch (Exception e) {
FsLogger.warn("【飞书表格】 获取临时下载地址异常!参数:{},错误信息:{}", fileToken, e.getMessage());
}
@@ -452,7 +504,7 @@ public class FsApiUtil {
String startColumn = FsTableUtil.getColumnNameByNuNumber(fromColumnIndex);
String endColumn = FsTableUtil.getColumnNameByNuNumber(fromColumnIndex + slice.size() - 1);
- builder.addRange(sheetId + "!" + startColumn + "1:" + endColumn + "1");
+ builder.addRange(sheetId + "!" + startColumn + titleRow + ":" + endColumn + titleRow);
builder.addRow(slice.toArray());
index = end;
@@ -565,25 +617,33 @@ public class FsApiUtil {
public static Object getTableInfo(String sheetId, String spreadsheetToken, FeishuClient client) {
try {
- // 创建请求对象
- GetSpreadsheetReq req = GetSpreadsheetReq.newBuilder()
- .spreadsheetToken(spreadsheetToken)
- .build();
+ return executeOfficial(client, ApiOperation.GET_SPREADSHEET, spreadsheetToken, () -> {
+ GetSpreadsheetReq req = GetSpreadsheetReq.newBuilder()
+ .spreadsheetToken(spreadsheetToken)
+ .build();
- // 发起请求
- GetSpreadsheetResp resp = client.sheets().v3().spreadsheet().get(req, client.getCloseOfficialPool()
- ? RequestOptions.newBuilder().headers(m).build() : null);
+ GetSpreadsheetResp resp = client.sheets().v3().spreadsheet().get(req, client.getCloseOfficialPool()
+ ? RequestOptions.newBuilder().headers(m).build() : null);
- // 处理服务端错误
- if (resp.success()) {
- return resp.getData();
- } else {
+ if (resp.success()) {
+ return resp.getData();
+ }
+
+ if (resp.getCode() == 99991400) {
+ throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
+ .message("【飞书表格】 获取表格信息触发频控限制")
+ .context("httpStatus", 429)
+ .build();
+ }
FsLogger.warn("【飞书表格】 获取表格信息失败!参数:{},错误信息:{}", sheetId, resp.getMsg());
- }
+ return null;
+ });
+ } catch (FsHelperException ex) {
+ throw ex;
} catch (Exception e) {
FsLogger.warn("【飞书表格】 获取表格信息异常!参数:{},错误信息:{}", sheetId, e.getMessage());
+ return null;
}
- return null;
}
/**