feat(core): 引入飞书API限流与重试机制

- 新增API操作枚举ApiOperation,定义各类接口的频控规则
- 实现FeishuApiExecutor统一处理限流、429重试和文档锁逻辑- 添加DocumentLockRegistry支持文档级别串行调用
- 集成FeishuRateLimiterManager管理租户维度的限流器
- 在AbstractFeishuApiService中封装executeRequest方法处理限流
- 扩展FsApiUtil工具类支持通过executeOfficial方法调用受控API
- 新增FeishuApiOperationResolver用于解析请求对应的操作类型
- 完善飞书接口调用异常处理,增强频控场景下的错误提示
-优化表格标题行读取逻辑,修正range构造中的标题行索引问题
This commit is contained in:
liushuang 2025-11-13 23:35:48 +08:00
parent 58251de5a0
commit 492626ad8d
9 changed files with 776 additions and 90 deletions

@ -4,11 +4,13 @@ import com.lark.oapi.Client;
import com.lark.oapi.core.enums.AppType; import com.lark.oapi.core.enums.AppType;
import com.lark.oapi.service.drive.DriveService; import com.lark.oapi.service.drive.DriveService;
import com.lark.oapi.service.sheets.SheetsService; import com.lark.oapi.service.sheets.SheetsService;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import java.util.concurrent.TimeUnit; 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.*; import cn.isliu.core.service.*;
/** /**
@ -23,6 +25,9 @@ public class FeishuClient {
private final String appId; private final String appId;
private final String appSecret; private final String appSecret;
private final boolean closeOfficialPool; private final boolean closeOfficialPool;
private final FeishuRateLimiterManager rateLimiterManager;
private final DocumentLockRegistry documentLockRegistry;
private final FeishuApiExecutor apiExecutor;
// 自定义服务处理官方SDK未覆盖的API // 自定义服务处理官方SDK未覆盖的API
private volatile CustomSheetService customSheetService; private volatile CustomSheetService customSheetService;
@ -34,11 +39,7 @@ public class FeishuClient {
private volatile CustomFileService customFileService; private volatile CustomFileService customFileService;
private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient) { private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient) {
this.appId = appId; this(appId, appSecret, officialClient, httpClient, false);
this.appSecret = appSecret;
this.officialClient = officialClient;
this.httpClient = httpClient;
this.closeOfficialPool = false;
} }
private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient, boolean closeOfficialPool) { private FeishuClient(String appId, String appSecret, Client officialClient, OkHttpClient httpClient, boolean closeOfficialPool) {
@ -47,6 +48,9 @@ public class FeishuClient {
this.officialClient = officialClient; this.officialClient = officialClient;
this.httpClient = httpClient; this.httpClient = httpClient;
this.closeOfficialPool = closeOfficialPool; 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; return closeOfficialPool;
} }
public FeishuApiExecutor apiExecutor() {
return apiExecutor;
}
public FeishuRateLimiterManager rateLimiterManager() {
return rateLimiterManager;
}
public DocumentLockRegistry documentLockRegistry() {
return documentLockRegistry;
}
/** /**
* FeishuClient构建器 * FeishuClient构建器
*/ */

@ -0,0 +1,114 @@
package cn.isliu.core.ratelimit;
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
/**
* 飞书 API 操作枚举
*
* <p>枚举定义了不同 API 行为的频控规则便于统一限流管理</p>
*/
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<ApiOperation, RateLimitRule> 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);
}
}

@ -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;
/**
* 文档级别锁注册表
*
* <p>对于单个文档只能串行调用的操作通过文档 token 获取同一把锁</p>
*/
public class DocumentLockRegistry {
private final ConcurrentMap<String, ReentrantLock> 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);
}
}
}

@ -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 调用执行器
*
* <p>统一处理限流429 重试串行锁等逻辑</p>
*/
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> T execute(String tenantKey,
ApiOperation operation,
String spreadsheetToken,
CheckedCallable<T> 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> T executeWithRetry(String tenantKey,
ApiOperation operation,
RateLimitRule rule,
CheckedCallable<T> 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<T> extends Callable<T> {
@Override
T call() throws Exception;
}
}

@ -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;
}
}

@ -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;
/**
* 飞书频控管理器
*
* <p>基于租户/应用维度缓存 {@link RateLimiter}实现线程安全的限流</p>
*/
public class FeishuRateLimiterManager {
private final Map<String, RateLimiter> 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);
}
}

@ -0,0 +1,94 @@
package cn.isliu.core.ratelimit;
import java.time.Duration;
/**
* 频控规则定义
*
* <p>描述单个飞书 API 操作的频控窗口令牌数以及串行约束等信息</p>
*/
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);
}
}
}

@ -1,9 +1,13 @@
package cn.isliu.core.service; package cn.isliu.core.service;
import cn.isliu.core.client.FeishuClient; import cn.isliu.core.client.FeishuClient;
import cn.isliu.core.enums.ErrorCode;
import cn.isliu.core.exception.FsHelperException; 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.Gson;
import com.google.gson.JsonObject;
import com.lark.oapi.core.utils.Jsons; import com.lark.oapi.core.utils.Jsons;
import okhttp3.*; import okhttp3.*;
@ -19,6 +23,8 @@ public abstract class AbstractFeishuApiService {
protected final OkHttpClient httpClient; protected final OkHttpClient httpClient;
protected final Gson gson; protected final Gson gson;
protected final TenantTokenManager tokenManager; 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 String BASE_URL = "https://open.feishu.cn/open-apis";
protected static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8"); 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.httpClient = feishuClient.getHttpClient();
this.gson = Jsons.DEFAULT; this.gson = Jsons.DEFAULT;
this.tokenManager = new TenantTokenManager(feishuClient); this.tokenManager = new TenantTokenManager(feishuClient);
this.apiExecutor = feishuClient.apiExecutor();
this.tenantKey = feishuClient.getAppId();
} }
/** /**
@ -77,8 +85,41 @@ public abstract class AbstractFeishuApiService {
* @throws IOException 请求异常 * @throws IOException 请求异常
*/ */
protected <T> T executeRequest(Request request, Class<T> responseClass) throws IOException { protected <T> T executeRequest(Request request, Class<T> responseClass) throws IOException {
return executeRequest(null, request, responseClass);
}
protected <T> T executeRequest(String spreadsheetToken, Request request, Class<T> 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> T doExecuteRequest(Request request, Class<T> responseClass) throws IOException {
try (Response response = httpClient.newCall(request).execute()) { try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) { 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); throw new IOException("Request failed: " + response);
} }
@ -86,4 +127,9 @@ public abstract class AbstractFeishuApiService {
return gson.fromJson(responseBody, responseClass); return gson.fromJson(responseBody, responseClass);
} }
} }
protected <T> T executeWithOperation(ApiOperation operation, String spreadsheetToken,
FeishuApiExecutor.CheckedCallable<T> action) throws Exception {
return apiExecutor.execute(tenantKey, operation, spreadsheetToken, action);
}
} }

@ -12,6 +12,8 @@ import cn.isliu.core.exception.FsHelperException;
import cn.isliu.core.logging.FsLogger; import cn.isliu.core.logging.FsLogger;
import cn.isliu.core.pojo.ApiResponse; import cn.isliu.core.pojo.ApiResponse;
import cn.isliu.core.pojo.RootFolderMetaResponse; import cn.isliu.core.pojo.RootFolderMetaResponse;
import cn.isliu.core.ratelimit.ApiOperation;
import cn.isliu.core.ratelimit.FeishuApiExecutor;
import cn.isliu.core.service.*; import cn.isliu.core.service.*;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonArray; import com.google.gson.JsonArray;
@ -42,6 +44,13 @@ public class FsApiUtil {
} }
}; };
private static <T> T executeOfficial(FeishuClient client,
ApiOperation operation,
String spreadsheetToken,
FeishuApiExecutor.CheckedCallable<T> action) throws Exception {
return client.apiExecutor().execute(client.getAppId(), operation, spreadsheetToken, action);
}
/** /**
* 获取工作表数据 * 获取工作表数据
* *
@ -94,6 +103,7 @@ public class FsApiUtil {
*/ */
public static Sheet getSheetMetadata(String sheetId, FeishuClient client, String spreadsheetToken) { public static Sheet getSheetMetadata(String sheetId, FeishuClient client, String spreadsheetToken) {
try { try {
return executeOfficial(client, ApiOperation.GET_SPREADSHEET, spreadsheetToken, () -> {
QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder() QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder()
.spreadsheetToken(spreadsheetToken) .spreadsheetToken(spreadsheetToken)
.build(); .build();
@ -101,25 +111,29 @@ public class FsApiUtil {
QuerySpreadsheetSheetResp resp = client.sheets().v3().spreadsheetSheet().query(req, client.getCloseOfficialPool() QuerySpreadsheetSheetResp resp = client.sheets().v3().spreadsheetSheet().query(req, client.getCloseOfficialPool()
? RequestOptions.newBuilder().headers(m).build() : null); ? RequestOptions.newBuilder().headers(m).build() : null);
// 处理服务端错误
if (resp.success()) { if (resp.success()) {
// 修复参数转换遗漏问题 - 直接使用添加了注解的类进行转换
SheetMeta sheetMeta = gson.fromJson(gson.toJson(resp.getData()), SheetMeta.class); SheetMeta sheetMeta = gson.fromJson(gson.toJson(resp.getData()), SheetMeta.class);
List<Sheet> sheets = sheetMeta.getSheets(); List<Sheet> sheets = sheetMeta.getSheets();
AtomicReference<Sheet> sheet = new AtomicReference<>(); AtomicReference<Sheet> sheet = new AtomicReference<>();
sheets.forEach(s -> { sheets.forEach(s -> {
if (s.getSheetId().equals(sheetId)) { if (s.getSheetId().equals(sheetId)) {
sheet.set(s); sheet.set(s);
} }
}); });
return sheet.get(); return sheet.get();
} else {
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常错误信息" + gson.toJson(resp));
throw new FsHelperException("【飞书表格】 获取Sheet元数据异常错误信息" + resp.getMsg());
} }
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) { } catch (Exception e) {
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常错误信息" + e.getMessage(), "getSheetMeta", e); FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常错误信息" + e.getMessage(), "getSheetMeta", e);
throw new FsHelperException("【飞书表格】 获取Sheet元数据异常"); throw new FsHelperException("【飞书表格】 获取Sheet元数据异常");
@ -194,7 +208,7 @@ public class FsApiUtil {
public static CreateFolderFileRespBody createFolder(String folderName, String folderToken, FeishuClient client) { public static CreateFolderFileRespBody createFolder(String folderName, String folderToken, FeishuClient client) {
try { try {
// 创建请求对象 return executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
CreateFolderFileReq req = CreateFolderFileReq.newBuilder() CreateFolderFileReq req = CreateFolderFileReq.newBuilder()
.createFolderFileReqBody(CreateFolderFileReqBody.newBuilder() .createFolderFileReqBody(CreateFolderFileReqBody.newBuilder()
.name(folderName) .name(folderName)
@ -202,16 +216,24 @@ public class FsApiUtil {
.build()) .build())
.build(); .build();
// 发起请求
CreateFolderFileResp resp = client.drive().v1().file().createFolder(req, client.getCloseOfficialPool() CreateFolderFileResp resp = client.drive().v1().file().createFolder(req, client.getCloseOfficialPool()
? RequestOptions.newBuilder().headers(m).build() : null); ? RequestOptions.newBuilder().headers(m).build() : null);
if (resp.success()) { if (resp.success()) {
FsLogger.info("【飞书表格】 创建文件夹成功! {}", gson.toJson(resp)); FsLogger.info("【飞书表格】 创建文件夹成功! {}", gson.toJson(resp));
return resp.getData(); return resp.getData();
} else { }
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()); FsLogger.warn("【飞书表格】 创建文件夹失败!参数:{},错误信息:{}", String.format("folderName: %s, folderToken: %s", folderName, folderToken), resp.getMsg());
throw new FsHelperException("【飞书表格】 创建文件夹失败!"); throw new FsHelperException("【飞书表格】 创建文件夹失败!");
} });
} catch (FsHelperException ex) {
throw ex;
} catch (Exception e) { } catch (Exception e) {
FsLogger.warn("【飞书表格】 创建文件夹异常!参数:{},错误信息:{}", String.format("folderName: %s, folderToken: %s", folderName, folderToken), e.getMessage(), e); FsLogger.warn("【飞书表格】 创建文件夹异常!参数:{},错误信息:{}", String.format("folderName: %s, folderToken: %s", folderName, folderToken), e.getMessage(), e);
throw new FsHelperException("【飞书表格】 创建文件夹异常!"); throw new FsHelperException("【飞书表格】 创建文件夹异常!");
@ -220,6 +242,7 @@ public class FsApiUtil {
public static CreateSpreadsheetRespBody createTable(String tableName, String folderToken, FeishuClient client) { public static CreateSpreadsheetRespBody createTable(String tableName, String folderToken, FeishuClient client) {
try { try {
return executeOfficial(client, ApiOperation.CREATE_SPREADSHEET, null, () -> {
CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder() CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder()
.spreadsheet(Spreadsheet.newBuilder() .spreadsheet(Spreadsheet.newBuilder()
.title(tableName) .title(tableName)
@ -229,13 +252,23 @@ public class FsApiUtil {
CreateSpreadsheetResp resp = client.sheets().v3().spreadsheet().create(req, client.getCloseOfficialPool() CreateSpreadsheetResp resp = client.sheets().v3().spreadsheet().create(req, client.getCloseOfficialPool()
? RequestOptions.newBuilder().headers(m).build() : null); ? RequestOptions.newBuilder().headers(m).build() : null);
if (resp.success()) { if (resp.success()) {
FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp)); FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp));
return resp.getData(); return resp.getData();
} else { }
if (resp.getCode() == 99991400) {
throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
.message("【飞书表格】 创建表格触发频控限制")
.context("httpStatus", 429)
.build();
}
FsLogger.warn("【飞书表格】 创建表格失败!错误信息:{}", gson.toJson(resp)); FsLogger.warn("【飞书表格】 创建表格失败!错误信息:{}", gson.toJson(resp));
throw new FsHelperException("【飞书表格】 创建表格异常!"); throw new FsHelperException("【飞书表格】 创建表格异常!");
} });
} catch (FsHelperException ex) {
throw ex;
} catch (Exception e) { } catch (Exception e) {
FsLogger.warn("【飞书表格】 创建表格异常!参数:{},错误信息:{}", String.format("tableName:%s, folderToken:%s", tableName, folderToken), e.getMessage(), e); FsLogger.warn("【飞书表格】 创建表格异常!参数:{},错误信息:{}", String.format("tableName:%s, folderToken:%s", tableName, folderToken), e.getMessage(), e);
throw new FsHelperException("【飞书表格】 创建表格异常!"); throw new FsHelperException("【飞书表格】 创建表格异常!");
@ -391,20 +424,30 @@ public class FsApiUtil {
*/ */
public static void downloadMaterial(String fileToken, String outputPath, FeishuClient client, String extra) { public static void downloadMaterial(String fileToken, String outputPath, FeishuClient client, String extra) {
try { try {
executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
DownloadMediaReq req = DownloadMediaReq.newBuilder() DownloadMediaReq req = DownloadMediaReq.newBuilder()
.fileToken(fileToken) .fileToken(fileToken)
// .extra("")
.build(); .build();
// 发起请求
DownloadMediaResp resp = client.drive().v1().media().download(req, client.getCloseOfficialPool() DownloadMediaResp resp = client.drive().v1().media().download(req, client.getCloseOfficialPool()
? RequestOptions.newBuilder().headers(m).build() : null); ? RequestOptions.newBuilder().headers(m).build() : null);
// 处理服务端错误 if (!resp.success()) {
if (resp.success()) { if (resp.getCode() == 99991400) {
resp.writeFile(outputPath); throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
.message("【飞书表格】 下载素材触发频控限制")
.context("httpStatus", 429)
.build();
}
FsLogger.warn("【飞书表格】 下载素材失败!参数:{},错误信息:{}", fileToken, gson.toJson(resp));
throw new FsHelperException("【飞书表格】 下载素材失败!");
} }
resp.writeFile(outputPath);
return null;
});
} catch (FsHelperException ex) {
throw ex;
} catch (Exception e) { } catch (Exception e) {
FsLogger.warn("【飞书表格】 下载素材异常!参数:{},错误信息:{}", fileToken, e.getMessage()); FsLogger.warn("【飞书表格】 下载素材异常!参数:{},错误信息:{}", fileToken, e.getMessage());
throw new FsHelperException("【飞书表格】 下载素材异常!"); throw new FsHelperException("【飞书表格】 下载素材异常!");
@ -414,6 +457,7 @@ public class FsApiUtil {
public static String downloadTmpMaterialUrl(String fileToken, FeishuClient client) { public static String downloadTmpMaterialUrl(String fileToken, FeishuClient client) {
String tmpUrl = ""; String tmpUrl = "";
try { try {
return executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder() BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder()
.fileTokens(new String[]{fileToken}) .fileTokens(new String[]{fileToken})
.build(); .build();
@ -423,9 +467,17 @@ public class FsApiUtil {
if (resp.success()) { if (resp.success()) {
return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl(); return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl();
} else {
FsLogger.warn("【飞书表格】 获取临时下载地址失败!参数:{},错误信息:{}", fileToken, gson.toJson(resp));
} }
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) { } catch (Exception e) {
FsLogger.warn("【飞书表格】 获取临时下载地址异常!参数:{},错误信息:{}", fileToken, e.getMessage()); FsLogger.warn("【飞书表格】 获取临时下载地址异常!参数:{},错误信息:{}", fileToken, e.getMessage());
} }
@ -452,7 +504,7 @@ public class FsApiUtil {
String startColumn = FsTableUtil.getColumnNameByNuNumber(fromColumnIndex); String startColumn = FsTableUtil.getColumnNameByNuNumber(fromColumnIndex);
String endColumn = FsTableUtil.getColumnNameByNuNumber(fromColumnIndex + slice.size() - 1); 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()); builder.addRow(slice.toArray());
index = end; index = end;
@ -565,26 +617,34 @@ public class FsApiUtil {
public static Object getTableInfo(String sheetId, String spreadsheetToken, FeishuClient client) { public static Object getTableInfo(String sheetId, String spreadsheetToken, FeishuClient client) {
try { try {
// 创建请求对象 return executeOfficial(client, ApiOperation.GET_SPREADSHEET, spreadsheetToken, () -> {
GetSpreadsheetReq req = GetSpreadsheetReq.newBuilder() GetSpreadsheetReq req = GetSpreadsheetReq.newBuilder()
.spreadsheetToken(spreadsheetToken) .spreadsheetToken(spreadsheetToken)
.build(); .build();
// 发起请求
GetSpreadsheetResp resp = client.sheets().v3().spreadsheet().get(req, client.getCloseOfficialPool() GetSpreadsheetResp resp = client.sheets().v3().spreadsheet().get(req, client.getCloseOfficialPool()
? RequestOptions.newBuilder().headers(m).build() : null); ? RequestOptions.newBuilder().headers(m).build() : null);
// 处理服务端错误
if (resp.success()) { if (resp.success()) {
return resp.getData(); return resp.getData();
} else {
FsLogger.warn("【飞书表格】 获取表格信息失败!参数:{},错误信息:{}", sheetId, resp.getMsg());
} }
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) { } catch (Exception e) {
FsLogger.warn("【飞书表格】 获取表格信息异常!参数:{},错误信息:{}", sheetId, e.getMessage()); FsLogger.warn("【飞书表格】 获取表格信息异常!参数:{},错误信息:{}", sheetId, e.getMessage());
}
return null; return null;
} }
}
/** /**
* 字符串类型 formatter: "@" * 字符串类型 formatter: "@"