diff --git a/pom.xml b/pom.xml index c71954f..a4590df 100644 --- a/pom.xml +++ b/pom.xml @@ -151,12 +151,12 @@ - - - - - - + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + diff --git a/src/main/java/cn/isliu/FsHelper.java b/src/main/java/cn/isliu/FsHelper.java index c369c8d..77300d5 100644 --- a/src/main/java/cn/isliu/FsHelper.java +++ b/src/main/java/cn/isliu/FsHelper.java @@ -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 fieldsMap = PropertyUtil.getTablePropertyFieldsMap(clazz); List 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 results = new ArrayList<>(); FeishuClient client = FsClient.getInstance().getClient(); Sheet sheet = FsApiUtil.getSheetMetadata(sheetId, client, spreadsheetToken); - List fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken); + TableConf tableConf = PropertyUtil.getTableConf(clazz); + List fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken, tableConf); Map fieldsMap = PropertyUtil.getTablePropertyFieldsMap(clazz); List 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) data); + Map rowData = (HashMap) data; + JsonObject jsonObject = JSONUtil.convertMapToJsonObject(rowData); Map 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 fieldsMap = PropertyUtil.getTablePropertyFieldsMap(aClass); + TableConf tableConf = PropertyUtil.getTableConf(aClass); FeishuClient client = FsClient.getInstance().getClient(); Sheet sheet = FsApiUtil.getSheetMetadata(sheetId, client, spreadsheetToken); - List fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken); + List fsTableDataList = FsTableUtil.getFsTableData(sheet, spreadsheetToken, tableConf); Map currTableRowMap = fsTableDataList.stream().collect(Collectors.toMap(FsTableData::getUniqueId, FsTableData::getRow)); final Integer[] row = {0}; @@ -133,7 +140,7 @@ public class FsHelper { } }); - Map titlePostionMap = FsTableUtil.getTitlePostionMap(sheet, spreadsheetToken); + Map titlePostionMap = FsTableUtil.getTitlePostionMap(sheet, spreadsheetToken, tableConf); Map 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 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; } diff --git a/src/main/java/cn/isliu/core/BaseEntity.java b/src/main/java/cn/isliu/core/BaseEntity.java index 4c33e89..391c9cc 100644 --- a/src/main/java/cn/isliu/core/BaseEntity.java +++ b/src/main/java/cn/isliu/core/BaseEntity.java @@ -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 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 getRowData() { + return rowData; + } + + public void setRowData(Map 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); + } } \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/annotation/TableConf.java b/src/main/java/cn/isliu/core/annotation/TableConf.java new file mode 100644 index 0000000..4e54c4c --- /dev/null +++ b/src/main/java/cn/isliu/core/annotation/TableConf.java @@ -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"; +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/annotation/TableProperty.java b/src/main/java/cn/isliu/core/annotation/TableProperty.java index 80dd366..f04f8d9 100644 --- a/src/main/java/cn/isliu/core/annotation/TableProperty.java +++ b/src/main/java/cn/isliu/core/annotation/TableProperty.java @@ -23,7 +23,14 @@ public @interface TableProperty { * * @return 列名字符串 */ - String value() default ""; + String[] value() default {}; + + /** + * 字段描述 + * + * @return 字段描述字符串或字符串数组 + */ + String desc() default ""; /** * 字段名 diff --git a/src/main/java/cn/isliu/core/config/ConfigBuilder.java b/src/main/java/cn/isliu/core/config/ConfigBuilder.java deleted file mode 100644 index 4ca6076..0000000 --- a/src/main/java/cn/isliu/core/config/ConfigBuilder.java +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/config/ConfigChangeEvent.java b/src/main/java/cn/isliu/core/config/ConfigChangeEvent.java deleted file mode 100644 index 446a995..0000000 --- a/src/main/java/cn/isliu/core/config/ConfigChangeEvent.java +++ /dev/null @@ -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 + - '}'; - } -} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/config/ConfigChangeListener.java b/src/main/java/cn/isliu/core/config/ConfigChangeListener.java deleted file mode 100644 index fe20a2c..0000000 --- a/src/main/java/cn/isliu/core/config/ConfigChangeListener.java +++ /dev/null @@ -1,13 +0,0 @@ -package cn.isliu.core.config; - -/** - * 配置变更监听器接口 - */ -public interface ConfigChangeListener { - - /** - * 配置变更时的回调方法 - * @param event 配置变更事件 - */ - void onConfigChanged(ConfigChangeEvent event); -} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/config/ConfigSnapshot.java b/src/main/java/cn/isliu/core/config/ConfigSnapshot.java deleted file mode 100644 index 772162c..0000000 --- a/src/main/java/cn/isliu/core/config/ConfigSnapshot.java +++ /dev/null @@ -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 + - '}'; - } -} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/config/FsConfig.java b/src/main/java/cn/isliu/core/config/FsConfig.java deleted file mode 100644 index 6d94ee2..0000000 --- a/src/main/java/cn/isliu/core/config/FsConfig.java +++ /dev/null @@ -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 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); - } -} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/enums/ErrorCode.java b/src/main/java/cn/isliu/core/enums/ErrorCode.java index dc6151c..fd4d1b0 100644 --- a/src/main/java/cn/isliu/core/enums/ErrorCode.java +++ b/src/main/java/cn/isliu/core/enums/ErrorCode.java @@ -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; diff --git a/src/main/java/cn/isliu/core/exception/TokenManagementException.java b/src/main/java/cn/isliu/core/exception/TokenManagementException.java new file mode 100644 index 0000000..fa68156 --- /dev/null +++ b/src/main/java/cn/isliu/core/exception/TokenManagementException.java @@ -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 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 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."; + } + } +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/pojo/FieldProperty.java b/src/main/java/cn/isliu/core/pojo/FieldProperty.java index ec12b30..e6ec9b8 100644 --- a/src/main/java/cn/isliu/core/pojo/FieldProperty.java +++ b/src/main/java/cn/isliu/core/pojo/FieldProperty.java @@ -89,7 +89,7 @@ public class FieldProperty { * * @return 列名字符串 */ - public String getFieldName() { + public String[] getFieldName() { return tableProperty.value(); } diff --git a/src/main/java/cn/isliu/core/pojo/TokenInfo.java b/src/main/java/cn/isliu/core/pojo/TokenInfo.java new file mode 100644 index 0000000..7589bb1 --- /dev/null +++ b/src/main/java/cn/isliu/core/pojo/TokenInfo.java @@ -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() + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/pojo/TokenResponse.java b/src/main/java/cn/isliu/core/pojo/TokenResponse.java new file mode 100644 index 0000000..5237b0a --- /dev/null +++ b/src/main/java/cn/isliu/core/pojo/TokenResponse.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java b/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java index acb28a3..fac253f 100644 --- a/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java +++ b/src/main/java/cn/isliu/core/service/AbstractFeishuApiService.java @@ -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 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); } } diff --git a/src/main/java/cn/isliu/core/service/CustomValueService.java b/src/main/java/cn/isliu/core/service/CustomValueService.java index 679590f..4691128 100644 --- a/src/main/java/cn/isliu/core/service/CustomValueService.java +++ b/src/main/java/cn/isliu/core/service/CustomValueService.java @@ -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> 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; + } + /** * 获取数据值 * diff --git a/src/main/java/cn/isliu/core/service/TenantTokenManager.java b/src/main/java/cn/isliu/core/service/TenantTokenManager.java new file mode 100644 index 0000000..c9d8ddb --- /dev/null +++ b/src/main/java/cn/isliu/core/service/TenantTokenManager.java @@ -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 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 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表示有有效的缓存token,false表示缓存为空或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表示有线程持有写锁(正在获取token),false表示没有 + */ + 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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/service/TokenCache.java b/src/main/java/cn/isliu/core/service/TokenCache.java new file mode 100644 index 0000000..9ff97cf --- /dev/null +++ b/src/main/java/cn/isliu/core/service/TokenCache.java @@ -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") + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/utils/FsTableUtil.java b/src/main/java/cn/isliu/core/utils/FsTableUtil.java index 655e13f..e754b4d 100644 --- a/src/main/java/cn/isliu/core/utils/FsTableUtil.java +++ b/src/main/java/cn/isliu/core/utils/FsTableUtil.java @@ -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 getFsTableData(Sheet sheet, String spreadsheetToken) { + public static List getFsTableData(Sheet sheet, String spreadsheetToken, TableConf tableConf) { // 计算数据范围 GridProperties gridProperties = sheet.getGridProperties(); @@ -68,12 +72,12 @@ public class FsTableUtil { List dataList = getFsTableData(tableData); Map 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 map = (Map) 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 resultMap = new HashMap<>(); Map map = (Map) item.getData(); @@ -260,14 +264,14 @@ public class FsTableUtil { return columnName.toString(); } - public static Map getTitlePostionMap(Sheet sheet, String spreadsheetToken) { + public static Map getTitlePostionMap(Sheet sheet, String spreadsheetToken, TableConf tableConf) { GridProperties gridProperties = sheet.getGridProperties(); int colCount = gridProperties.getColumnCount(); Map 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 valueRanges = valuesBatch.getValueRanges(); if (valueRanges != null && !valueRanges.isEmpty()) { @@ -288,8 +292,9 @@ public class FsTableUtil { return resultMap; } - public static void setTableOptions(String spreadsheetToken, List headers, Map fieldsMap, String sheetId) { + public static void setTableOptions(String spreadsheetToken, List headers, Map fieldsMap, String sheetId, boolean enableDesc) { List 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 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 headers, + Map fieldsMap, TableConf tableConf) { + + String position = FsTableUtil.getColumnNameByNuNumber(headers.size()); + + CustomValueService.ValueRequest.BatchPutValuesBuilder batchPutValuesBuilder + = CustomValueService.ValueRequest.batchPutValues(); + + // 获取父级表头 +// int maxLevel = getMaxLevel(fieldsMap); +// +// Map> levelListMap = groupFieldsByLevel(fieldsMap); +// for (int i = maxLevel; i >= 1; i--) { +// List 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 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 headers, Map 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> groupFieldsByLevel(Map fieldsMap) { + Map> levelMap = new HashMap<>(); + + for (Map.Entry 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 ="支持1~3个搜索"; + System.out.println(str.length()); + } } \ No newline at end of file diff --git a/src/main/java/cn/isliu/core/utils/JSONUtil.java b/src/main/java/cn/isliu/core/utils/JSONUtil.java index 0ec9c30..76f8f5f 100644 --- a/src/main/java/cn/isliu/core/utils/JSONUtil.java +++ b/src/main/java/cn/isliu/core/utils/JSONUtil.java @@ -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 data) { + public static JsonObject convertMapToJsonObject(Map data) { JsonObject jsonObject = new JsonObject(); for (Map.Entry 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; + } + } } diff --git a/src/main/java/cn/isliu/core/utils/PropertyUtil.java b/src/main/java/cn/isliu/core/utils/PropertyUtil.java index 1aa93f3..641f9c4 100644 --- a/src/main/java/cn/isliu/core/utils/PropertyUtil.java +++ b/src/main/java/cn/isliu/core/utils/PropertyUtil.java @@ -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 TableConf getTableConf(Class clazz) { + TableConf tableConf; + if (clazz.isAnnotationPresent(TableConf.class)) { + tableConf = clazz.getAnnotation(TableConf.class); + } else { + tableConf = new TableConf() { + @Override + public Class 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; + } } \ No newline at end of file