feat(core): 引入飞书API限流与重试机制
- 新增API操作枚举ApiOperation,定义各类接口的频控规则 - 实现FeishuApiExecutor统一处理限流、429重试和文档锁逻辑- 添加DocumentLockRegistry支持文档级别串行调用 - 集成FeishuRateLimiterManager管理租户维度的限流器 - 在AbstractFeishuApiService中封装executeRequest方法处理限流 - 扩展FsApiUtil工具类支持通过executeOfficial方法调用受控API - 新增FeishuApiOperationResolver用于解析请求对应的操作类型 - 完善飞书接口调用异常处理,增强频控场景下的错误提示 -优化表格标题行读取逻辑,修正range构造中的标题行索引问题
This commit is contained in:
parent
58251de5a0
commit
492626ad8d
@ -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构建器
|
||||||
*/
|
*/
|
||||||
|
|||||||
114
src/main/java/cn/isliu/core/ratelimit/ApiOperation.java
Normal file
114
src/main/java/cn/isliu/core/ratelimit/ApiOperation.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
160
src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java
Normal file
160
src/main/java/cn/isliu/core/ratelimit/FeishuApiExecutor.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
94
src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java
Normal file
94
src/main/java/cn/isliu/core/ratelimit/RateLimitRule.java
Normal file
@ -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,32 +103,37 @@ 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 {
|
||||||
QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder()
|
return executeOfficial(client, ApiOperation.GET_SPREADSHEET, spreadsheetToken, () -> {
|
||||||
.spreadsheetToken(spreadsheetToken)
|
QuerySpreadsheetSheetReq req = QuerySpreadsheetSheetReq.newBuilder()
|
||||||
.build();
|
.spreadsheetToken(spreadsheetToken)
|
||||||
|
.build();
|
||||||
|
|
||||||
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);
|
||||||
// 修复参数转换遗漏问题 - 直接使用添加了注解的类进行转换
|
List<Sheet> sheets = sheetMeta.getSheets();
|
||||||
SheetMeta sheetMeta = gson.fromJson(gson.toJson(resp.getData()), SheetMeta.class);
|
AtomicReference<Sheet> sheet = new AtomicReference<>();
|
||||||
List<Sheet> sheets = sheetMeta.getSheets();
|
sheets.forEach(s -> {
|
||||||
|
if (s.getSheetId().equals(sheetId)) {
|
||||||
|
sheet.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sheet.get();
|
||||||
|
}
|
||||||
|
|
||||||
AtomicReference<Sheet> sheet = new AtomicReference<>();
|
if (resp.getCode() == 99991400) {
|
||||||
sheets.forEach(s -> {
|
throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
|
||||||
if (s.getSheetId().equals(sheetId)) {
|
.message("【飞书表格】 获取Sheet元数据触发频控限制")
|
||||||
sheet.set(s);
|
.context("httpStatus", 429)
|
||||||
}
|
.build();
|
||||||
});
|
}
|
||||||
|
|
||||||
return sheet.get();
|
|
||||||
} else {
|
|
||||||
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常!错误信息:" + gson.toJson(resp));
|
FsLogger.error(ErrorCode.API_CALL_FAILED, "【飞书表格】 获取Sheet元数据异常!错误信息:" + gson.toJson(resp));
|
||||||
throw new FsHelperException("【飞书表格】 获取Sheet元数据异常!错误信息:" + resp.getMsg());
|
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,24 +208,32 @@ 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)
|
||||||
.folderToken(folderToken)
|
.folderToken(folderToken)
|
||||||
.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,22 +242,33 @@ 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 {
|
||||||
CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder()
|
return executeOfficial(client, ApiOperation.CREATE_SPREADSHEET, null, () -> {
|
||||||
.spreadsheet(Spreadsheet.newBuilder()
|
CreateSpreadsheetReq req = CreateSpreadsheetReq.newBuilder()
|
||||||
.title(tableName)
|
.spreadsheet(Spreadsheet.newBuilder()
|
||||||
.folderToken(folderToken)
|
.title(tableName)
|
||||||
.build())
|
.folderToken(folderToken)
|
||||||
.build();
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
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()) {
|
|
||||||
FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp));
|
if (resp.success()) {
|
||||||
return resp.getData();
|
FsLogger.info("【飞书表格】 创建表格成功! {}", gson.toJson(resp));
|
||||||
} else {
|
return resp.getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
DownloadMediaReq req = DownloadMediaReq.newBuilder()
|
executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
|
||||||
.fileToken(fileToken)
|
DownloadMediaReq req = DownloadMediaReq.newBuilder()
|
||||||
// .extra("无")
|
.fileToken(fileToken)
|
||||||
.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.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);
|
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,18 +457,27 @@ 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 {
|
||||||
BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder()
|
return executeOfficial(client, ApiOperation.GENERIC_OPERATION, null, () -> {
|
||||||
.fileTokens(new String[]{fileToken})
|
BatchGetTmpDownloadUrlMediaReq req = BatchGetTmpDownloadUrlMediaReq.newBuilder()
|
||||||
.build();
|
.fileTokens(new String[]{fileToken})
|
||||||
|
.build();
|
||||||
|
|
||||||
BatchGetTmpDownloadUrlMediaResp resp = client.drive().v1().media().batchGetTmpDownloadUrl(req, client.getCloseOfficialPool()
|
BatchGetTmpDownloadUrlMediaResp resp = client.drive().v1().media().batchGetTmpDownloadUrl(req, client.getCloseOfficialPool()
|
||||||
? RequestOptions.newBuilder().headers(m).build() : null);
|
? RequestOptions.newBuilder().headers(m).build() : null);
|
||||||
|
|
||||||
if (resp.success()) {
|
if (resp.success()) {
|
||||||
return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl();
|
return resp.getData().getTmpDownloadUrls()[0].getTmpDownloadUrl();
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (resp.getCode() == 99991400) {
|
||||||
|
throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
|
||||||
|
.message("【飞书表格】 获取临时下载地址触发频控限制")
|
||||||
|
.context("httpStatus", 429)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
FsLogger.warn("【飞书表格】 获取临时下载地址失败!参数:{},错误信息:{}", fileToken, gson.toJson(resp));
|
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,25 +617,33 @@ 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 {
|
|
||||||
|
if (resp.getCode() == 99991400) {
|
||||||
|
throw FsHelperException.builder(ErrorCode.API_CALL_FAILED)
|
||||||
|
.message("【飞书表格】 获取表格信息触发频控限制")
|
||||||
|
.context("httpStatus", 429)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
FsLogger.warn("【飞书表格】 获取表格信息失败!参数:{},错误信息:{}", sheetId, resp.getMsg());
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user