diff --git a/.gitignore b/.gitignore index 9d3ebbe1a..109a1d6f6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ build/ .vscode/ *.db *.trace.db + +### Claude Code & CodeGraph ### +.claude/ +.codegraph/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fa13ff489 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概览 + +`com.codingapi.springboot:springboot-parent`(v2.10.55)是一套基于 Spring Boot 2.7.18 + JDK 8 的**领域驱动设计(DDD)落地框架**,围绕"事件风暴 + 流程编排 + 数据权限 + 动态脚本"四大支柱提供可插拔的 starter 模块。框架代码本身即是规范,业务方按需引入 starter 即可获得对应能力,无需重复造轮子。 + +详细能力文档见 `docs/capabilities/index.md`(共 9 篇),开发规范见 `docs/conventions/index.md`。 + +## 模块拓扑与依赖 + +多模块项目,根 `pom.xml` 中只激活 `springboot-starter-data-authorization`;完整 6 模块通过 `dev` / `travis` / `ossrh` profile 激活。模块依赖**严格单向**,不得反向依赖: + +``` +springboot-starter (基础 DDD 框架:领域事件、代理、异常、加密、HTTP 代理) + │ + ├─→ springboot-starter-script (Groovy 脚本引擎,依赖 starter) + │ │ + │ ├─→ springboot-starter-data-fast (JPA 增强 + 脚本映射,依赖 starter+script) + │ │ │ + │ │ └─→ springboot-starter-flow (流程引擎,依赖 starter+script) + │ │ + │ └─→ springboot-starter-flow (同上路径) + │ + └─→ springboot-starter-security (JWT/Redis 双模 Token 网关,依赖 starter) + │ + └─→ springboot-starter-data-authorization (行+列数据权限,**仅在根 pom 直接声明**) +``` + +⚠️ `springboot-starter-data-authorization` 不依赖 starter,自带 jsqlparser 解析 SQL,改写后通过 `AuthorizationJdbcDriver` 在结果集层做列权限脱敏。 + +## 关键架构决策 + +| 决策 | 体现位置 | 说明 | +|------|---------|------| +| **领域事件总线** | `framework/event/` | 自研 `EventPusher` + `DomainEvent`,支持同步/异步/事务后(`@TransactionalEventListener AFTER_COMMIT`),通过 `EventTraceContext` 检测循环事件 | +| **CGLIB 实体代理** | `framework/domain/proxy/` | `DomainProxyFactory.create()` 在 setter 调用前后对比字段差异,自动发布 `DomainChangeEvent`,无需在业务 setter 中写事件代码 | +| **脚本热加载** | `script/GroovyScript*` | 编译缓存用 SHA-256 做 key;`TempGroovyScript` 临时脚本由 `GroovyScriptEngineRunner` 在启动时从 DB 恢复、应用关闭时落盘 | +| **双模 Token** | `security/gateway/TokenGateway` | 同一接口有 `JwtTokenGateway`(无状态)和 `RedisTokenGateway`(可踢人)两种实现,通过 `codingapi.security.token.type` 切换 | +| **行+列双层权限** | `authorization/` | `DefaultSQLInterceptor` 在 SQL 执行前用 `JSqlParser` 递归改写 WHERE;`ResultSetProxy` 在结果集读取时通过 `ColumnHandlerContext` 脱敏 | +| **泛型签名一致性** | `framework/utils/RandomGenerator` 等 | JDK 8 编译时必须显式声明 `LinkedHashMap` 等类型,依赖 IDE 推断会丢失泛型信息(见 git log 中 `GroovyScriptRuntime` 修复记录) | +| **javax.persistence 适配** | 所有 `*Repository` 接口 | 项目基于 Spring Boot 2.7.18,对应 JPA 2.x;**禁止使用** `jakarta.persistence.*`(那是 Spring Boot 3.x / Jakarta EE 9+) | + +## 常用命令 + +```bash +# 构建 +./mvnw clean install -DskipTests # 跳过测试 +./mvnw clean test # 全量测试(默认 dev profile) + +# 单模块测试 +./mvnw -pl springboot-starter -am test +./mvnw -pl springboot-starter-flow -Dtest=FlowServiceTest test + +# 部署 profile +./mvnw clean test -P travis # 启用 jacoco + clover +./mvnw clean deploy -P ossrh # 推送到 Sonatype(需 GPG) +``` + +测试基于 H2 内存库(`com.h2database:h2` scope=test),无需外部 DB;流程引擎测试会触发 `DemoChangeLogHandler` 的大量并发日志输出(H2 关闭时的告警是正常的)。 + +## 文档与知识库 + +- `README.md` — 模块列表、maven 依赖、版本说明 +- `event.md` — 流程引擎事件触发机制(CREATE/SAVE/PASS/REJECT/RECALL/DELETE/VOIDED/BACK/FINISH/TRANSFER/URGE/STOP) +- `springboot-starter-flow/README.md` — 流程引擎功能矩阵(流程管理/设计/能力 9 大类) +- `docs/capabilities/` — 9 篇核心能力文档(DDD 事件、领域代理、Groovy 脚本、JDBC 代理、SQL 拦截器、JPA 仓储、流程 Schema、Token 网关、Spring Boot 基础) +- `docs/conventions/` — 项目开发规范(待补全) +- Wiki:https://github.com/codingapi/springboot-framework/wiki + + +## PKR 知识查阅(编码前必须) + +进入计划模式或实现功能前,**必须按以下优先级查阅**: + +### ⚠️ 开发规范(最高优先级,必须严格遵守) + +1. [docs/conventions/index.md](./docs/conventions/index.md) — 项目开发规范 + +**规范具有最高优先级。** 所有代码必须遵循已注册的 Convention,违反规范的代码视为缺陷。 +编码前必须逐条检查相关规范,确保命名、结构、模式完全符合要求。 + +### 已有能力(必须复用,禁止重复实现) + +2. [docs/capabilities/index.md](./docs/capabilities/index.md) — 已有可复用能力 + +已有能力必须复用,禁止重新实现。优先组合已有能力解决问题。 + +### 计划模式约束 + +计划方案中必须包含: +1. **遵循了哪些规范** — 列出遵守的 Convention(必须首先说明) +2. **复用了哪些已有能力** — 列出从 PKR 中找到并复用的 Capability +3. **是否有新增能力** — 如果本次开发产生了可复用的新能力,完成后通过 `/pkr-add` 注册 + +### 知识管理命令 + +| 命令 | 用途 | +|------|------| +| `/pkr-init` | 扫描项目,发现候选能力和规范(自动跳过已有文档) | +| `/pkr-sync` | 全量同步,对比代码变更 | +| `/pkr-update / [desc]` | 单项更新,可带描述指导更新 | +| `/pkr-add / ` | 从代码/框架扫描注册 | +| `/pkr-add plan / ` | 注册计划中的能力 | +| `/pkr-export ...` | 导出模块文档供其他项目使用 | + diff --git a/docs/capabilities/index.md b/docs/capabilities/index.md new file mode 100644 index 000000000..0739ebd09 --- /dev/null +++ b/docs/capabilities/index.md @@ -0,0 +1,21 @@ +# 能力(Capabilities) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +## ✅ 已实现 + +| 名称 | 模块 | 描述 | 范围 | 来源 | +|------|------|------|------|------| +| [springboot/spring-boot](./springboot/spring-boot.md) | springboot | Spring Boot 2.7 基础框架,提供 IoC 容器、AOP、事件发布、配置自动装配等核心能力 | 后端 | 框架:springboot | +| [springboot-starter/domain-event](./springboot-starter/domain-event.md) | springboot-starter | 领域事件总线(同步/异步/事务后),提供事件栈追踪、循环事件检测与全局 Handler 调度 | 后端 | 项目自有 | +| [springboot-starter/domain-proxy](./springboot-starter/domain-proxy.md) | springboot-starter | 基于 CGLIB 的实体代理工厂,在创建时发布 DomainCreateEvent,在 setter 触发时对比字段差异并发布 DomainChangeE... | 后端 | 项目自有 | +| [springboot-starter-data-authorization/jdbc-proxy](./springboot-starter-data-authorization/jdbc-proxy.md) | springboot-starter-data-authorization | 自定义 JDBC 驱动代理(Connection/Statement/PreparedStatement/CallableStatement/Result... | 后端 | 项目自有 | +| [springboot-starter-data-authorization/sql-interceptor](./springboot-starter-data-authorization/sql-interceptor.md) | springboot-starter-data-authorization | 基于 JSqlParser 的 SQL 拦截器,对 SELECT 语句递归改写以注入行级数据权限条件 | 后端 | 项目自有 | +| [springboot-starter-data-fast/jpa-repository](./springboot-starter-data-fast/jpa-repository.md) | springboot-starter-data-fast | 增强版 JPA Repository 接口体系,支持动态 SQL、原生查询、Map 返回、分页、排序与高级搜索 | 后端 | 项目自有 | +| [springboot-starter-flow/schema-reader](./springboot-starter-flow/schema-reader.md) | springboot-starter-flow | 流程设计图(Schema)解析器,将 JSON 描述的节点/边转换为 FlowNode/FlowRelation 内存模型 | 后端 | 项目自有 | +| [springboot-starter-script/groovy-script-runtime](./springboot-starter-script/groovy-script-runtime.md) | springboot-starter-script | 基于 Groovy 的动态脚本运行时,支持函数调用与脚本执行两类入口、LRU 缓存、临时脚本落盘与启动恢复 | 后端 | 项目自有 | +| [springboot-starter-security/token-gateway](./springboot-starter-security/token-gateway.md) | springboot-starter-security | 无状态 JWT 与有状态 Redis 双模 Token 网关,统一 Token 生命周期、过期/续期与权限注入 | 后端 | 项目自有 | + +--- + +**统计**: 共 9 篇 — 已实现 9 / 计划中 0 / 已废弃 0 diff --git a/docs/capabilities/springboot-starter-data-authorization/jdbc-proxy.md b/docs/capabilities/springboot-starter-data-authorization/jdbc-proxy.md new file mode 100644 index 000000000..8726c8331 --- /dev/null +++ b/docs/capabilities/springboot-starter-data-authorization/jdbc-proxy.md @@ -0,0 +1,92 @@ +--- +name: springboot-starter-data-authorization/jdbc-proxy +module: springboot-starter-data-authorization +description: 自定义 JDBC 驱动代理(Connection/Statement/PreparedStatement/CallableStatement/ResultSet),在结果集读取时按列权限脱敏 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-authorization" +symbols: + - ConnectionProxy + - StatementProxy + - PreparedStatementProxy + - CallableStatementProxy + - ResultSetProxy +content_hash: 6008a8c44ddcc660b4d1d802a6357cf4fa41ae7da5b798f80e5b995187192397 +--- + +## 解决什么问题 + +数据权限的最后一公里问题:业务 SQL 已经按 `DefaultSQLInterceptor` 增强了 WHERE 条件,但 **列级权限**(如手机号脱敏、身份证屏蔽)需要在结果集返回前对每一列做后置处理。 + +JDBC 规范要求返回的 `ResultSet` 是只读视图,业务方代码无法在数据离开驱动层后再修改。本能力通过自定义 JDBC 驱动 + 全套 Proxy 类,在 `getString/getInt/getBigDecimal` 等方法被调用时统一走 `ColumnHandlerContext` 进行列权限判断与脱敏。 + +## 如何使用 + +**1. 启用框架自动装配** + +引入 `springboot-starter-data-authorization` 依赖后,框架会注册 `AuthorizationJdbcDriver`。业务代码通过 `DriverManager.getConnection(url, props)` 时,传入自定义 URL 前缀即可走代理。 + +**2. 注册列处理器(按表/列粒度)** + +```java +@Component +public class PhoneColumnHandler extends ColumnHandler { + @Override + public String getString(SQLExecuteState state, int columnIndex, String tableName, + String columnName, String value) { + if ("user".equalsIgnoreCase(tableName) && "phone".equalsIgnoreCase(columnName) + && !hasPermission(state, "user:phone:read")) { + return maskPhone(value); // 138****1234 + } + return value; + } +} +``` + +**3. 注册 RowHandler 控制行级权限** + +行级权限由 `DefaultSQLInterceptor` 配合 `RowHandler` 完成 SQL 改写;列级权限则由 `ColumnHandler` 在结果集读取时完成(详见 `ResultSetProxy.getString(int)` 等方法)。 + +**4. 支持的列类型** + +`ResultSetProxy` 完整代理了以下方法的重载: +- `getString` / `getBoolean` / `getByte` / `getShort` / `getInt` / `getLong` +- `getFloat` / `getDouble` / `getBigDecimal` / `getBytes` +- `getDate` / `getTime` / `getTimestamp` +- `getAsciiStream` / `getUnicodeStream` / `getBinaryStream` / `getObject` + +每种类型都通过 `ColumnHandlerContext` 的对应方法转发。 + +## 使用实例 + +```java +// 1. 业务代码无需感知权限:直接通过标准 JDBC 读取 +try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement("SELECT id, name, phone FROM user"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String name = rs.getString("name"); + String phone = rs.getString("phone"); // 自动脱敏:138****1234 + } +} + +// 2. 自定义列权限策略 +@Component +public class SalaryColumnHandler extends ColumnHandler { + @Override + public String getString(SQLExecuteState state, int columnIndex, String tableName, + String columnName, String value) { + if ("employee".equalsIgnoreCase(tableName) && "salary".equalsIgnoreCase(columnName) + && !isHR(state.getUserId())) { + return "***"; // 非 HR 不可见 + } + return value; + } +} + +// 3. 与 SQL 拦截器配合: +// SQL 拦截器在 SQL 执行前改写 WHERE 条件(行级权限) +// JDBC 代理在结果集返回时改写列值(列级权限) +// 二者结合形成完整的"行+列"数据权限 +``` diff --git a/docs/capabilities/springboot-starter-data-authorization/sql-interceptor.md b/docs/capabilities/springboot-starter-data-authorization/sql-interceptor.md new file mode 100644 index 000000000..a4b64c836 --- /dev/null +++ b/docs/capabilities/springboot-starter-data-authorization/sql-interceptor.md @@ -0,0 +1,122 @@ +--- +name: springboot-starter-data-authorization/sql-interceptor +module: springboot-starter-data-authorization +description: 基于 JSqlParser 的 SQL 拦截器,对 SELECT 语句递归改写以注入行级数据权限条件 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-authorization" +symbols: + - DefaultSQLInterceptor + - SQLInterceptor + - SQLInterceptorContext + - DataPermissionSQL + - DataPermissionSQLEnhancer +content_hash: 4ccd229afdaba85784de3adbfe98b9c96ae342baf3f5ad7ba9dbddbeb50f603b +--- + +## 解决什么问题 + +在多租户/部门级/用户级的数据权限场景下,业务 SQL 往往只写 `SELECT * FROM order`,但实际执行时需要自动追加 `WHERE dept_id IN (...) AND create_user = ?` 之类的条件。手动在每个 DAO 中拼接条件既易遗漏又难维护。 + +本能力提供 `SQLInterceptor` 抽象,框架默认实现 `DefaultSQLInterceptor` 通过 `JSqlParser` 解析 SELECT 语句,递归遍历 FROM/JOIN/WHERE 中的子查询,按表名+别名调用 `RowHandler` 返回的 `Condition` 注入到对应位置。 + +## 如何使用 + +**1. 注册行级权限处理器** + +```java +@Component +public class OrderRowHandler implements RowHandler { + @Override + public Condition handler(String sql, String tableName, String aliasName) { + if (!"order".equalsIgnoreCase(tableName)) { + return null; + } + List conditions = new ArrayList<>(); + + // 当前用户部门 + String deptId = currentUser.getDeptId(); + conditions.add(new EqualCondition(aliasName + ".dept_id", deptId)); + + // 仅本人订单 + conditions.add(new OrCondition( + new EqualCondition(aliasName + ".create_user", currentUser.getId()), + new EqualCondition(aliasName + ".is_public", true) + )); + + return new Condition(conditions); + } +} +``` + +**2. 实现自定义拦截器(可选,覆盖默认)** + +```java +@Component +@Primary +public class MySQLInterceptor implements SQLInterceptor { + @Override + public boolean beforeHandler(String sql) { + return SQLUtils.isQuerySql(sql); + } + + @Override + public DataPermissionSQL postHandler(String sql) throws SQLException { + // 自定义 SQL 改写逻辑 + ... + } + + @Override + public void afterHandler(String sql, String newSql, SQLException exception) { + // 审计日志 + } +} +``` + +**3. 配置(可选)** + +```yaml +codingapi: + authorization: + show-sql: true # 打印改写后的 SQL +``` + +**4. 工作流程(见 `DataPermissionSQLEnhancer.deepMatch`)** + +- 解析 SQL → `Select` AST +- 若为 `PlainSelect`:处理 FROM + JOIN + WHERE +- 若为 `SetOperationList`:递归处理每个子 SELECT +- WHERE 中的 `AndExpression/OrExpression/InExpression/ParenthesedSelect` 也递归处理 +- 对每个表调用 `RowHandler.handler()` 获取 `Condition` 并通过 `ConditionSQLHandlerContext` 注入 + +## 使用实例 + +```java +// 1. 业务方编写原始 SQL,无需关心权限 +@Repository +public class OrderRepository { + @Query("SELECT * FROM order WHERE status = 'PAID'") + List findPaidOrders(); +} +// 实际执行时会被改写为: +// SELECT * FROM order o WHERE status = 'PAID' AND o.dept_id = ? AND (o.create_user = ? OR o.is_public = true) + +// 2. 子查询递归处理 +// 复杂报表:SELECT * FROM (SELECT * FROM order WHERE ...) tmp +// 内层 SELECT 也会被自动增强 + +// 3. 注册多个 RowHandler(按表名分发) +@Component +public class CompositeRowHandler implements RowHandler { + @Autowired private OrderRowHandler orderHandler; + @Autowired private UserRowHandler userHandler; + + @Override + public Condition handler(String sql, String tableName, String aliasName) { + if ("order".equalsIgnoreCase(tableName)) return orderHandler.handler(sql, tableName, aliasName); + if ("user".equalsIgnoreCase(tableName)) return userHandler.handler(sql, tableName, aliasName); + return null; + } +} +``` diff --git a/docs/capabilities/springboot-starter-data-fast/jpa-repository.md b/docs/capabilities/springboot-starter-data-fast/jpa-repository.md new file mode 100644 index 000000000..73c2db0a7 --- /dev/null +++ b/docs/capabilities/springboot-starter-data-fast/jpa-repository.md @@ -0,0 +1,120 @@ +--- +name: springboot-starter-data-fast/jpa-repository +module: springboot-starter-data-fast +description: 增强版 JPA Repository 接口体系,支持动态 SQL、原生查询、Map 返回、分页、排序与高级搜索 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-fast" +symbols: + - BaseRepository + - DynamicRepository + - DynamicNativeRepository + - SortRepository + - FastRepository +content_hash: 25a2f36d9e6b8884a84feef23916c16218218e1b4162221779651c051d7c31d5 +--- + +## 解决什么问题 + +标准 Spring Data JPA 的 `JpaRepository` 在面对以下场景时力不从心: + +- 动态 SQL(运行时拼接 WHERE 条件) +- 原生 SQL(避免 HQL/Criteria 复杂度) +- 返回 `Map`/`MapViewResult`(前端表格、报表) +- 列表+分页+动态过滤的统一抽象 +- 拖拽排序(基于 `ISort.sort` 字段) + +本能力在 `JpaRepository` 之上扩展了 5 个接口,按需组合使用: + +- `BaseRepository`:提供 `getEntityClass()` 反射工具 +- `DynamicRepository`:基于 HQL 的动态查询 +- `DynamicNativeRepository`:基于原生 SQL 的查询 +- `SortRepository`:拖拽排序 +- `FastRepository`:聚合上述所有能力 + 高级搜索(`PageRequest`/`SearchRequest`) + +## 如何使用 + +**1. 基础使用(推荐 `FastRepository`)** + +```java +public interface UserRepository extends FastRepository { + // 0 行业务代码,直接继承获得所有能力 +} +``` + +**2. 动态列表查询** + +```java +// 方式 1:传 SQL + params +List users = userRepository.dynamicListQuery( + "FROM User u WHERE u.deptId = ?1 AND u.status = ?2", deptId, "ACTIVE"); + +// 方式 2:传 SQLBuilder(链式) +SQLBuilder builder = SQLBuilder.of(User.class) + .where("u.deptId = :dept") + .setParam("dept", deptId); +List users = userRepository.dynamicListQuery(builder); + +// 方式 3:返回 Map +List rows = userRepository.dynamicMapListQuery( + QueryColumns.of("id", "name", "phone"), + "SELECT id, name, phone FROM user WHERE dept_id = ?1", deptId); +``` + +**3. 动态分页** + +```java +PageRequest request = PageRequest.of(0, 20, Sort.by("createTime").descending()); +Page page = userRepository.dynamicPageQuery(builder, request); +``` + +**4. 高级搜索(FastRepository 特有)** + +```java +// 接收前端的 PageRequest(含 filter / sort / page) +Page page = userRepository.findAll(pageRequest); + +// 或 SearchRequest(含 keyword) +Page page = userRepository.searchRequest(searchRequest); +``` + +**5. 拖拽排序** + +```java +// 实体实现 ISort 接口(含 sort 字段) +SortRequest request = new SortRequest(Arrays.asList(10L, 8L, 6L, 4L, 2L)); +userRepository.reSort(request); // 重新分配 sort 值 +``` + +**6. 配合 JpaQueryContext 与 JdbcQueryContext** + +- `DynamicRepository` → `JpaQueryContext.getJpaQuery().listQuery(...)`(基于 HQL) +- `DynamicNativeRepository` → `JdbcQueryContext.getJdbcQuery().queryForList(...)`(基于 JDBC) + +可通过自定义 `JpaQueryConfiguration` / `JdbcQueryConfiguration` 覆盖默认实现。 + +## 使用实例 + +```java +// 1. 业务仓库示例 +public interface OrderRepository extends FastRepository { +} + +// 2. 报表场景:返回 Map 分页 +@GetMapping("/report/orders") +public Page report(OrderQuery query, PageRequest request) { + QueryColumns columns = QueryColumns.of("id", "title", "amount", "status", "createTime"); + String sql = "SELECT id, title, amount, status, create_time FROM order WHERE 1=1 "; + if (query.getStatus() != null) sql += " AND status = ?"; + return orderRepository.dynamicMapPageQuery(columns, sql, countSql, request, query.getStatus()); +} + +// 3. 配合搜索请求体 +public class UserSearchRequest extends SearchRequest { + private String keyword; + private Long deptId; + // getters/setters +} +Page page = userRepository.searchRequest(userSearchRequest); +``` diff --git a/docs/capabilities/springboot-starter-flow/schema-reader.md b/docs/capabilities/springboot-starter-flow/schema-reader.md new file mode 100644 index 000000000..73183b717 --- /dev/null +++ b/docs/capabilities/springboot-starter-flow/schema-reader.md @@ -0,0 +1,114 @@ +--- +name: springboot-starter-flow/schema-reader +module: springboot-starter-flow +description: 流程设计图(Schema)解析器,将 JSON 描述的节点/边转换为 FlowNode/FlowRelation 内存模型 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-flow" +symbols: + - SchemaReader +content_hash: 8c99d36738692cd511d97859cae931de1de84b46d3957b02a48de144cd44f640 +--- + +## 解决什么问题 + +前端流程设计器(flow-pc / flow-mobile)输出的 JSON 包含 `nodes[]` 和 `edges[]` 数组,每个节点/边带有 `properties.code/operatorMatcher/titleGenerator/...` 等配置。业务后端需要把这套 JSON 解析为强类型的 `FlowNode` / `FlowRelation` 对象,并支持: + +- 节点类型识别(`NodeType.parser(type)`:开始/结束/审批/抄送等) +- 审批人策略(`OperatorMatcher`:基于用户/角色/部门/发起人等) +- 标题动态生成(`TitleGenerator`:Groovy 脚本) +- 边上的出方向触发器(`OutTrigger`) +- 错误处理策略(`ErrTrigger`) +- 节点按钮(`FlowButton[]`) + +## 如何使用 + +**1. 解析 Schema JSON** + +```java +String schemaJson = "{...nodes:[...], edges:[...]}"; +SchemaReader reader = new SchemaReader(schemaJson); + +List nodes = reader.getFlowNodes(); +List relations = reader.getFlowRelations(); + +// 与 FlowWork 配合使用(推荐) +FlowWork flow = new FlowWork(currentUser); +flow.schema(schemaJson); // 内部使用 SchemaReader,并自动 verify() +``` + +**2. Schema JSON 格式示例** + +```json +{ + "nodes": [ + { + "properties": { + "id": "node-1", + "code": "start", + "name": "发起", + "type": "START", + "operatorMatcher": "user:#{currentUser}", + "titleGenerator": "return '订单 '+orderNo+' 待发起'", + "editable": true, + "mergeable": false, + "view": "/form/order", + "approvalType": "AUTO", + "timeout": 0, + "errTrigger": "log:error", + "buttons": [ + { "code": "submit", "name": "提交", "style": "primary" } + ] + } + } + ], + "edges": [ + { + "id": "edge-1", + "sourceNodeId": "node-1", + "targetNodeId": "node-2", + "properties": { + "name": "提交", + "outTrigger": "default", + "back": false, + "order": 1 + } + } + ] +} +``` + +**3. 字段说明** + +| 字段 | 含义 | 对应类型 | +|------|------|----------| +| `code` | 节点代码(`start`/`over` 是保留字) | `String` | +| `type` | 节点类型 | `NodeType`(START/OVER/APPROVAL/CC/...) | +| `approvalType` | 审批类型 | `ApprovalType`(AUTO/ANY/ALL/ORDER) | +| `operatorMatcher` | 审批人策略(Groovy) | `OperatorMatcher` | +| `titleGenerator` | 标题脚本(Groovy) | `TitleGenerator` | +| `errTrigger` | 错误处理(可选) | `ErrTrigger` | +| `outTrigger` | 出边触发器(默认 `default`) | `OutTrigger` | +| `back` | 是否可回退 | `boolean` | +| `order` | 边的执行顺序 | `int` | + +## 使用实例 + +```java +// 1. 接收前端设计器提交 +@PostMapping("/flow/save") +public FlowWork save(@RequestBody FlowDesignDTO dto) { + FlowWork flow = new FlowWork(currentUser); + flow.setTitle(dto.getTitle()); + flow.setCode(dto.getCode()); + flow.schema(dto.getSchema()); // 内部自动 verify() + return flowRepository.save(flow); +} + +// 2. 复制流程(同时重新生成节点/边 ID) +FlowWork copy = flow.copy(); // 内部调用 SchemaReader + +// 3. 配合验证逻辑 +flow.verify(); // 校验:节点不为空、关系不为空、title/code 非空、起始/结束节点存在等 +``` diff --git a/docs/capabilities/springboot-starter-script/groovy-script-runtime.md b/docs/capabilities/springboot-starter-script/groovy-script-runtime.md new file mode 100644 index 000000000..5ee431fd4 --- /dev/null +++ b/docs/capabilities/springboot-starter-script/groovy-script-runtime.md @@ -0,0 +1,112 @@ +--- +name: springboot-starter-script/groovy-script-runtime +module: springboot-starter-script +description: 基于 Groovy 的动态脚本运行时,支持函数调用与脚本执行两类入口、LRU 缓存、临时脚本落盘与启动恢复 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-script" +symbols: + - GroovyScript + - GroovyScriptRuntime + - GroovyScriptRuntimeContext + - GroovyScriptCacheContext + - GroovyScriptEngineRunner +content_hash: 6da4f2cb99af82f939e0cf9577a8b1f7ed442aef77e690efe3ba5aad9576123e +--- + +## 解决什么问题 + +业务系统经常需要让运营/管理员在运行时编写/修改业务规则、表单逻辑、按钮响应脚本,传统做法是发版重部署。本能力提供: + +- 运行时 Groovy 脚本解析与执行(`groovy.lang.GroovyShell`) +- 函数调用与脚本执行两种入口 +- LRU 缓存编译结果(SHA-256 作为 key) +- 临时脚本(`TempGroovyScript`)定时清理 + 启动恢复 +- Builder 模式构建 `GroovyScript` 对象,支持元数据(描述/标签/类型) + +## 如何使用 + +**1. 函数式调用(执行有入参出参的方法)** + +```java +Map binds = new HashMap<>(); +binds.put("user", currentUser); + +String result = GroovyScriptRuntimeContext.getInstance().invoke( + "compute", // 方法名 + "def compute(user){ return user.name + '_computed' }", // 脚本 + String.class, // 返回类型 + TransactionMode.DEFAULT, + binds, + currentUser // 方法实参 +); +``` + +**2. 脚本式执行(执行整个脚本体)** + +```java +Map binds = new HashMap<>(); +binds.put("x", 10); + +Integer result = GroovyScriptRuntimeContext.getInstance().run( + "return x * 2", + Integer.class, + TransactionMode.DEFAULT, + binds +); +``` + +**3. 通过 GroovyScript 对象管理** + +```java +GroovyScript script = GroovyScript.builder("order-discount") + .script("return amount > 1000 ? amount * 0.9 : amount") + .description("订单满 1000 打 9 折") + .method("compute") + .returnType(BigDecimal.class) + .build(); + +script.save(); // 持久化到 repository +script.compile(); // 预编译并缓存 +BigDecimal result = script.run(Map.of("amount", new BigDecimal("1500"))); +``` + +**4. 临时脚本(重启后自动落盘恢复)** + +```java +GroovyScript tempScript = GroovyScript.builder("test-temp").script("...").build(); +tempScript.temp(); // 写入 TempGroovyScriptContext,应用启动时由 GroovyScriptEngineRunner 加载 +``` + +**5. 配置缓存大小** + +```yaml +codingapi: + script: + shell-max-cache-size: 1000 # 编译缓存上限(默认通过 PropertiesContext 读取) +``` + +## 使用实例 + +```java +// 1. 业务规则动态化(按钮脚本) +@RestController +public class DynamicButtonController { + @PostMapping("/button/execute") + public Object execute(@RequestParam String scriptKey, @RequestBody Map params) { + GroovyScript script = GroovyScriptCacheContext.getInstance().getGroovyScript(scriptKey); + if (script == null) { + throw new IllegalArgumentException("script not found: " + scriptKey); + } + return script.run(params); + } +} + +// 2. 临时调试脚本(开发期) +// 在不重启服务的前提下,业务人员可注入测试脚本 +// 重启后 TempGroovyScriptEngineRunner 会把内存中的 temp 脚本批量落盘到 DB + +// 3. 配合 springboot-starter-data-fast 的 ScriptMapping 使用: +// ScriptMapping 注册脚本 key 与业务模块的映射关系,ScriptRuntime 解析并执行 +``` diff --git a/docs/capabilities/springboot-starter-security/token-gateway.md b/docs/capabilities/springboot-starter-security/token-gateway.md new file mode 100644 index 000000000..f5fb25d48 --- /dev/null +++ b/docs/capabilities/springboot-starter-security/token-gateway.md @@ -0,0 +1,137 @@ +--- +name: springboot-starter-security/token-gateway +module: springboot-starter-security +description: 无状态 JWT 与有状态 Redis 双模 Token 网关,统一 Token 生命周期、过期/续期与权限注入 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-security" +symbols: + - Token + - TokenContext + - TokenGateway + - RedisTokenGateway +content_hash: 36e7a0811ec5e56eb2b6b288446d5330ed912b99789cde58a560ccc3819d2afc +--- + +## 解决什么问题 + +权限系统面临两种典型部署: + +- **无状态(JWT)**:适合分布式/移动端,无需服务端存储,但难以即时吊销 +- **有状态(Redis)**:服务端可即时吊销/踢人,但需要共享存储 + +本能力提供 `TokenGateway` 抽象,内置两种实现(`JwtTokenGateway` / `RedisTokenGateway`),业务代码无需关心底层是 JWT 还是 Redis,统一通过 `TokenContext.current()` 拿到当前用户。 + +`Token` 对象本身承载: +- 用户名(`username`) +- 权限列表(`authorities`) +- IV/extra 加密字段 +- 过期时间(`expireTime`)/ 续期时间(`remindTime`) +- 是否可以续期(`canRestToken()`:未过期 + 已到续期点) + +## 如何使用 + +**1. 配置(选其一)** + +```yaml +# 启用 Redis 有状态 Token +spring: + data: + redis: + host: localhost + port: 6379 + +codingapi: + security: + token: + type: redis # 或 jwt + redis: + valid-time: 3600000 # 1 小时 + rest-time: 1800000 # 30 分钟前可续 + jwt: + secret: your-256-bit-secret + expire-time: 7200 +``` + +**2. 创建 Token** + +```java +TokenGateway tokenGateway; // 框架自动注入 +List authorities = List.of("ROLE_USER", "ORDER:READ"); + +Token token = tokenGateway.create("alice", "iv-encrypted", authorities, "extra-json"); +String sign = token.getToken(); // 客户端后续请求携带 +``` + +**3. 解析当前 Token** + +```java +@RestController +public class UserController { + @GetMapping("/me") + public User me() { + Token current = TokenContext.current(); + return userService.findByUsername(current.getUsername()); + } +} +``` + +**4. 检查过期与续期** + +```java +Token token = tokenGateway.parser(sign); +token.verify(); // 过期时抛 TokenExpiredException + +if (token.canRestToken()) { + tokenGateway.resetToken(token); // Redis 实现:续期;JWT 实现:需重新签发 +} +``` + +**5. 按用户名踢人(仅 Redis)** + +```java +redisTokenGateway.removeUsername("alice"); // 删除该用户所有 token +// 或自定义过滤条件 +redisTokenGateway.removeUsername("alice", t -> t.getAuthorities().contains("ROLE_GUEST")); +``` + +**6. 与 Spring Security 集成** + +`Token.getAuthenticationToken()` 返回 `UsernamePasswordAuthenticationToken`,由 `MyAuthenticationFilter` 写入 `SecurityContextHolder`,因此 `TokenContext.current()` 实际就是从 `SecurityContext` 中取出 `Token` 对象。 + +## 使用实例 + +```java +// 1. 登录过滤器创建 Token +public class MyLoginFilter extends UsernamePasswordAuthenticationFilter { + @Override + protected void successfulAuthentication(...) { + UserDetails user = (UserDetails) authResult.getPrincipal(); + Token token = tokenGateway.create( + user.getUsername(), + loginRequest.getPassword(), // 用密码当 IV + user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(), + TokenContext.getExtra() + ); + // 返回 token 给客户端 + } +} + +// 2. 业务接口读取当前用户 +@GetMapping("/api/orders") +public List myOrders() { + Token token = TokenContext.current(); + String username = token.getUsername(); + List auths = token.getAuthorities(); // 包含 ROLE_* + return orderService.findByUser(username, auths); +} + +// 3. 解析 extra 字段(前端传入的加密数据) +@PostMapping("/api/decrypt") +public DecryptedData decrypt() { + Token token = TokenContext.current(); + MyExtra extra = token.parseExtra(MyExtra.class); // fastjson 反序列化 + return new DecryptedData(token.decodeIv(), extra); +} +``` diff --git a/docs/capabilities/springboot-starter/domain-event.md b/docs/capabilities/springboot-starter/domain-event.md new file mode 100644 index 000000000..86d12e128 --- /dev/null +++ b/docs/capabilities/springboot-starter/domain-event.md @@ -0,0 +1,112 @@ +--- +name: springboot-starter/domain-event +module: springboot-starter +description: 领域事件总线(同步/异步/事务后),提供事件栈追踪、循环事件检测与全局 Handler 调度 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - DomainEvent + - EventPusher + - DomainEventContext + - EventStackContext + - EventTraceContext + - SpringEventHandler + - SpringDefaultEventHandler + - SpringTransactionEventHandler + - HandlerBeanDefinitionRegistrar + - SpringEventConfiguration + - SpringHandlerConfiguration +content_hash: c6e83b55164a8e318f7ea08470136960bc1b4a97ef0f3e87cbe913b14128869a +--- + +## 解决什么问题 + +在 DDD 场景下,领域实体产生的业务事件(如创建、修改、删除)需要在不同模块/层级间可靠传播,且需要: + +- 同步/异步两种触发模式 +- 事务提交后再触发(避免回滚后副作用) +- 自动追踪事件链(traceId),并检测事件循环 +- 集中收集所有 `IHandler` 进行统一调度 +- 可在 `FrameworkProperties` 中配置线程池大小 + +## 如何使用 + +**1. 业务事件定义** + +```java +// 同步事件 +public class OrderPaidEvent implements ISyncEvent { + private final Order order; + public OrderPaidEvent(Order order) { this.order = order; } + public Order getOrder() { return order; } +} + +// 异步事件 +public class OrderShippedEvent implements IAsyncEvent { ... } +``` + +**2. 发布事件** + +```java +EventPusher.push(new OrderPaidEvent(order)); +// 框架根据事件是否实现 ISyncEvent/IAsyncEvent 自动选择同步或异步 +// 也可直接传 IEvent,由 push 内部按接口类型分发 +``` + +**3. 订阅事件(实现 IHandler)** + +```java +@Component +public class OrderPaidHandler implements IHandler { + @Override + public Class getEventClass() { return OrderPaidEvent.class; } + + @Override + public void handler(OrderPaidEvent event) { + // 处理逻辑 + } +} +``` + +**4. 启用事务后事件(可选)** + +```yaml +codingapi: + framework: + event: + transaction: + enable: true # 启用 SpringTransactionEventHandler(在 @Transactional commit 后触发) +``` + +**5. 线程池配置** + +```yaml +codingapi: + framework: + handler-thread-pool-size: 10 +``` + +## 使用实例 + +```java +// 1. 实体创建时自动触发 DomainCreateEvent(与 DomainProxyFactory 配合使用) +T entity = DomainProxyFactory.create(MyEntity.class, "arg"); + +// 2. 业务模块内手动发布 +EventPusher.push(new OrderCancelledEvent(orderId)); + +// 3. 事务提交后再异步处理 +@Transactional +public void completeOrder(Long orderId) { + orderRepository.update(orderId, OrderStatus.PAID); + EventPusher.push(new OrderCompletedEvent(orderId)); + // 因配置 codingapi.framework.event.transaction.enable=true, + // handler 将在 commit 后触发 +} + +// 4. 错误处理:循环事件自动抛 EventLoopException +// EventStackContext 检测到同一事件类在同一 traceId 中重复出现时 +// 会清理 trace 状态并抛出异常,避免无限递归 +``` diff --git a/docs/capabilities/springboot-starter/domain-proxy.md b/docs/capabilities/springboot-starter/domain-proxy.md new file mode 100644 index 000000000..56de94153 --- /dev/null +++ b/docs/capabilities/springboot-starter/domain-proxy.md @@ -0,0 +1,79 @@ +--- +name: springboot-starter/domain-proxy +module: springboot-starter +description: 基于 CGLIB 的实体代理工厂,在创建时发布 DomainCreateEvent,在 setter 触发时对比字段差异并发布 DomainChangeEvent +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - DomainProxyFactory + - DomainChangeInterceptor +content_hash: 454f65c620ab3f109fa77bc45e84b5926cb90a12a0c73f0dbffc52124879d538 +--- + +## 解决什么问题 + +在 DDD 实践中,实体字段变更需要自动触发领域事件(`DomainChangeEvent`)以便: + +- 审计日志(谁在何时改了什么字段) +- 联动业务(金额变更触发风控、状态变更触发通知) +- 事件溯源(Event Sourcing) + +手动在每个 setter 中编写事件发布代码会污染业务代码。本能力通过 CGLIB 动态代理在 setter 调用前后自动对比字段差异,无侵入地发布变更事件。 + +## 如何使用 + +**1. 工厂创建实体(推荐)** + +```java +// 触发顺序:构造 → 创建 DomainCreateEvent → setter 触发 → 对比字段 → 发布 DomainChangeEvent +User user = DomainProxyFactory.create(User.class, "alice", 25); +user.setName("bob"); // 自动发布 DomainChangeEvent("name", "alice", "bob") +``` + +**2. 事件订阅** + +```java +@Component +public class UserChangeLogHandler implements IHandler { + @Override + public Class getEventClass() { return DomainChangeEvent.class; } + + @Override + public void handler(DomainChangeEvent event) { + // event.getEntity() / getFieldName() / getOldValue() / getNewValue() + log.info("entity={} field={} {} -> {}", + event.getEntity().getClass().getSimpleName(), + event.getFieldName(), + event.getOldValue(), event.getNewValue()); + } +} +``` + +**3. 支持嵌套对象字段** + +`DomainChangeInterceptor.readFields` 递归读取对象属性,对嵌套对象的字段变更会以 `parent.child` 形式发布(参见 `compareAndUpdateField` 方法)。 + +**4. 适用范围** + +- 仅当方法有参数时(>0)才走对比逻辑 +- 支持 8 种基本类型 + String + Enum + Class 的值比较 +- 不支持集合类型(List/Map)的内部元素对比 + +## 使用实例 + +```java +// 1. 基本用法 +Order order = DomainProxyFactory.create(Order.class, 1001L, "PENDING"); +order.setStatus("PAID"); +// 自动发布 DomainChangeEvent(target=order, fieldName="status", oldValue="PENDING", newValue="PAID") + +// 2. 嵌套对象字段变更 +User user = DomainProxyFactory.create(User.class, "alice", new Profile("Beijing")); +user.getProfile().setCity("Shanghai"); +// 发布 DomainChangeEvent(fieldName="profile.city", oldValue="Beijing", newValue="Shanghai") + +// 3. 配合 EventPusher 与事务事件 +// 配合 codingapi.framework.event.transaction.enable=true 可在事务提交后落库审计日志 +``` diff --git a/docs/capabilities/springboot/spring-boot.md b/docs/capabilities/springboot/spring-boot.md new file mode 100644 index 000000000..b14243100 --- /dev/null +++ b/docs/capabilities/springboot/spring-boot.md @@ -0,0 +1,125 @@ +--- +name: springboot/spring-boot +module: springboot +description: Spring Boot 2.7 基础框架,提供 IoC 容器、AOP、事件发布、配置自动装配等核心能力 +status: 已实现 +scope: 后端 +source: 框架:springboot +import: "org.springframework.boot:spring-boot-starter-parent" +framework_version: 2.7.18 +--- + +## 解决什么问题 + +springboot-framework 整体运行在 Spring Boot 容器之上。本能力为上层各 starter 模块提供: + +- **IoC 容器**(`ApplicationContext`):管理 `IHandler`/`RowHandler`/`ColumnHandler` 等扩展点的自动注入 +- **AOP**(`spring-aop` + `cglib`):支撑 `DomainChangeInterceptor`(CGLIB 动态代理)和 `DomainProxyFactory`(实体的方法拦截) +- **事件发布**(`ApplicationEventPublisher`):本框架的领域事件(`DomainEvent`)直接继承自 `org.springframework.context.ApplicationEvent` +- **配置自动装配**(`@Configuration` + `spring.factories` / `AutoConfiguration.imports`):每个 starter 模块通过 `AutoConfiguration` 类自动注册 Bean +- **事务管理**(`@Transactional` + `TransactionPhase.AFTER_COMMIT`):支撑 `SpringTransactionEventHandler` 在事务提交后再触发 handler +- **JDBC 数据源**(`DataSource`):支撑 `springboot-starter-data-authorization` 的自定义 JDBC 驱动 + +## 如何使用 + +**1. 引入依赖** + +所有 starter 模块的 `pom.xml` 都已继承 `spring-boot-starter-parent:2.7.18`,业务方无需重复声明版本: + +```xml + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + +``` + +**2. 自动装配入口** + +每个 starter 模块提供 `AutoConfiguration` 类,被 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册: + +``` +com.codingapi.springboot.framework.AutoConfiguration +com.codingapi.springboot.script.AutoConfiguration +com.codingapi.springboot.security.AutoConfiguration +com.codingapi.springboot.fast.DataFastConfiguration +com.codingapi.springboot.flow.FlowConfiguration +com.codingapi.springboot.authorization.DataAuthorizationConfiguration +``` + +**3. 常用扩展点(本框架对 Spring Boot 的封装)** + +| Spring 抽象 | 本框架封装 | 作用 | +|------------|-----------|------| +| `ApplicationContext.publishEvent()` | `EventPusher.push(IEvent)` | 同步/异步领域事件分发 | +| `@EventListener` | `IHandler` | 类型安全的事件处理 | +| `BeanPostProcessor` | `HandlerBeanDefinitionRegistrar` | 扫描 `Handler` 注解自动注册 | +| `JdbcTemplate` / `DataSource` | `AuthorizationJdbcDriver` | 数据权限驱动 | +| `TransactionSynchronization` | `SpringTransactionEventHandler` | 事务后事件 | + +**4. 配置项前缀** + +| 前缀 | 含义 | 模块 | +|------|------|------| +| `codingapi.framework.*` | 框架主配置(线程池、事务事件开关) | springboot-starter | +| `codingapi.script.*` | 脚本引擎配置(缓存大小、临时脚本) | springboot-starter-script | +| `codingapi.security.*` | 安全/Token 配置 | springboot-starter-security | +| `codingapi.authorization.*` | 数据权限配置 | springboot-starter-data-authorization | + +**5. 与 starter 模块的关系** + +``` +spring-boot-starter (基础) + ├── springboot-starter (DDD 领域框架) + │ ├── springboot-starter-script + │ │ ├── springboot-starter-data-fast + │ │ └── springboot-starter-flow + │ ├── springboot-starter-security + │ └── springboot-starter-data-authorization +``` + +业务方按需引入 starter 即可获得对应能力,无需关心 Spring Boot 底层细节。 + +## 使用实例 + +```java +// 1. 业务方应用:仅引入需要的 starter +@SpringBootApplication +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} + +// 2. application.yml +spring: + application: + name: my-order-service + datasource: + url: jdbc:mysql://localhost:3306/order + jpa: + hibernate: + ddl-auto: update + +codingapi: + framework: + event: + transaction: + enable: true + authorization: + show-sql: true +``` + +```xml + + + + com.codingapi.springboot + springboot-starter + + + com.codingapi.springboot + springboot-starter-data-authorization + + +``` diff --git a/docs/conventions/index.md b/docs/conventions/index.md new file mode 100644 index 000000000..04276e5b4 --- /dev/null +++ b/docs/conventions/index.md @@ -0,0 +1,6 @@ +# 规范(Conventions) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +_暂无已注册的规范文档。_ + diff --git a/pom.xml b/pom.xml index dfdfcabc1..1048c798e 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,7 @@ com.codingapi.springboot springboot-parent - 2.10.53 + 2.10.55 https://github.com/codingapi/springboot-framewrok springboot-parent diff --git a/springboot-starter-data-authorization/pom.xml b/springboot-starter-data-authorization/pom.xml index c914d90d1..b66c54990 100644 --- a/springboot-starter-data-authorization/pom.xml +++ b/springboot-starter-data-authorization/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 2.10.53 + 2.10.55 springboot-starter-data-authorization diff --git a/springboot-starter-data-fast/pom.xml b/springboot-starter-data-fast/pom.xml index bfef311b5..1c5de9a5d 100644 --- a/springboot-starter-data-fast/pom.xml +++ b/springboot-starter-data-fast/pom.xml @@ -5,7 +5,7 @@ springboot-parent com.codingapi.springboot - 2.10.53 + 2.10.55 4.0.0 diff --git a/springboot-starter-flow/pom.xml b/springboot-starter-flow/pom.xml index adaeeb9ad..2f6123fb9 100644 --- a/springboot-starter-flow/pom.xml +++ b/springboot-starter-flow/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 2.10.53 + 2.10.55 springboot-starter-flow diff --git a/springboot-starter-script/pom.xml b/springboot-starter-script/pom.xml index 0957b26c0..6e96c3b2a 100644 --- a/springboot-starter-script/pom.xml +++ b/springboot-starter-script/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 2.10.53 + 2.10.55 springboot-starter-script diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/scanner/GroovyMetadataScannerUtils.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/scanner/GroovyMetadataScannerUtils.java index 06874df0c..f1466c55b 100644 --- a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/scanner/GroovyMetadataScannerUtils.java +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/scanner/GroovyMetadataScannerUtils.java @@ -1,5 +1,6 @@ package com.codingapi.springboot.script.scanner; +import com.codingapi.springboot.framework.reflect.ObjectAnnotationFieldUtils; import com.codingapi.springboot.script.GroovyScript; import com.codingapi.springboot.script.annotation.ScriptField; import com.codingapi.springboot.script.annotation.ScriptFunction; @@ -284,18 +285,6 @@ private void loadMethods() { }); } - /** - * 是否简单数据结构 - */ - private boolean isSimpleType() { - return this.clazz.isPrimitive() - || ClassUtils.isPrimitiveOrWrapper(this.clazz) - || this.clazz == String.class - || this.clazz.isEnum() - || Number.class.isAssignableFrom(this.clazz) - || CharSequence.class.isAssignableFrom(this.clazz); - } - /** * 扫描类对象 @@ -308,7 +297,7 @@ public void scanner() { } // 简单数据类型不处理 - if (this.isSimpleType()) { + if (ObjectAnnotationFieldUtils.isSimpleType(this.clazz)) { return; } diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java index 8ccb2cc7c..4b3c447a6 100644 --- a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java @@ -5,7 +5,13 @@ import com.codingapi.springboot.script.repository.TempGroovyScriptRepositoryContext; import lombok.Getter; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -16,10 +22,20 @@ public class TempGroovyScriptContext { @Getter private final static TempGroovyScriptContext instance = new TempGroovyScriptContext(); + /** + * 共享的清理调度线程(daemon) + * 所有临时脚本共用一个调度线程,避免每个脚本创建原生线程导致线程泄漏 + */ + private final static ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "temp-groovy-script-clear"); + thread.setDaemon(true); + return thread; + }); + private final Map cache; private TempGroovyScriptContext() { - this.cache = new HashMap<>(); + this.cache = new ConcurrentHashMap<>(); } private static class ClearJob { @@ -27,30 +43,37 @@ private static class ClearJob { @Getter private final TempGroovyScript tempGroovyScript; - private final Timer timer; + private final ScheduledFuture future; public ClearJob(TempGroovyScript groovyScript) { this.tempGroovyScript = groovyScript; - this.timer = new Timer(); - this.initTimer(); + // clearTime 为绝对到期时间戳,需换算为相对延迟 + long delay = groovyScript.getClearTime() - System.currentTimeMillis(); + this.future = SCHEDULER.schedule(this::expire, delay, TimeUnit.MILLISECONDS); } public GroovyScript getGroovyScript() { return this.tempGroovyScript.getGroovyScript(); } - private void initTimer() { - this.timer.schedule(new TimerTask() { - @Override - public void run() { - TempGroovyScriptContext.getInstance().remove(getKey()); - } - }, tempGroovyScript.getClearTime()); - } - public String getKey() { return tempGroovyScript.getKey(); } + + /** + * 到期清理 + * 仅当缓存中仍是当前任务时才删除,避免过期任务误删已被刷新的脚本 + */ + private void expire() { + TempGroovyScriptContext.getInstance().removeIfCurrent(getKey(), this); + } + + /** + * 取消定时清理任务 + */ + private void cancel() { + this.future.cancel(false); + } } /** @@ -61,7 +84,17 @@ public String getKey() { public void save(GroovyScript script) { if (script != null) { long tempValidTime = PropertiesContext.getInstance().getTempValidTime(); - this.cache.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis()))); + this.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis()))); + } + } + + /** + * 写入缓存,覆盖时取消旧任务的定时清理 + */ + private void put(String key, ClearJob job) { + ClearJob previous = this.cache.put(key, job); + if (previous != null) { + previous.cancel(); } } @@ -74,7 +107,7 @@ public void loadAll(List groovyScripts) { if (groovyScript.isExpired()) { this.remove(groovyScript.getKey()); } else { - this.cache.put(groovyScript.getKey(), new ClearJob(groovyScript)); + this.put(groovyScript.getKey(), new ClearJob(groovyScript)); } } } @@ -94,10 +127,23 @@ public List findAll() { * @param key 脚本key */ public void remove(String key) { - this.cache.remove(key); + ClearJob job = this.cache.remove(key); + if (job != null) { + job.cancel(); + } TempGroovyScriptRepositoryContext.getInstance().delete(key); } + /** + * 仅当缓存中的任务仍为当前任务时删除,防止过期任务误删已被刷新的脚本。 + * 使用 ConcurrentHashMap.remove(key, value) 原子判断 + 删除,避免 check-then-act 竞态 + */ + private void removeIfCurrent(String key, ClearJob job) { + if (this.cache.remove(key, job)) { + job.cancel(); + TempGroovyScriptRepositoryContext.getInstance().delete(key); + } + } /** * 脚本总数量 @@ -119,7 +165,11 @@ public GroovyScript getGroovyScript(String key) { if (job == null) { TempGroovyScript groovyScript = TempGroovyScriptRepositoryContext.getInstance().get(key); if (groovyScript != null) { - this.cache.put(key, new ClearJob(groovyScript)); + if (groovyScript.isExpired()) { + this.remove(key); + return null; + } + this.put(key, new ClearJob(groovyScript)); return groovyScript.getGroovyScript(); } return null; @@ -132,6 +182,9 @@ public GroovyScript getGroovyScript(String key) { * 清空脚本数据 */ public void clear() { + for (ClearJob job : this.cache.values()) { + job.cancel(); + } this.cache.clear(); } -} +} \ No newline at end of file diff --git a/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java new file mode 100644 index 000000000..d7fbd7c92 --- /dev/null +++ b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java @@ -0,0 +1,84 @@ +package com.codingapi.springboot.script.temp; + +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.properties.GroovyScriptProperties; +import com.codingapi.springboot.script.properties.PropertiesContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 回归测试:临时脚本不得为每个脚本创建原生线程(OOM 根因), + * 且脚本到期后应被自动清理。 + */ +class TempGroovyScriptContextTest { + + private TempGroovyScriptContext context; + + @BeforeEach + void setUp() { + context = TempGroovyScriptContext.getInstance(); + // 清理上一用例残留的定时任务 + context.clear(); + // 预热共享调度线程,避免首个任务创建线程影响基线统计 + context.save(script("__warmup__")); + context.remove("__warmup__"); + } + + @AfterEach + void tearDown() { + // 清理定时任务并恢复默认配置,避免泄漏到同 JVM 的其他测试 + context.clear(); + PropertiesContext.getInstance().setProperties(new GroovyScriptProperties()); + } + + private GroovyScript script(String key) { + return GroovyScript.builder(key).script("return 1;").build(); + } + + @Test + void saveManyScriptsShouldNotCreateThreads() { + int threadsBefore = Thread.getAllStackTraces().size(); + + for (int i = 0; i < 200; i++) { + context.save(script("key-" + i)); + } + + assertEquals(200, context.count()); + assertEquals(threadsBefore, Thread.getAllStackTraces().size(), + "注册 200 个临时脚本不应创建任何新线程"); + } + + @Test + void overwriteRemoveClearShouldNotCreateThreads() { + int threadsBefore = Thread.getAllStackTraces().size(); + + for (int i = 0; i < 100; i++) { + context.save(script("key-" + i)); + context.save(script("key-" + i)); // 覆盖刷新 + } + for (int i = 0; i < 100; i++) { + context.remove("key-" + i); + } + context.clear(); + + assertEquals(0, context.count()); + assertEquals(threadsBefore, Thread.getAllStackTraces().size(), + "覆盖/删除/清空不应创建新线程"); + } + + @Test + void expiredScriptShouldBeRemovedAutomatically() throws InterruptedException { + GroovyScriptProperties properties = new GroovyScriptProperties(); + properties.setTempValidTime(300); + PropertiesContext.getInstance().setProperties(properties); + + context.save(script("expire-key")); + assertEquals(1, context.count()); + + Thread.sleep(1500); + assertEquals(0, context.count(), "脚本到期后应被自动清理"); + } +} \ No newline at end of file diff --git a/springboot-starter-security/pom.xml b/springboot-starter-security/pom.xml index 0c69a141a..50b0e24b6 100644 --- a/springboot-starter-security/pom.xml +++ b/springboot-starter-security/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 2.10.53 + 2.10.55 springboot-starter-security diff --git a/springboot-starter/pom.xml b/springboot-starter/pom.xml index 1761b9067..84cadab44 100644 --- a/springboot-starter/pom.xml +++ b/springboot-starter/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot springboot-parent - 2.10.53 + 2.10.55 springboot-starter diff --git a/springboot-starter/src/main/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtils.java b/springboot-starter/src/main/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtils.java index 1fa3c17a0..dae3dbcf4 100644 --- a/springboot-starter/src/main/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtils.java +++ b/springboot-starter/src/main/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtils.java @@ -31,6 +31,19 @@ public static AnnotationTargetFieldResult findFieldAnnotationValue(Object } + /** + * 是否简单数据结构 + * @param fieldType 字段类型 + */ + public static boolean isSimpleType(Class fieldType) { + return fieldType.isPrimitive() + || ClassUtils.isPrimitiveOrWrapper(fieldType) + || fieldType == String.class + || fieldType.isEnum() + || Number.class.isAssignableFrom(fieldType) + || CharSequence.class.isAssignableFrom(fieldType); + } + /** * 注解目标字段持有对象 * @param 字段类型 @@ -131,6 +144,9 @@ public ObjectAnnotationScanner(Object target, AnnotationTargetFieldHolder val * 扫描对象 */ public void scanner() { + if(ObjectAnnotationFieldUtils.isSimpleType(this.targetClass)) { + return; + } if (this.target instanceof Collection) { for (Object item : (Collection) this.target) { ObjectAnnotationScanner scanner = new ObjectAnnotationScanner<>(item, this.valueHolder); @@ -164,7 +180,7 @@ private void scannerFields() { Object value = ReflectionUtils.getField(field, target); Class fieldTypeClass = field.getType(); if (value != null) { - if (isSimpleType(fieldTypeClass)) { + if (ObjectAnnotationFieldUtils.isSimpleType(fieldTypeClass)) { Annotation annotation = field.getAnnotation(this.valueHolder.getAnnotationClass()); if (annotation != null) { this.valueHolder.addFiled(field, target, (T) value); @@ -176,18 +192,5 @@ private void scannerFields() { } }); } - - /** - * 是否简单数据结构 - * @param fieldType 字段类型 - */ - private boolean isSimpleType(Class fieldType) { - return fieldType.isPrimitive() - || ClassUtils.isPrimitiveOrWrapper(fieldType) - || fieldType == String.class - || fieldType.isEnum() - || Number.class.isAssignableFrom(fieldType) - || CharSequence.class.isAssignableFrom(fieldType); - } } } diff --git a/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/FieldAttribute.java b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/FieldAttribute.java new file mode 100644 index 000000000..d8ac2d020 --- /dev/null +++ b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/FieldAttribute.java @@ -0,0 +1,17 @@ +package com.codingapi.springboot.framework.reflect; + +import lombok.Data; + +/** + * 附加属性 + */ +@Data +public class FieldAttribute { + // 属性key + @MyScript + private String key; + // 属性名称 + private String label; + // 属性值 + private Object value; +} diff --git a/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/MyScript.java b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/MyScript.java new file mode 100644 index 000000000..0506e2800 --- /dev/null +++ b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/MyScript.java @@ -0,0 +1,9 @@ +package com.codingapi.springboot.framework.reflect; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MyScript { +} diff --git a/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtilsTest.java b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtilsTest.java new file mode 100644 index 000000000..c40e2b812 --- /dev/null +++ b/springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtilsTest.java @@ -0,0 +1,27 @@ +package com.codingapi.springboot.framework.reflect; + +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ObjectAnnotationFieldUtilsTest { + + @Test + void findFieldAnnotationValue() { + String attribute2 = " {\"key\":\"123\",\"label\":\"123\",\"value\":[ {\"data\":\"123\"}, {\"data\":\"123\"}]} "; + + + FieldAttribute attributeObj1 = new FieldAttribute(); + FieldAttribute attributeObj2 = JSONObject.parseObject(attribute2,FieldAttribute.class); + + + List keys1 = ObjectAnnotationFieldUtils.findFieldAnnotationValue(attributeObj1,MyScript.class, String.class).getValues(); + assertEquals(0,keys1.size()); + + List keys2 = ObjectAnnotationFieldUtils.findFieldAnnotationValue(attributeObj2,MyScript.class, String.class).getValues(); + assertEquals(1,keys2.size()); + } +} \ No newline at end of file