From 492626ad8d3dec706e32eb15fa166d5b70a8e42e Mon Sep 17 00:00:00 2001 From: liushuang Date: Thu, 13 Nov 2025 23:35:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=BC=95=E5=85=A5=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6API=E9=99=90=E6=B5=81=E4=B8=8E=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增API操作枚举ApiOperation,定义各类接口的频控规则 - 实现FeishuApiExecutor统一处理限流、429重试和文档锁逻辑- 添加DocumentLockRegistry支持文档级别串行调用 - 集成FeishuRateLimiterManager管理租户维度的限流器 - 在AbstractFeishuApiService中封装executeRequest方法处理限流 - 扩展FsApiUtil工具类支持通过executeOfficial方法调用受控API - 新增FeishuApiOperationResolver用于解析请求对应的操作类型 - 完善飞书接口调用异常处理,增强频控场景下的错误提示 -优化表格标题行读取逻辑,修正range构造中的标题行索引问题 --- .../cn/isliu/core/client/FeishuClient.java | 28 ++- .../cn/isliu/core/ratelimit/ApiOperation.java | 114 +++++++++ .../core/ratelimit/DocumentLockRegistry.java | 32 +++ .../core/ratelimit/FeishuApiExecutor.java | 160 +++++++++++++ .../ratelimit/FeishuApiOperationResolver.java | 118 +++++++++ .../ratelimit/FeishuRateLimiterManager.java | 46 ++++ .../isliu/core/ratelimit/RateLimitRule.java | 94 ++++++++ .../service/AbstractFeishuApiService.java | 48 +++- .../java/cn/isliu/core/utils/FsApiUtil.java | 226 +++++++++++------- 9 files changed, 776 insertions(+), 90 deletions(-) create mode 100644 src/main/java/cn/isliu/core/ratelimit/ApiOperation.java create mode 100644 src/main/java/cn/isliu/core/ratelimit/DocumentLockRegistry.java create mode 100644 src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java create mode 100644 src/main/java/cn/isliu/core/ratelimit/FeishuApiOperationResolver.java create mode 100644 src/main/java/cn/isliu/core/ratelimit/FeishuRateLimiterManager.java create mode 100644 src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java 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; } /**