feat(core): 重构配置管理并优化表格处理逻辑

- 移除 FsConfig 类,使用 TableConf 注解替代
- 重构 FsHelper 类,使用新配置方式- 优化 FsTableUtil 类,支持多级表头和描述行
- 更新 CustomValueService 类,添加 majorDimension 支持
- 修改 BaseEntity 类,增加 row 和 rowData 字段
This commit is contained in:
liushuang 2025-08-20 23:35:31 +08:00
parent aa14a659ee
commit 5a43a7f2e3
22 changed files with 1510 additions and 622 deletions

12
pom.xml

@ -151,12 +151,12 @@
<!-- <version>2.5.3</version>-->
<!-- </plugin>-->
<!-- -->
<!-- &lt;!&ndash; exec plugin for running tests &ndash;&gt;-->
<!-- <plugin>-->
<!-- <groupId>org.codehaus.mojo</groupId>-->
<!-- <artifactId>exec-maven-plugin</artifactId>-->
<!-- <version>3.1.0</version>-->
<!-- </plugin>-->
<!-- exec plugin for running tests -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
</plugin>
</plugins>
</build>

@ -4,9 +4,9 @@ import cn.isliu.core.BaseEntity;
import cn.isliu.core.FileData;
import cn.isliu.core.FsTableData;
import cn.isliu.core.Sheet;
import cn.isliu.core.annotation.TableConf;
import cn.isliu.core.client.FeishuClient;
import cn.isliu.core.client.FsClient;
import cn.isliu.core.config.FsConfig;
import cn.isliu.core.enums.ErrorCode;
import cn.isliu.core.enums.FileType;
import cn.isliu.core.logging.FsLogger;
@ -44,25 +44,26 @@ public class FsHelper {
Map<String, FieldProperty> fieldsMap = PropertyUtil.getTablePropertyFieldsMap(clazz);
List<String> headers = PropertyUtil.getHeaders(fieldsMap);
TableConf tableConf = PropertyUtil.getTableConf(clazz);
FeishuClient client = FsClient.getInstance().getClient();
// 1创建sheet
String sheetId = FsApiUtil.createSheet(sheetName, client, spreadsheetToken);
// 2 添加表头数据
FsApiUtil.putValues(spreadsheetToken, FsTableUtil.getHeadTemplateBuilder(sheetId, headers), client);
FsApiUtil.putValues(spreadsheetToken, FsTableUtil.getHeadTemplateBuilder(sheetId, headers, fieldsMap, tableConf), client);
// 3 设置表格样式
FsApiUtil.setTableStyle(FsTableUtil.getDefaultTableStyle(sheetId, headers.size()), sheetId, client, spreadsheetToken);
FsApiUtil.setTableStyle(FsTableUtil.getDefaultTableStyle(sheetId, headers.size(), tableConf), sheetId, client, spreadsheetToken);
// 4 设置单元格为文本格式
FsConfig fsConfig = FsConfig.getInstance();
if (fsConfig.isCellText()) {
if (tableConf.isText()) {
String column = FsTableUtil.getColumnNameByNuNumber(headers.size());
FsApiUtil.setCellType(sheetId, "@", "A1", column + 200, client, spreadsheetToken);
}
// 5 设置表格下拉
FsTableUtil.setTableOptions(spreadsheetToken, headers, fieldsMap, sheetId);
FsTableUtil.setTableOptions(spreadsheetToken, headers, fieldsMap, sheetId, tableConf.enableDesc());
return sheetId;
}
@ -82,7 +83,8 @@ public class FsHelper {
List<T> results = new ArrayList<>();
FeishuClient client = FsClient.getInstance().getClient();
Sheet sheet = FsApiUtil.getSheetMetadata(sheetId, client, spreadsheetToken);
List<FsTableData> fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken);
TableConf tableConf = PropertyUtil.getTableConf(clazz);
List<FsTableData> fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken, tableConf);
Map<String, FieldProperty> fieldsMap = PropertyUtil.getTablePropertyFieldsMap(clazz);
List<String> fieldPathList = fieldsMap.values().stream().map(FieldProperty::getField).collect(Collectors.toList());
@ -90,11 +92,15 @@ public class FsHelper {
fsTableDataList.forEach(tableData -> {
Object data = tableData.getData();
if (data instanceof HashMap) {
JsonObject jsonObject = JSONUtil.convertHashMapToJsonObject((HashMap<String, Object>) data);
Map<String, Object> rowData = (HashMap<String, Object>) data;
JsonObject jsonObject = JSONUtil.convertMapToJsonObject(rowData);
Map<String, Object> dataMap = ConvertFieldUtil.convertPositionToField(jsonObject, fieldsMap);
T t = GenerateUtil.generateInstance(fieldPathList, clazz, dataMap);
if (t instanceof BaseEntity) {
((BaseEntity) t).setUniqueId(tableData.getUniqueId());
BaseEntity baseEntity = (BaseEntity) t;
baseEntity.setUniqueId(tableData.getUniqueId());
baseEntity.setRow(tableData.getRow());
baseEntity.setRowData(rowData);
}
results.add(t);
}
@ -120,10 +126,11 @@ public class FsHelper {
Class<?> aClass = dataList.get(0).getClass();
Map<String, FieldProperty> fieldsMap = PropertyUtil.getTablePropertyFieldsMap(aClass);
TableConf tableConf = PropertyUtil.getTableConf(aClass);
FeishuClient client = FsClient.getInstance().getClient();
Sheet sheet = FsApiUtil.getSheetMetadata(sheetId, client, spreadsheetToken);
List<FsTableData> fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken);
List<FsTableData> fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken, tableConf);
Map<String, Integer> currTableRowMap = fsTableDataList.stream().collect(Collectors.toMap(FsTableData::getUniqueId, FsTableData::getRow));
final Integer[] row = {0};
@ -133,7 +140,7 @@ public class FsHelper {
}
});
Map<String, String> titlePostionMap = FsTableUtil.getTitlePostionMap(sheet, spreadsheetToken);
Map<String, String> titlePostionMap = FsTableUtil.getTitlePostionMap(sheet, spreadsheetToken, tableConf);
Map<String, String> fieldMap = new HashMap<>();
fieldsMap.forEach((field, fieldProperty) -> fieldMap.put(field, fieldProperty.getField()));
@ -142,7 +149,6 @@ public class FsHelper {
CustomValueService.ValueRequest.BatchPutValuesBuilder resultValuesBuilder = CustomValueService.ValueRequest.batchPutValues();
List<FileData> fileDataList = new ArrayList<>();
FsConfig fsConfig = FsConfig.getInstance();
AtomicInteger rowCount = new AtomicInteger(row[0] + 1);
@ -155,7 +161,7 @@ public class FsHelper {
if (uniqueId != null && rowNum.get() != null) {
rowNum.set(rowNum.get() + 1);
values.forEach((field, fieldValue) -> {
if (!fsConfig.isCover() && fieldValue == null) {
if (!tableConf.enableCover() && fieldValue == null) {
return;
}
@ -177,7 +183,7 @@ public class FsHelper {
} else {
int rowCou = rowCount.incrementAndGet();
values.forEach((field, fieldValue) -> {
if (!fsConfig.isCover() && fieldValue == null) {
if (!tableConf.enableCover() && fieldValue == null) {
return;
}

@ -1,5 +1,8 @@
package cn.isliu.core;
import java.util.Map;
import java.util.Objects;
/**
* 实体类基类
*
@ -12,6 +15,14 @@ public abstract class BaseEntity {
* 唯一标识符用于标识表格中的行数据
*/
public String uniqueId;
/**
* 行号用于标识表格中的行位置
*/
private Integer row;
/**
* 行数据用于存储与表格行相关的信息
*/
private Map<String, Object> rowData;
/**
* 获取唯一标识符
@ -30,4 +41,32 @@ public abstract class BaseEntity {
public void setUniqueId(String uniqueId) {
this.uniqueId = uniqueId;
}
public Integer getRow() {
return row;
}
public void setRow(Integer row) {
this.row = row;
}
public Map<String, Object> getRowData() {
return rowData;
}
public void setRowData(Map<String, Object> rowData) {
this.rowData = rowData;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
BaseEntity that = (BaseEntity) o;
return Objects.equals(uniqueId, that.uniqueId) && Objects.equals(row, that.row) && Objects.equals(rowData, that.rowData);
}
@Override
public int hashCode() {
return Objects.hash(uniqueId, row, rowData);
}
}

@ -0,0 +1,60 @@
package cn.isliu.core.annotation;
import java.lang.annotation.*;
/**
* 表格配置注解
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface TableConf {
/**
* 表头行数
*
* @return 表头行数
*/
int headLine() default 1;
/**
* 标题行数
*
* @return 标题行数
*/
int titleRow() default 1;
/**
* 是否覆盖已存在数据
*
* @return 是否覆盖
*/
boolean enableCover() default false;
/**
* 是否设置表格为纯文本
*
* @return 是否设置表格为纯文本
*/
boolean isText() default false;
/**
* 是否开启字段描述
*
* @return 是否开启字段描述
*/
boolean enableDesc() default false;
/**
* 字体颜色
*
* @return 字体颜色
*/
String headFontColor() default "#ffffff";
/**
* 背景颜色
*
* @return 背景颜色
*/
String headBackColor() default "#000000";
}

@ -23,7 +23,14 @@ public @interface TableProperty {
*
* @return 列名字符串
*/
String value() default "";
String[] value() default {};
/**
* 字段描述
*
* @return 字段描述字符串或字符串数组
*/
String desc() default "";
/**
* 字段名

@ -1,85 +0,0 @@
package cn.isliu.core.config;
/**
* 配置构建器用于批量配置更新
*/
public class ConfigBuilder {
Integer headLine;
Integer titleLine;
Boolean isCover;
Boolean cellText;
String foreColor;
String backColor;
public ConfigBuilder() {
}
public ConfigBuilder headLine(int headLine) {
if (headLine < 0) {
throw new IllegalArgumentException("headLine must be non-negative, got: " + headLine);
}
this.headLine = headLine;
return this;
}
public ConfigBuilder titleLine(int titleLine) {
if (titleLine < 0) {
throw new IllegalArgumentException("titleLine must be non-negative, got: " + titleLine);
}
this.titleLine = titleLine;
return this;
}
public ConfigBuilder isCover(boolean isCover) {
this.isCover = isCover;
return this;
}
public ConfigBuilder cellText(boolean cellText) {
this.cellText = cellText;
return this;
}
public ConfigBuilder foreColor(String foreColor) {
if (foreColor == null) {
throw new IllegalArgumentException("foreColor cannot be null");
}
if (!isValidColor(foreColor)) {
throw new IllegalArgumentException("Invalid foreColor format: " + foreColor);
}
this.foreColor = foreColor;
return this;
}
public ConfigBuilder backColor(String backColor) {
if (backColor == null) {
throw new IllegalArgumentException("backColor cannot be null");
}
if (!isValidColor(backColor)) {
throw new IllegalArgumentException("Invalid backColor format: " + backColor);
}
this.backColor = backColor;
return this;
}
/**
* 验证颜色格式
* @param color 颜色值
* @return 是否为有效的颜色格式
*/
private boolean isValidColor(String color) {
if (color == null || color.trim().isEmpty()) {
return false;
}
// 简单的十六进制颜色验证 #RRGGBB
return color.matches("^#[0-9A-Fa-f]{6}$");
}
/**
* 应用配置到ThreadSafeConfig
*/
public void apply() {
FsConfig.getInstance().updateConfig(this);
}
}

@ -1,79 +0,0 @@
package cn.isliu.core.config;
/**
* 配置变更事件
*/
public class ConfigChangeEvent {
private final ConfigSnapshot oldSnapshot;
private final ConfigSnapshot newSnapshot;
private final long timestamp;
/**
* 创建配置变更事件
* @param oldSnapshot 旧配置快照
* @param newSnapshot 新配置快照
*/
public ConfigChangeEvent(ConfigSnapshot oldSnapshot, ConfigSnapshot newSnapshot) {
this.oldSnapshot = oldSnapshot;
this.newSnapshot = newSnapshot;
this.timestamp = System.currentTimeMillis();
}
/**
* 获取旧配置快照
* @return 旧配置快照
*/
public ConfigSnapshot getOldSnapshot() {
return oldSnapshot;
}
/**
* 获取新配置快照
* @return 新配置快照
*/
public ConfigSnapshot getNewSnapshot() {
return newSnapshot;
}
/**
* 获取事件时间戳
* @return 时间戳
*/
public long getTimestamp() {
return timestamp;
}
/**
* 检查指定字段是否发生变更
* @param fieldName 字段名
* @return 是否发生变更
*/
public boolean hasChanged(String fieldName) {
switch (fieldName.toLowerCase()) {
case "headline":
return oldSnapshot.getHeadLine() != newSnapshot.getHeadLine();
case "titleline":
return oldSnapshot.getTitleLine() != newSnapshot.getTitleLine();
case "iscover":
return oldSnapshot.isCover() != newSnapshot.isCover();
case "celltext":
return oldSnapshot.isCellText() != newSnapshot.isCellText();
case "forecolor":
return !oldSnapshot.getForeColor().equals(newSnapshot.getForeColor());
case "backcolor":
return !oldSnapshot.getBackColor().equals(newSnapshot.getBackColor());
default:
return false;
}
}
@Override
public String toString() {
return "ConfigChangeEvent{" +
"oldSnapshot=" + oldSnapshot +
", newSnapshot=" + newSnapshot +
", timestamp=" + timestamp +
'}';
}
}

@ -1,13 +0,0 @@
package cn.isliu.core.config;
/**
* 配置变更监听器接口
*/
public interface ConfigChangeListener {
/**
* 配置变更时的回调方法
* @param event 配置变更事件
*/
void onConfigChanged(ConfigChangeEvent event);
}

@ -1,77 +0,0 @@
package cn.isliu.core.config;
/**
* 不可变的配置快照
* 用于线程安全的配置读取
*/
public class ConfigSnapshot {
private final int headLine;
private final int titleLine;
private final boolean isCover;
private final boolean cellText;
private final String foreColor;
private final String backColor;
private final long timestamp;
/**
* 创建配置快照
* @param headLine 头部行数
* @param titleLine 标题行数
* @param isCover 是否覆盖
* @param cellText 是否单元格文本
* @param foreColor 前景色
* @param backColor 背景色
*/
public ConfigSnapshot(int headLine, int titleLine, boolean isCover,
boolean cellText, String foreColor, String backColor) {
this.headLine = headLine;
this.titleLine = titleLine;
this.isCover = isCover;
this.cellText = cellText;
this.foreColor = foreColor;
this.backColor = backColor;
this.timestamp = System.currentTimeMillis();
}
public int getHeadLine() {
return headLine;
}
public int getTitleLine() {
return titleLine;
}
public boolean isCover() {
return isCover;
}
public boolean isCellText() {
return cellText;
}
public String getForeColor() {
return foreColor;
}
public String getBackColor() {
return backColor;
}
public long getTimestamp() {
return timestamp;
}
@Override
public String toString() {
return "ConfigSnapshot{" +
"headLine=" + headLine +
", titleLine=" + titleLine +
", isCover=" + isCover +
", cellText=" + cellText +
", foreColor='" + foreColor + '\'' +
", backColor='" + backColor + '\'' +
", timestamp=" + timestamp +
'}';
}
}

@ -1,302 +0,0 @@
package cn.isliu.core.config;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 线程安全的配置管理器
* 使用volatile关键字和ReadWriteLock确保线程安全
*/
public class FsConfig {
// 使用volatile确保可见性
private volatile int headLine = 1;
private volatile int titleLine = 1;
private volatile boolean isCover = false;
private volatile boolean cellText = false;
private volatile String foreColor = "#000000";
private volatile String backColor = "#d5d5d5";
// 读写锁保护配置更新操作
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// 配置变更监听器列表
private final List<ConfigChangeListener> listeners = new CopyOnWriteArrayList<>();
// 单例实例
private static volatile FsConfig instance;
private FsConfig() {
}
/**
* 获取单例实例
* @return ThreadSafeConfig实例
*/
public static FsConfig getInstance() {
if (instance == null) {
synchronized (FsConfig.class) {
if (instance == null) {
instance = new FsConfig();
}
}
}
return instance;
}
/**
* 原子性配置更新
* @param builder 配置构建器
*/
public void updateConfig(ConfigBuilder builder) {
// 验证配置
validateConfig(builder);
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
if (builder.headLine != null) {
this.headLine = builder.headLine;
}
if (builder.titleLine != null) {
this.titleLine = builder.titleLine;
}
if (builder.isCover != null) {
this.isCover = builder.isCover;
}
if (builder.cellText != null) {
this.cellText = builder.cellText;
}
if (builder.foreColor != null) {
this.foreColor = builder.foreColor;
}
if (builder.backColor != null) {
this.backColor = builder.backColor;
}
} finally {
lock.writeLock().unlock();
}
// 通知配置变更
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
/**
* 验证配置参数
* @param builder 配置构建器
* @throws IllegalArgumentException 如果配置无效
*/
private void validateConfig(ConfigBuilder builder) {
if (builder.headLine != null && builder.headLine < 0) {
throw new IllegalArgumentException("headLine must be non-negative, got: " + builder.headLine);
}
if (builder.titleLine != null && builder.titleLine < 0) {
throw new IllegalArgumentException("titleLine must be non-negative, got: " + builder.titleLine);
}
if (builder.foreColor != null && !isValidColor(builder.foreColor)) {
throw new IllegalArgumentException("Invalid foreColor format: " + builder.foreColor);
}
if (builder.backColor != null && !isValidColor(builder.backColor)) {
throw new IllegalArgumentException("Invalid backColor format: " + builder.backColor);
}
}
/**
* 验证颜色格式
* @param color 颜色值
* @return 是否为有效的颜色格式
*/
private boolean isValidColor(String color) {
if (color == null || color.trim().isEmpty()) {
return false;
}
// 简单的十六进制颜色验证 #RRGGBB
return color.matches("^#[0-9A-Fa-f]{6}$");
}
/**
* 通知配置变更
* @param oldSnapshot 旧配置快照
* @param newSnapshot 新配置快照
*/
private void notifyConfigChange(ConfigSnapshot oldSnapshot, ConfigSnapshot newSnapshot) {
if (!listeners.isEmpty()) {
ConfigChangeEvent event = new ConfigChangeEvent(oldSnapshot, newSnapshot);
for (ConfigChangeListener listener : listeners) {
try {
listener.onConfigChanged(event);
} catch (Exception e) {
// 记录异常但不影响配置更新
System.err.println("Error notifying config change listener: " + e.getMessage());
}
}
}
}
/**
* 添加配置变更监听器
* @param listener 监听器
*/
public void addConfigChangeListener(ConfigChangeListener listener) {
if (listener != null) {
listeners.add(listener);
}
}
/**
* 移除配置变更监听器
* @param listener 监听器
*/
public void removeConfigChangeListener(ConfigChangeListener listener) {
listeners.remove(listener);
}
/**
* 线程安全的配置读取 - 获取配置快照
* @return 不可变的配置快照
*/
public ConfigSnapshot getSnapshot() {
lock.readLock().lock();
try {
return new ConfigSnapshot(headLine, titleLine, isCover, cellText, foreColor, backColor);
} finally {
lock.readLock().unlock();
}
}
// 单独的getter方法使用volatile保证可见性
public int getHeadLine() {
return headLine;
}
public int getTitleLine() {
return titleLine;
}
public boolean isCover() {
return isCover;
}
public boolean isCellText() {
return cellText;
}
public String getForeColor() {
return foreColor;
}
public String getBackColor() {
return backColor;
}
// 单独的setter方法使用写锁保护
public void setHeadLine(int headLine) {
if (headLine < 0) {
throw new IllegalArgumentException("headLine must be non-negative, got: " + headLine);
}
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.headLine = headLine;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
public void setTitleLine(int titleLine) {
if (titleLine < 0) {
throw new IllegalArgumentException("titleLine must be non-negative, got: " + titleLine);
}
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.titleLine = titleLine;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
public void setIsCover(boolean isCover) {
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.isCover = isCover;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
public void setCellText(boolean cellText) {
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.cellText = cellText;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
public void setForeColor(String foreColor) {
if (foreColor == null) {
throw new IllegalArgumentException("foreColor cannot be null");
}
if (!isValidColor(foreColor)) {
throw new IllegalArgumentException("Invalid foreColor format: " + foreColor);
}
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.foreColor = foreColor;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
public void setBackColor(String backColor) {
if (backColor == null) {
throw new IllegalArgumentException("backColor cannot be null");
}
if (!isValidColor(backColor)) {
throw new IllegalArgumentException("Invalid backColor format: " + backColor);
}
ConfigSnapshot oldSnapshot = getSnapshot();
lock.writeLock().lock();
try {
this.backColor = backColor;
} finally {
lock.writeLock().unlock();
}
ConfigSnapshot newSnapshot = getSnapshot();
notifyConfigChange(oldSnapshot, newSnapshot);
}
}

@ -64,6 +64,13 @@ public enum ErrorCode implements BaseEnum {
TOKEN_EXPIRED("FS603", "Token expired", ErrorCategory.SECURITY),
ENCRYPTION_FAILED("FS604", "Encryption failed", ErrorCategory.SECURITY),
DECRYPTION_FAILED("FS605", "Decryption failed", ErrorCategory.SECURITY),
// Token管理相关错误 (FS610-FS619)
TOKEN_MANAGEMENT_ERROR("FS610", "Token management error", ErrorCategory.SECURITY),
TOKEN_FETCH_FAILED("FS611", "Failed to fetch token from API", ErrorCategory.SECURITY),
TOKEN_PARSE_ERROR("FS612", "Failed to parse token response", ErrorCategory.SECURITY),
TOKEN_CACHE_ERROR("FS613", "Token cache operation failed", ErrorCategory.SECURITY),
TOKEN_REFRESH_FAILED("FS614", "Token refresh operation failed", ErrorCategory.SECURITY),
// 业务逻辑相关错误 (FS700-FS799)
BUSINESS_LOGIC_ERROR("FS700", "Business logic error", ErrorCategory.BUSINESS),
@ -247,6 +254,8 @@ public enum ErrorCode implements BaseEnum {
case API_SERVER_ERROR:
case SERVICE_UNAVAILABLE:
case CONNECTION_POOL_EXHAUSTED:
case TOKEN_FETCH_FAILED:
case TOKEN_REFRESH_FAILED:
return true;
default:
return false;

@ -0,0 +1,242 @@
package cn.isliu.core.exception;
import cn.isliu.core.enums.ErrorCode;
import java.util.Map;
/**
* Token management exception class
*
* Specialized exception for handling various error scenarios during tenant_access_token management,
* including token fetch failures, parsing errors, cache operation failures, etc.
*
* @author isliu
*/
public class TokenManagementException extends FsHelperException {
private static final long serialVersionUID = 1L;
/**
* Constructor - uses default TOKEN_MANAGEMENT_ERROR error code
*
* @param message error message
*/
public TokenManagementException(String message) {
super(ErrorCode.TOKEN_MANAGEMENT_ERROR, message);
}
/**
* Constructor - uses default TOKEN_MANAGEMENT_ERROR error code with cause
*
* @param message error message
* @param cause root cause exception
*/
public TokenManagementException(String message, Throwable cause) {
super(ErrorCode.TOKEN_MANAGEMENT_ERROR, message, cause);
}
/**
* Constructor - specifies specific error code
*
* @param errorCode specific token management error code
* @param message error message
*/
public TokenManagementException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
/**
* Constructor - specifies specific error code with cause
*
* @param errorCode specific token management error code
* @param message error message
* @param cause root cause exception
*/
public TokenManagementException(ErrorCode errorCode, String message, Throwable cause) {
super(errorCode, message, cause);
}
/**
* Constructor - includes context information
*
* @param errorCode specific token management error code
* @param message error message
* @param context context information
*/
public TokenManagementException(ErrorCode errorCode, String message, Map<String, Object> context) {
super(errorCode, message, context);
}
/**
* Constructor - full parameters
*
* @param errorCode specific token management error code
* @param message error message
* @param context context information
* @param cause root cause exception
*/
public TokenManagementException(ErrorCode errorCode, String message, Map<String, Object> context, Throwable cause) {
super(errorCode, message, context, cause);
}
// Static factory methods for creating specific types of token management exceptions
/**
* Create token fetch failed exception
*
* @param message error message
* @param cause root cause exception
* @return TokenManagementException instance
*/
public static TokenManagementException tokenFetchFailed(String message, Throwable cause) {
return new TokenManagementException(ErrorCode.TOKEN_FETCH_FAILED, message, cause);
}
/**
* Create token parse error exception
*
* @param message error message
* @param responseBody response body content for debugging
* @return TokenManagementException instance
*/
public static TokenManagementException tokenParseError(String message, String responseBody) {
TokenManagementException exception = new TokenManagementException(ErrorCode.TOKEN_PARSE_ERROR, message);
if (responseBody != null) {
exception.addContext("responseBody", responseBody);
}
return exception;
}
/**
* Create token cache operation failed exception
*
* @param message error message
* @param cause root cause exception
* @return TokenManagementException instance
*/
public static TokenManagementException tokenCacheError(String message, Throwable cause) {
return new TokenManagementException(ErrorCode.TOKEN_CACHE_ERROR, message, cause);
}
/**
* Create token refresh failed exception
*
* @param message error message
* @param cause root cause exception
* @return TokenManagementException instance
*/
public static TokenManagementException tokenRefreshFailed(String message, Throwable cause) {
return new TokenManagementException(ErrorCode.TOKEN_REFRESH_FAILED, message, cause);
}
/**
* Create token fetch failed exception with API response info
*
* @param message error message
* @param apiCode API returned error code
* @param apiMessage API returned error message
* @return TokenManagementException instance
*/
public static TokenManagementException tokenFetchFailedWithApiInfo(String message, int apiCode, String apiMessage) {
TokenManagementException exception = new TokenManagementException(ErrorCode.TOKEN_FETCH_FAILED, message);
exception.addContext("apiCode", apiCode);
exception.addContext("apiMessage", apiMessage);
return exception;
}
/**
* Create token management exception with retry information
*
* @param errorCode error code
* @param message error message
* @param retryCount retry count
* @param maxRetries maximum retry count
* @param cause root cause exception
* @return TokenManagementException instance
*/
public static TokenManagementException withRetryInfo(ErrorCode errorCode, String message,
int retryCount, int maxRetries, Throwable cause) {
TokenManagementException exception = new TokenManagementException(errorCode, message, cause);
exception.addContext("retryCount", retryCount);
exception.addContext("maxRetries", maxRetries);
exception.addContext("retriesExhausted", retryCount >= maxRetries);
return exception;
}
/**
* Check if this is a network-related token management exception
*
* @return true if network-related exception
*/
public boolean isNetworkRelated() {
Throwable rootCause = getRootCause();
return rootCause instanceof java.net.SocketTimeoutException ||
rootCause instanceof java.net.ConnectException ||
rootCause instanceof java.net.UnknownHostException ||
rootCause instanceof java.io.IOException;
}
/**
* Check if this is an API response-related exception
*
* @return true if API response-related exception
*/
public boolean isApiResponseRelated() {
ErrorCode code = getErrorCode();
return code == ErrorCode.TOKEN_PARSE_ERROR ||
code == ErrorCode.TOKEN_FETCH_FAILED;
}
/**
* Check if this is a cache-related exception
*
* @return true if cache-related exception
*/
public boolean isCacheRelated() {
return getErrorCode() == ErrorCode.TOKEN_CACHE_ERROR;
}
/**
* Get suggested retry delay time in milliseconds
*
* @param retryCount current retry count
* @return suggested delay time
*/
public long getSuggestedRetryDelay(int retryCount) {
if (!isRetryable()) {
return 0;
}
// Exponential backoff strategy, base delay 1 second, max delay 30 seconds
long baseDelay = 1000; // 1 second
long maxDelay = 30000; // 30 seconds
long delay = Math.min(baseDelay * (1L << retryCount), maxDelay);
// Add random jitter to avoid thundering herd effect
double jitter = 0.1; // 10% jitter
long jitterAmount = (long) (delay * jitter * Math.random());
return delay + jitterAmount;
}
/**
* Get user-friendly error message
*
* @return user-friendly error message
*/
@Override
public String getUserFriendlyMessage() {
ErrorCode code = getErrorCode();
switch (code) {
case TOKEN_FETCH_FAILED:
return "Failed to fetch access token, please check network connection and application configuration.";
case TOKEN_PARSE_ERROR:
return "Failed to parse access token response, please contact technical support.";
case TOKEN_CACHE_ERROR:
return "Token cache operation failed, system will re-fetch token.";
case TOKEN_REFRESH_FAILED:
return "Failed to refresh access token, please try again later.";
default:
return "Error occurred during token management, please try again later or contact technical support.";
}
}
}

@ -89,7 +89,7 @@ public class FieldProperty {
*
* @return 列名字符串
*/
public String getFieldName() {
public String[] getFieldName() {
return tableProperty.value();
}

@ -0,0 +1,123 @@
package cn.isliu.core.pojo;
/**
* Token信息数据模型类
*
* 封装飞书API的tenant_access_token相关信息包括token值过期时间和获取时间
* 提供便利方法用于检查token有效性计算剩余时间和判断是否即将过期
*
* @author FsHelper
* @since 1.0
*/
public class TokenInfo {
/**
* 实际的access token字符串
*/
private final String token;
/**
* token过期的绝对时间戳毫秒
*/
private final long expiresAt;
/**
* token获取的时间戳毫秒用于调试和日志
*/
private final long fetchedAt;
/**
* 构造函数
*
* @param token 实际的access token字符串
* @param expiresAt token过期的绝对时间戳毫秒
* @param fetchedAt token获取的时间戳毫秒
*/
public TokenInfo(String token, long expiresAt, long fetchedAt) {
this.token = token;
this.expiresAt = expiresAt;
this.fetchedAt = fetchedAt;
}
/**
* 根据token值和有效期秒数创建TokenInfo实例
*
* @param token 实际的access token字符串
* @param expireSeconds token有效期
* @return TokenInfo实例
*/
public static TokenInfo create(String token, int expireSeconds) {
long now = System.currentTimeMillis();
long expiresAt = now + (expireSeconds * 1000L);
return new TokenInfo(token, expiresAt, now);
}
/**
* 获取token字符串
*
* @return token字符串
*/
public String getToken() {
return token;
}
/**
* 获取token过期时间戳
*
* @return 过期时间戳毫秒
*/
public long getExpiresAt() {
return expiresAt;
}
/**
* 获取token获取时间戳
*
* @return 获取时间戳毫秒
*/
public long getFetchedAt() {
return fetchedAt;
}
/**
* 检查token是否仍然有效
*
* @return true表示token仍在有效期内false表示已过期
*/
public boolean isValid() {
return System.currentTimeMillis() < expiresAt;
}
/**
* 计算token剩余有效时间
*
* @return 剩余有效时间如果已过期则返回0
*/
public long getRemainingSeconds() {
long remaining = (expiresAt - System.currentTimeMillis()) / 1000;
return Math.max(0, remaining);
}
/**
* 判断token是否即将过期
*
* 根据飞书API文档当剩余有效期小于30分钟时调用接口会返回新的token
*
* @return true表示剩余有效期小于30分钟false表示剩余有效期大于等于30分钟
*/
public boolean isExpiringSoon() {
return getRemainingSeconds() < 1800; // 30分钟 = 1800秒
}
@Override
public String toString() {
return "TokenInfo{" +
"token='" + (token != null ? token.substring(0, Math.min(token.length(), 10)) + "..." : "null") + '\'' +
", expiresAt=" + expiresAt +
", fetchedAt=" + fetchedAt +
", remainingSeconds=" + getRemainingSeconds() +
", isValid=" + isValid() +
", isExpiringSoon=" + isExpiringSoon() +
'}';
}
}

@ -0,0 +1,197 @@
package cn.isliu.core.pojo;
import com.google.gson.annotations.SerializedName;
/**
* 飞书API获取租户访问令牌的响应模型类
*
* 对应飞书API返回的JSON格式
* {
* "code": 0,
* "msg": "ok",
* "tenant_access_token": "t-caecc734c2e3328a62489fe0648c4b98779515d3",
* "expire": 7200
* }
*
* @author FsHelper
* @since 1.0
*/
public class TokenResponse {
/**
* 响应状态码
* 0表示成功非0表示失败
*/
@SerializedName("code")
private int code;
/**
* 响应消息
* 通常成功时为"ok"失败时包含错误描述
*/
@SerializedName("msg")
private String msg;
/**
* 租户访问令牌
* 用于后续API调用的认证
*/
@SerializedName("tenant_access_token")
private String tenant_access_token;
/**
* 令牌有效期
* 通常为7200秒2小时
*/
@SerializedName("expire")
private int expire;
/**
* 默认构造函数
*/
public TokenResponse() {
}
/**
* 完整构造函数
*
* @param code 响应状态码
* @param msg 响应消息
* @param tenant_access_token 租户访问令牌
* @param expire 令牌有效期
*/
public TokenResponse(int code, String msg, String tenant_access_token, int expire) {
this.code = code;
this.msg = msg;
this.tenant_access_token = tenant_access_token;
this.expire = expire;
}
/**
* 获取响应状态码
*
* @return 响应状态码0表示成功
*/
public int getCode() {
return code;
}
/**
* 设置响应状态码
*
* @param code 响应状态码
*/
public void setCode(int code) {
this.code = code;
}
/**
* 获取响应消息
*
* @return 响应消息
*/
public String getMsg() {
return msg;
}
/**
* 设置响应消息
*
* @param msg 响应消息
*/
public void setMsg(String msg) {
this.msg = msg;
}
/**
* 获取租户访问令牌
*
* @return 租户访问令牌字符串
*/
public String getTenant_access_token() {
return tenant_access_token;
}
/**
* 设置租户访问令牌
*
* @param tenant_access_token 租户访问令牌
*/
public void setTenant_access_token(String tenant_access_token) {
this.tenant_access_token = tenant_access_token;
}
/**
* 获取令牌有效期
*
* @return 令牌有效期
*/
public int getExpire() {
return expire;
}
/**
* 设置令牌有效期
*
* @param expire 令牌有效期
*/
public void setExpire(int expire) {
this.expire = expire;
}
/**
* 检查响应是否成功
*
* @return true表示API调用成功false表示失败
*/
public boolean isSuccess() {
return code == 0;
}
/**
* 检查是否包含有效的令牌数据
*
* @return true表示包含有效的令牌数据
*/
public boolean hasValidToken() {
return isSuccess() &&
tenant_access_token != null &&
!tenant_access_token.trim().isEmpty() &&
expire > 0;
}
@Override
public String toString() {
return "TokenResponse{" +
"code=" + code +
", msg='" + msg + '\'' +
", tenant_access_token='" +
(tenant_access_token != null ?
tenant_access_token.substring(0, Math.min(tenant_access_token.length(), 10)) + "..." :
"null") + '\'' +
", expire=" + expire +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TokenResponse that = (TokenResponse) o;
if (code != that.code) return false;
if (expire != that.expire) return false;
if (!java.util.Objects.equals(msg, that.msg)) return false;
return java.util.Objects.equals(tenant_access_token, that.tenant_access_token);
}
@Override
public int hashCode() {
int result = code;
result = 31 * result + (msg != null ? msg.hashCode() : 0);
result = 31 * result + (tenant_access_token != null ? tenant_access_token.hashCode() : 0);
result = 31 * result + expire;
return result;
}
}

@ -1,6 +1,7 @@
package cn.isliu.core.service;
import cn.isliu.core.client.FeishuClient;
import cn.isliu.core.exception.FsHelperException;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.lark.oapi.core.utils.Jsons;
@ -17,6 +18,7 @@ public abstract class AbstractFeishuApiService {
protected final FeishuClient feishuClient;
protected final OkHttpClient httpClient;
protected final Gson gson;
protected final TenantTokenManager tokenManager;
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");
@ -30,35 +32,22 @@ public abstract class AbstractFeishuApiService {
this.feishuClient = feishuClient;
this.httpClient = feishuClient.getHttpClient();
this.gson = Jsons.DEFAULT;
this.tokenManager = new TenantTokenManager(feishuClient);
}
/**
* 获取租户访问令牌
*
* 使用TenantTokenManager进行智能的token管理包括缓存过期检测和自动刷新
*
* @return 访问令牌
* @throws IOException 请求异常
*/
protected String getTenantAccessToken() throws IOException {
Map<String, String> params = new HashMap<>();
params.put("app_id", feishuClient.getAppId());
params.put("app_secret", feishuClient.getAppSecret());
RequestBody body = RequestBody.create(gson.toJson(params), JSON_MEDIA_TYPE);
Request request =
new Request.Builder().url(BASE_URL + "/auth/v3/tenant_access_token/internal").post(body).build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("Failed to get tenant access token: " + response);
}
JsonObject jsonResponse = gson.fromJson(response.body().string(), JsonObject.class);
if (jsonResponse.has("tenant_access_token")) {
return jsonResponse.get("tenant_access_token").getAsString();
} else {
throw new IOException("Invalid token response: " + jsonResponse);
}
try {
return tokenManager.getCachedTenantAccessToken();
} catch (FsHelperException e) {
throw new IOException("Failed to get tenant access token: " + e.getMessage(), e);
}
}

@ -1040,6 +1040,11 @@ public class CustomValueService extends AbstractFeishuApiService {
return this;
}
public BatchPutValuesBuilder setMajorDimension(String majorDimension) {
currentItem.setMajorDimension(majorDimension);
return this;
}
/**
* 构建向多个范围写入数据请求
*
@ -1620,6 +1625,7 @@ public class CustomValueService extends AbstractFeishuApiService {
public static class ValueRangeItem {
private String range;
private String type;
private String majorDimension;
private List<List<Object>> values;
public ValueRangeItem() {
@ -1644,6 +1650,14 @@ public class CustomValueService extends AbstractFeishuApiService {
this.range = range;
}
public String getMajorDimension() {
return majorDimension;
}
public void setMajorDimension(String majorDimension) {
this.majorDimension = majorDimension;
}
/**
* 获取数据值
*

@ -0,0 +1,481 @@
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.pojo.TokenInfo;
import cn.isliu.core.pojo.TokenResponse;
import com.google.gson.Gson;
import com.lark.oapi.core.utils.Jsons;
import okhttp3.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 飞书租户访问令牌管理器
*
* 负责管理tenant_access_token的获取缓存过期检测和自动刷新
* 实现线程安全的token管理避免频繁的API调用提高系统性能
*
* 核心功能
* - 智能缓存缓存有效的token避免重复获取
* - 过期检测自动检测token是否即将过期剩余时间<30分钟
* - 自动刷新在token即将过期时自动获取新token
* - 线程安全使用读写锁确保并发访问的安全性
* - 异常处理完善的错误处理和重试机制
*
* @author FsHelper
* @since 1.0
*/
public class TenantTokenManager {
/** 飞书API基础URL */
private static final String BASE_URL = "https://open.feishu.cn/open-apis";
/** 获取租户访问令牌的API端点 */
private static final String TOKEN_ENDPOINT = "/auth/v3/tenant_access_token/internal";
/** JSON媒体类型 */
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
/** Token缓存组件 */
private final TokenCache tokenCache;
/** 读写锁,确保线程安全 */
private final ReentrantReadWriteLock lock;
/** 飞书客户端 */
private final FeishuClient feishuClient;
/** HTTP客户端 */
private final OkHttpClient httpClient;
/** JSON序列化工具 */
private final Gson gson;
/**
* 构造函数
*
* @param feishuClient 飞书客户端实例不能为null
* @throws IllegalArgumentException 如果feishuClient为null
*/
public TenantTokenManager(FeishuClient feishuClient) {
if (feishuClient == null) {
throw new IllegalArgumentException("FeishuClient cannot be null");
}
this.feishuClient = feishuClient;
this.httpClient = feishuClient.getHttpClient();
this.gson = Jsons.DEFAULT;
this.tokenCache = new TokenCache();
this.lock = new ReentrantReadWriteLock();
FsLogger.debug("TenantTokenManager initialized for app_id: {}", feishuClient.getAppId());
}
/**
* 获取租户访问令牌 直接调用飞书API获取
*
* tenant_access_token 的最大有效期是 2 小时
*
* 剩余有效期小于 30 分钟时调用本接口会返回一个新的 tenant_access_token这会同时存在两个有效的 tenant_access_token
* 剩余有效期大于等于 30 分钟时调用本接口会返回原有的 tenant_access_token
*
* @return 获取到的租户访问令牌字符串
* @throws IOException 如果获取令牌时发生错误
*/
public String getTenantAccessToken() throws IOException {
Map<String, String> params = new HashMap<>();
params.put("app_id", feishuClient.getAppId());
params.put("app_secret", feishuClient.getAppSecret());
RequestBody body = RequestBody.create(gson.toJson(params), JSON_MEDIA_TYPE);
Request request =
new Request.Builder().url(BASE_URL + "/auth/v3/tenant_access_token/internal").post(body).build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("HTTP request failed with status: " + response.code() +
", message: " + response.message());
}
if (response.body() == null) {
throw new IOException("Response body is null");
}
String responseBody = response.body().string();
FsLogger.debug("Token API response received - status: {}, body_length: {} chars",
response.code(), responseBody.length());
// 解析响应
TokenResponse tokenResponse;
try {
tokenResponse = gson.fromJson(responseBody, TokenResponse.class);
} catch (Exception e) {
throw new IOException("Failed to parse token response JSON: " + responseBody, e);
}
// 检查API响应状态
if (tokenResponse.getCode() != 0) {
throw new IOException("API returned error code: " + tokenResponse.getCode() +
", message: " + tokenResponse.getMsg());
}
// 验证响应数据
if (tokenResponse.getTenant_access_token() == null ||
tokenResponse.getTenant_access_token().trim().isEmpty()) {
throw new IOException("Invalid token response: tenant_access_token is null or empty");
}
if (tokenResponse.getExpire() <= 0) {
throw new IOException("Invalid token response: expire value is invalid: " +
tokenResponse.getExpire());
}
FsLogger.debug("New token created successfully - expires in: {} seconds, token: {}",
tokenResponse.getExpire(),
tokenResponse.getTenant_access_token());
return tokenResponse.getTenant_access_token();
}
}
/**
* 获取有效的租户访问令牌
*
* 该方法实现智能的token管理逻辑和完整的线程安全机制
* 1. 首先使用读锁检查缓存中的token是否有效且未即将过期
* 2. 如果缓存的token有效且不会很快过期直接返回
* 3. 如果token无效或即将过期使用写锁获取新token
* 4. 在写锁中再次检查缓存双重检查锁定模式避免重复获取
* 5. 调用飞书API获取新token并更新缓存
*
* 线程安全保证
* - 使用ReentrantReadWriteLock确保并发访问安全
* - 实现双重检查锁定模式避免重复token获取
* - 多个线程同时请求时只有一个线程执行token获取操作
* - 其他线程等待获取完成后使用新token
*
* @return 有效的租户访问令牌字符串
* @throws FsHelperException 当token获取失败时抛出
*/
public String getCachedTenantAccessToken() throws FsHelperException {
// 第一次检查使用读锁检查缓存
// 这允许多个线程同时读取缓存提高并发性能
lock.readLock().lock();
try {
TokenInfo cachedToken = tokenCache.get();
if (cachedToken != null && cachedToken.isValid() && !cachedToken.isExpiringSoon()) {
FsLogger.trace("Token cache hit - remaining: {} seconds", cachedToken.getRemainingSeconds());
logTokenDetails(cachedToken, "cache_hit");
return cachedToken.getToken();
}
} finally {
lock.readLock().unlock();
}
// 需要获取新token使用写锁确保只有一个线程执行获取操作
lock.writeLock().lock();
try {
// 双重检查锁定模式再次检查缓存
// 这是关键的并发控制机制防止多个线程重复获取token
TokenInfo cachedToken = tokenCache.get();
if (cachedToken != null && cachedToken.isValid() && !cachedToken.isExpiringSoon()) {
FsLogger.debug("Token was refreshed by another thread, using updated token - remaining: {} seconds",
cachedToken.getRemainingSeconds());
return cachedToken.getToken();
}
// 记录token获取原因便于调试并发问题
String reason = cachedToken == null ? "no cached token" :
!cachedToken.isValid() ? "token expired" : "token expiring soon";
long startTime = System.currentTimeMillis();
FsLogger.info("Token refresh triggered - reason: {}, thread: {}",
reason, Thread.currentThread().getName());
// 获取新token并原子性更新缓存
TokenInfo newToken = fetchNewToken();
tokenCache.set(newToken);
long duration = System.currentTimeMillis() - startTime;
FsLogger.info("Token refresh completed successfully - expires in: {} seconds, duration: {}ms, thread: {}",
newToken.getRemainingSeconds(), duration, Thread.currentThread().getName());
// 记录详细token信息
logTokenDetails(newToken, "token_refreshed");
// 记录性能指标
FsLogger.logPerformance("tenant_token_fetch", duration, true);
return newToken.getToken();
} catch (IOException e) {
// 记录错误日志
String context = String.format("app_id=%s, endpoint=%s, thread=%s",
feishuClient.getAppId(), BASE_URL + TOKEN_ENDPOINT,
Thread.currentThread().getName());
FsLogger.error(ErrorCode.TOKEN_FETCH_FAILED,
"Failed to fetch tenant access token from Feishu API", context, e);
// 清空缓存强制下次重新获取
// 这个操作在写锁保护下是原子的
tokenCache.clear();
throw FsHelperException.builder(ErrorCode.TOKEN_FETCH_FAILED)
.message("Failed to fetch tenant access token from Feishu API")
.context("app_id", feishuClient.getAppId())
.context("endpoint", BASE_URL + TOKEN_ENDPOINT)
.context("thread", Thread.currentThread().getName())
.cause(e)
.build();
} finally {
lock.writeLock().unlock();
}
}
/**
* 从飞书API获取新的访问令牌
*
* 该方法负责
* 1. 构建API请求参数app_id和app_secret
* 2. 发送HTTP POST请求到飞书API
* 3. 解析API响应并提取token信息
* 4. 创建TokenInfo实例并返回
*
* @return 新的TokenInfo实例
* @throws IOException 当网络请求失败或响应解析失败时抛出
*/
private TokenInfo fetchNewToken() throws IOException {
// 构建请求参数
Map<String, String> params = new HashMap<>();
params.put("app_id", feishuClient.getAppId());
params.put("app_secret", feishuClient.getAppSecret());
RequestBody body = RequestBody.create(gson.toJson(params), JSON_MEDIA_TYPE);
Request request = new Request.Builder()
.url(BASE_URL + TOKEN_ENDPOINT)
.post(body)
.build();
FsLogger.debug("Sending token request to: {}", BASE_URL + TOKEN_ENDPOINT);
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("HTTP request failed with status: " + response.code() +
", message: " + response.message());
}
if (response.body() == null) {
throw new IOException("Response body is null");
}
String responseBody = response.body().string();
FsLogger.debug("Token API response received - status: {}, body_length: {} chars",
response.code(), responseBody.length());
// 解析响应
TokenResponse tokenResponse;
try {
tokenResponse = gson.fromJson(responseBody, TokenResponse.class);
} catch (Exception e) {
throw new IOException("Failed to parse token response JSON: " + responseBody, e);
}
// 检查API响应状态
if (tokenResponse.getCode() != 0) {
throw new IOException("API returned error code: " + tokenResponse.getCode() +
", message: " + tokenResponse.getMsg());
}
// 验证响应数据
if (tokenResponse.getTenant_access_token() == null ||
tokenResponse.getTenant_access_token().trim().isEmpty()) {
throw new IOException("Invalid token response: tenant_access_token is null or empty");
}
if (tokenResponse.getExpire() <= 0) {
throw new IOException("Invalid token response: expire value is invalid: " +
tokenResponse.getExpire());
}
// 创建TokenInfo实例
TokenInfo tokenInfo = TokenInfo.create(tokenResponse.getTenant_access_token(), tokenResponse.getExpire());
FsLogger.debug("New token created successfully - expires in: {} seconds, token_prefix: {}...",
tokenInfo.getRemainingSeconds(),
tokenInfo.getToken().substring(0, Math.min(10, tokenInfo.getToken().length())));
return tokenInfo;
}
}
/**
* 获取当前缓存的token信息用于调试和监控
*
* @return 当前缓存的TokenInfo如果缓存为空返回null
*/
public TokenInfo getCurrentTokenInfo() {
lock.readLock().lock();
try {
return tokenCache.get();
} finally {
lock.readLock().unlock();
}
}
/**
* 强制清空token缓存
*
* 该方法会清空当前缓存的token强制下次调用getTenantAccessToken()时重新获取
* 通常在以下情况下使用
* - 检测到token无效时
* - 需要强制刷新token时
* - 系统重置或清理时
*/
public void clearTokenCache() {
lock.writeLock().lock();
try {
tokenCache.clear();
FsLogger.info("Token cache cleared manually");
} finally {
lock.writeLock().unlock();
}
}
/**
* 检查当前是否有有效的缓存token
*
* @return true表示有有效的缓存tokenfalse表示缓存为空或token已过期
*/
public boolean hasValidCachedToken() {
lock.readLock().lock();
try {
return tokenCache.hasValidToken();
} finally {
lock.readLock().unlock();
}
}
/**
* 强制刷新token用于主动刷新场景
*
* 该方法会忽略当前缓存状态强制获取新的token
* 使用写锁确保与其他操作的线程安全
*
* @return 新获取的租户访问令牌字符串
* @throws FsHelperException 当token获取失败时抛出
*/
public String forceRefreshToken() throws FsHelperException {
lock.writeLock().lock();
try {
long startTime = System.currentTimeMillis();
FsLogger.info("Force refresh initiated - thread: {}", Thread.currentThread().getName());
TokenInfo newToken = fetchNewToken();
tokenCache.set(newToken);
long duration = System.currentTimeMillis() - startTime;
FsLogger.info("Force refresh completed - expires in: {} seconds, duration: {}ms, thread: {}",
newToken.getRemainingSeconds(), duration, Thread.currentThread().getName());
// 记录性能指标
FsLogger.logPerformance("tenant_token_force_refresh", duration, true);
return newToken.getToken();
} catch (IOException e) {
String context = String.format("app_id=%s, thread=%s",
feishuClient.getAppId(), Thread.currentThread().getName());
FsLogger.error(ErrorCode.TOKEN_FETCH_FAILED,
"Failed to force refresh tenant access token", context, e);
tokenCache.clear();
throw FsHelperException.builder(ErrorCode.TOKEN_FETCH_FAILED)
.message("Failed to force refresh tenant access token from Feishu API")
.context("app_id", feishuClient.getAppId())
.context("thread", Thread.currentThread().getName())
.cause(e)
.build();
} finally {
lock.writeLock().unlock();
}
}
/**
* 检查是否有其他线程正在获取token
*
* @return true表示有线程持有写锁正在获取tokenfalse表示没有
*/
public boolean isTokenRefreshInProgress() {
return lock.isWriteLocked();
}
/**
* 获取当前持有读锁的线程数量
*
* @return 持有读锁的线程数量
*/
public int getReadLockCount() {
return lock.getReadLockCount();
}
/**
* 获取缓存状态信息用于监控和调试
*
* @return 包含缓存状态的字符串描述
*/
public String getCacheStatus() {
lock.readLock().lock();
try {
TokenInfo token = tokenCache.get();
String lockStatus = String.format("readers=%d, write_locked=%s",
lock.getReadLockCount(),
lock.isWriteLocked());
if (token == null) {
return String.format("Cache: empty, Lock: [%s]", lockStatus);
}
return String.format("Cache: token=%s..., valid=%s, expiring_soon=%s, remaining=%ds, Lock: [%s]",
token.getToken().substring(0, Math.min(10, token.getToken().length())),
token.isValid(),
token.isExpiringSoon(),
token.getRemainingSeconds(),
lockStatus);
} finally {
lock.readLock().unlock();
}
}
/**
* 记录缓存状态日志用于监控和调试
*/
public void logCacheStatus() {
String status = getCacheStatus();
FsLogger.debug("Token cache status: {}", status);
}
/**
* 记录详细的token信息用于调试
*/
private void logTokenDetails(TokenInfo token, String operation) {
if (token != null) {
Map<String, Object> context = new HashMap<>();
context.put("operation", operation);
context.put("token_prefix", token.getToken().substring(0, Math.min(10, token.getToken().length())));
context.put("valid", token.isValid());
context.put("expiring_soon", token.isExpiringSoon());
context.put("remaining_seconds", token.getRemainingSeconds());
context.put("thread", Thread.currentThread().getName());
FsLogger.debug("Token details - operation: {}, valid: {}, expiring_soon: {}, remaining: {}s",
operation, token.isValid(), token.isExpiringSoon(), token.getRemainingSeconds());
}
}
}

@ -0,0 +1,112 @@
package cn.isliu.core.service;
import cn.isliu.core.pojo.TokenInfo;
/**
* Token缓存组件
*
* 提供线程安全的TokenInfo存储和访问功能使用volatile关键字确保在多线程环境中
* 对token缓存的读写操作具有可见性保证
*
* 该类设计为轻量级缓存专门用于存储单个TokenInfo实例通过volatile字段
* 确保线程间的可见性配合外部的同步机制如读写锁实现完整的线程安全
*
* 线程安全机制
* - volatile字段确保内存可见性防止CPU缓存导致的数据不一致
* - 配合外部ReentrantReadWriteLock实现原子性操作
* - 所有操作都是原子的引用赋值避免数据竞争
*
* @author FsHelper
* @since 1.0
*/
public class TokenCache {
/**
* 当前缓存的token信息
*
* 使用volatile关键字确保多线程环境下的可见性
* - 当一个线程修改currentToken时其他线程能立即看到最新值
* - 防止CPU缓存导致的数据不一致问题
* - 与外部同步机制配合使用确保完整的线程安全
*/
private volatile TokenInfo currentToken;
/**
* 获取当前缓存的token信息
*
* @return 当前缓存的TokenInfo实例如果缓存为空则返回null
*/
public TokenInfo get() {
return currentToken;
}
/**
* 设置新的token信息到缓存
*
* 该操作是原子的由于使用volatile字段设置操作对所有线程立即可见
* 通常在外部写锁保护下调用确保与其他操作的原子性
*
* @param tokenInfo 要缓存的TokenInfo实例可以为null
*/
public void set(TokenInfo tokenInfo) {
this.currentToken = tokenInfo;
}
/**
* 清空token缓存
*
* 将缓存设置为null通常在token获取失败或需要强制刷新时调用
* 该操作是原子的由于使用volatile字段清空操作对所有线程立即可见
*/
public void clear() {
this.currentToken = null;
}
/**
* 检查缓存是否为空
*
* @return true表示缓存为空false表示缓存中有token信息
*/
public boolean isEmpty() {
return currentToken == null;
}
/**
* 检查缓存中的token是否有效
*
* @return true表示缓存中有token且仍然有效false表示缓存为空或token已过期
*/
public boolean hasValidToken() {
TokenInfo token = currentToken;
return token != null && token.isValid();
}
/**
* 原子性地比较并设置token
*
* 该方法提供了一种安全的方式来更新token只有当当前token与期望值相同时才进行更新
* 虽然volatile保证了可见性但这个方法在某些高级并发场景下可能有用
*
* 注意这个方法不是真正的CAS操作因为Java对象引用的比较和设置不是原子的
* 在实际使用中应该依赖外部的同步机制如写锁来确保原子性
*
* @param expected 期望的当前token值
* @param newToken 要设置的新token值
* @return true表示更新成功false表示当前token与期望值不同
*/
public boolean compareAndSet(TokenInfo expected, TokenInfo newToken) {
if (currentToken == expected) {
currentToken = newToken;
return true;
}
return false;
}
@Override
public String toString() {
TokenInfo token = currentToken;
return "TokenCache{" +
"currentToken=" + (token != null ? token.toString() : "null") +
'}';
}
}

@ -1,18 +1,22 @@
package cn.isliu.core.utils;
import cn.isliu.core.*;
import cn.isliu.core.annotation.TableConf;
import cn.isliu.core.annotation.TableProperty;
import cn.isliu.core.client.FsClient;
import cn.isliu.core.config.FsConfig;
import cn.isliu.core.converters.OptionsValueProcess;
import cn.isliu.core.enums.BaseEnum;
import cn.isliu.core.enums.TypeEnum;
import cn.isliu.core.pojo.FieldProperty;
import cn.isliu.core.service.CustomValueService;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
@ -32,7 +36,7 @@ public class FsTableUtil {
* @param spreadsheetToken 电子表格Token
* @return 飞书表格数据列表
*/
public static List<FsTableData> getFsTableData(Sheet sheet, String spreadsheetToken) {
public static List<FsTableData> getFsTableData(Sheet sheet, String spreadsheetToken, TableConf tableConf) {
// 计算数据范围
GridProperties gridProperties = sheet.getGridProperties();
@ -68,12 +72,12 @@ public class FsTableUtil {
List<FsTableData> dataList = getFsTableData(tableData);
Map<String, String> titleMap = new HashMap<>();
dataList.stream().filter(d -> d.getRow() == (FsConfig.getInstance().getTitleLine() - 1)).findFirst()
dataList.stream().filter(d -> d.getRow() == (tableConf.titleRow() - 1)).findFirst()
.ifPresent(d -> {
Map<String, String> map = (Map<String, String>) d.getData();
titleMap.putAll(map);
});
return dataList.stream().filter(fsTableData -> fsTableData.getRow() >= FsConfig.getInstance().getHeadLine()).map(item -> {
return dataList.stream().filter(fsTableData -> fsTableData.getRow() >= tableConf.headLine()).map(item -> {
Map<String, Object> resultMap = new HashMap<>();
Map<String, Object> map = (Map<String, Object>) item.getData();
@ -260,14 +264,14 @@ public class FsTableUtil {
return columnName.toString();
}
public static Map<String, String> getTitlePostionMap(Sheet sheet, String spreadsheetToken) {
public static Map<String, String> getTitlePostionMap(Sheet sheet, String spreadsheetToken, TableConf tableConf) {
GridProperties gridProperties = sheet.getGridProperties();
int colCount = gridProperties.getColumnCount();
Map<String, String> resultMap = new TreeMap<>();
ValuesBatch valuesBatch = FsApiUtil.getSheetData(sheet.getSheetId(), spreadsheetToken,
"A" + FsConfig.getInstance().getTitleLine(),
getColumnName(colCount - 1) + FsConfig.getInstance().getTitleLine(), FsClient.getInstance().getClient());
"A" + tableConf.titleRow(),
getColumnName(colCount - 1) + tableConf.titleRow(), FsClient.getInstance().getClient());
if (valuesBatch != null) {
List<ValueRange> valueRanges = valuesBatch.getValueRanges();
if (valueRanges != null && !valueRanges.isEmpty()) {
@ -288,8 +292,9 @@ public class FsTableUtil {
return resultMap;
}
public static void setTableOptions(String spreadsheetToken, List<String> headers, Map<String, FieldProperty> fieldsMap, String sheetId) {
public static void setTableOptions(String spreadsheetToken, List<String> headers, Map<String, FieldProperty> fieldsMap, String sheetId, boolean enableDesc) {
List<Object> list = Arrays.asList(headers.toArray());
int line = getMaxLevel(fieldsMap) + (enableDesc ? 2 : 1);
fieldsMap.forEach((field, fieldProperty) -> {
TableProperty tableProperty = fieldProperty.getTableProperty();
String position = "";
@ -300,7 +305,6 @@ public class FsTableUtil {
position = FsTableUtil.getColumnNameByNuNumber(i + 1);
}
}
int line = FsConfig.getInstance().getTitleLine() + 1;
if (tableProperty.enumClass() != BaseEnum.class) {
FsApiUtil.setOptions(sheetId, FsClient.getInstance().getClient(), spreadsheetToken, tableProperty.type() == TypeEnum.MULTI_SELECT, position + line, position + 200,
@ -324,19 +328,116 @@ public class FsTableUtil {
});
}
public static CustomValueService.ValueRequest getHeadTemplateBuilder(String sheetId, List<String> headers) {
CustomValueService.ValueRequest.BatchPutValuesBuilder batchPutValuesBuilder = CustomValueService.ValueRequest.batchPutValues();
batchPutValuesBuilder.addRange(sheetId + "!A1:" + FsTableUtil.getColumnNameByNuNumber(headers.size()) + "1");
batchPutValuesBuilder.addRow(headers.toArray());
public static CustomValueService.ValueRequest getHeadTemplateBuilder(String sheetId, List<String> headers,
Map<String, FieldProperty> fieldsMap, TableConf tableConf) {
String position = FsTableUtil.getColumnNameByNuNumber(headers.size());
CustomValueService.ValueRequest.BatchPutValuesBuilder batchPutValuesBuilder
= CustomValueService.ValueRequest.batchPutValues();
// 获取父级表头
// int maxLevel = getMaxLevel(fieldsMap);
//
// Map<Integer, List<String>> levelListMap = groupFieldsByLevel(fieldsMap);
// for (int i = maxLevel; i >= 1; i--) {
// List<String> values = levelListMap.get(i);
// batchPutValuesBuilder.addRange(sheetId + "!A" + i + ":" + position + i);
//
// }
//
// int titleRow = maxLevel;
int titleRow = tableConf.titleRow();
if (tableConf.enableDesc()) {
int descRow = titleRow + 1;
batchPutValuesBuilder.addRange(sheetId + "!A" + titleRow + ":" + position + descRow);
batchPutValuesBuilder.addRow(headers.toArray());
batchPutValuesBuilder.addRow(getDescArray(headers, fieldsMap));
} else {
batchPutValuesBuilder.addRange(sheetId + "!A" + titleRow + ":" + position + titleRow);
batchPutValuesBuilder.addRow(headers.toArray());
}
return batchPutValuesBuilder.build();
}
public static String getDefaultTableStyle(String sheetId, int size) {
private static int getMaxLevel(Map<String, FieldProperty> fieldsMap) {
AtomicInteger maxLevel = new AtomicInteger(1);
fieldsMap.forEach((field, fieldProperty) -> {
TableProperty tableProperty = fieldProperty.getTableProperty();
String[] value = tableProperty.value();
if (value.length > maxLevel.get()) {
maxLevel.set(value.length);
}
});
return maxLevel.get();
}
private static Object[] getDescArray(List<String> headers, Map<String, FieldProperty> fieldsMap) {
Object[] descArray = new String[headers.size()];
for (int i = 0; i < headers.size(); i++) {
String header = headers.get(i);
FieldProperty fieldProperty = fieldsMap.get(header);
if (fieldProperty != null && fieldProperty.getTableProperty() != null) {
String desc = fieldProperty.getTableProperty().desc();
if (desc != null && !desc.isEmpty()) {
try {
JsonElement element = JsonParser.parseString(desc);
if (element.isJsonObject()) {
descArray[i] = element.getAsJsonObject();
} else if (element.isJsonArray()) {
descArray[i] = element.getAsJsonArray();
} else {
descArray[i] = desc;
}
} catch (JsonSyntaxException e) {
descArray[i] = desc;
}
} else {
descArray[i] = null;
}
} else {
descArray[i] = null;
}
}
return descArray;
}
public static String getDefaultTableStyle(String sheetId, int size, TableConf tableConf) {
int row = tableConf.titleRow();
String colorTemplate = "{\"data\": [{\"style\": {\"font\": {\"bold\": true, \"clean\": false, \"italic\": false, \"fontSize\": \"10pt/1.5\"}, \"clean\": false, \"hAlign\": 1, \"vAlign\": 1, \"backColor\": \"#000000\", \"foreColor\": \"#ffffff\", \"formatter\": \"\", \"borderType\": \"FULL_BORDER\", \"borderColor\": \"#000000\", \"textDecoration\": 0}, \"ranges\": [\"SHEET_ID!RANG\"]}]}";
colorTemplate = colorTemplate.replace("SHEET_ID", sheetId);
colorTemplate = colorTemplate.replace("RANG", "A1:" + FsTableUtil.getColumnNameByNuNumber(size) + "1");
colorTemplate = colorTemplate.replace("FORE_COLOR", FsConfig.getInstance().getForeColor())
.replace("BACK_COLOR", FsConfig.getInstance().getBackColor());
colorTemplate = colorTemplate.replace("RANG", "A" + row + ":" + FsTableUtil.getColumnNameByNuNumber(size) + row);
colorTemplate = colorTemplate.replace("FORE_COLOR", tableConf.headFontColor())
.replace("BACK_COLOR", tableConf.headBackColor());
return colorTemplate;
}
/**
* 根据层级分组字段属性
*
* @param fieldsMap 字段属性映射
* @return 按层级分组的映射key为层级value为该层级的字段名数组
*/
public static Map<Integer, List<String>> groupFieldsByLevel(Map<String, FieldProperty> fieldsMap) {
Map<Integer, List<String>> levelMap = new HashMap<>();
for (Map.Entry<String, FieldProperty> entry : fieldsMap.entrySet()) {
FieldProperty fieldProperty = entry.getValue();
if (fieldProperty != null && fieldProperty.getTableProperty() != null) {
String[] values = fieldProperty.getTableProperty().value();
for (int i = 0; i < values.length; i++) {
levelMap.computeIfAbsent(i, k -> new ArrayList<>()).add(values[i]);
}
}
}
return levelMap;
}
public static void main(String[] args) {
String str ="支持13个搜索";
System.out.println(str.length());
}
}

@ -1,8 +1,6 @@
package cn.isliu.core.utils;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.*;
import java.util.HashMap;
import java.util.Map;
@ -16,7 +14,7 @@ public class JSONUtil {
* @param data HashMap数据
* @return 转换后的JsonObject
*/
public static JsonObject convertHashMapToJsonObject(HashMap<String, Object> data) {
public static JsonObject convertMapToJsonObject(Map<String, Object> data) {
JsonObject jsonObject = new JsonObject();
for (Map.Entry<String, Object> entry : data.entrySet()) {
String key = entry.getKey();
@ -46,4 +44,13 @@ public class JSONUtil {
}
return jsonObject;
}
public static boolean isValidJson(String json) {
try {
JsonParser.parseString(json);
return true;
} catch (JsonSyntaxException e) {
return false;
}
}
}

@ -1,5 +1,6 @@
package cn.isliu.core.utils;
import cn.isliu.core.annotation.TableConf;
import cn.isliu.core.annotation.TableProperty;
import cn.isliu.core.converters.FieldValueProcess;
import cn.isliu.core.converters.OptionsValueProcess;
@ -95,7 +96,9 @@ public class PropertyUtil {
// 检查字段是否有@TableProperty注解
if (field.isAnnotationPresent(TableProperty.class)) {
TableProperty tableProperty = field.getAnnotation(TableProperty.class);
String[] propertyValues = tableProperty.value().split("\\."); // 支持多个值
String[] values = tableProperty.value();
String value = values[values.length - 1];
String[] propertyValues = value.split("\\."); // 支持多个值
String propertyValue = (propertyValues.length > 0 && !propertyValues[0].isEmpty()) ? propertyValues[0] : field.getName();
processFieldForMap(field, propertyValue, keyPrefix, valuePrefix, result, depthMap, fieldsWithChildren, parentHasAnnotation);
@ -150,8 +153,13 @@ public class PropertyUtil {
}
@Override
public String value() {
return value;
public String[] value() {
return new String[]{value};
}
@Override
public String desc() {
return "";
}
@Override
@ -362,4 +370,53 @@ public class PropertyUtil {
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
public static <T> TableConf getTableConf(Class<T> clazz) {
TableConf tableConf;
if (clazz.isAnnotationPresent(TableConf.class)) {
tableConf = clazz.getAnnotation(TableConf.class);
} else {
tableConf = new TableConf() {
@Override
public Class<? extends Annotation> annotationType() {
return TableConf.class;
}
@Override
public int headLine() {
return 1;
}
@Override
public int titleRow() {
return 1;
}
@Override
public boolean enableCover() {
return false;
}
@Override
public boolean isText() {
return false;
}
@Override
public boolean enableDesc() {
return false;
}
@Override
public String headFontColor() {
return "#ffffff";
}
@Override
public String headBackColor() {
return "#000000";
}
};
}
return tableConf;
}
}