From 9f4a6e5adad93a67822aa170c60e426f229ee64b Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Mon, 1 Jun 2026 17:58:50 +0800
Subject: [PATCH 1/4] update version
---
pom.xml | 2 +-
springboot-starter-data-authorization/pom.xml | 2 +-
springboot-starter-data-fast/pom.xml | 2 +-
springboot-starter-flow/pom.xml | 2 +-
springboot-starter-script/pom.xml | 2 +-
.../scanner/GroovyMetadataScannerUtils.java | 15 ++-------
springboot-starter-security/pom.xml | 2 +-
springboot-starter/pom.xml | 2 +-
.../reflect/ObjectAnnotationFieldUtils.java | 31 ++++++++++---------
.../framework/reflect/FieldAttribute.java | 17 ++++++++++
.../framework/reflect/MyScript.java | 9 ++++++
.../ObjectAnnotationFieldUtilsTest.java | 27 ++++++++++++++++
12 files changed, 79 insertions(+), 34 deletions(-)
create mode 100644 springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/FieldAttribute.java
create mode 100644 springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/MyScript.java
create mode 100644 springboot-starter/src/test/java/com/codingapi/springboot/framework/reflect/ObjectAnnotationFieldUtilsTest.java
diff --git a/pom.xml b/pom.xml
index dfdfcabc..06fa73a7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
com.codingapi.springboot
springboot-parent
- 2.10.53
+ 2.10.54
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 c914d90d..5ac2c8da 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.54
springboot-starter-data-authorization
diff --git a/springboot-starter-data-fast/pom.xml b/springboot-starter-data-fast/pom.xml
index bfef311b..28fc23f3 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.54
4.0.0
diff --git a/springboot-starter-flow/pom.xml b/springboot-starter-flow/pom.xml
index adaeeb9a..1e8dce5f 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.54
springboot-starter-flow
diff --git a/springboot-starter-script/pom.xml b/springboot-starter-script/pom.xml
index 0957b26c..800c2eab 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.54
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 06874df0..f1466c55 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-security/pom.xml b/springboot-starter-security/pom.xml
index 0c69a141..9bec5645 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.54
springboot-starter-security
diff --git a/springboot-starter/pom.xml b/springboot-starter/pom.xml
index 1761b906..41d5f931 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.54
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 1fa3c17a..dae3dbcf 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 00000000..d8ac2d02
--- /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 00000000..0506e280
--- /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 00000000..c40e2b81
--- /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
From 66ab7005a554e7cde520607e89990c9a5edd1ff3 Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Tue, 9 Jun 2026 14:55:03 +0800
Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E8=84=9A=E6=9C=AC=E8=BF=90=E8=A1=8C?=
=?UTF-8?q?=E6=97=B6=E6=98=BE=E5=BC=8F=E6=B3=9B=E5=9E=8B=20+=20=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E5=AE=9E=E4=BD=93=20javax.persistence=20=E9=80=82?=
=?UTF-8?q?=E9=85=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GroovyScriptRuntime.cache 显式声明 LinkedHashMap
- MyTest 持久化注解由 jakarta.persistence 改回 javax.persistence(适配 Spring Boot 2.7)
- 新增 CLAUDE.md(项目级 Claude 协作规则,含 PKR 知识查阅约束)
- 新增 docs/capabilities/(9 篇能力文档,覆盖 DDD 事件、领域代理、
Groovy 脚本、JDBC/SQL 数据权限、JPA 仓储、流程 Schema、Token 网关、Spring Boot)
- 新增 docs/conventions/index.md(占位索引)
- .gitignore 增加 .claude/ 与 .codegraph/ 忽略项
Co-Authored-By: Claude Opus 4.8
---
.gitignore | 4 +
CLAUDE.md | 38 +++++
docs/capabilities/index.md | 21 +++
.../jdbc-proxy.md | 92 ++++++++++++
.../sql-interceptor.md | 122 ++++++++++++++++
.../jpa-repository.md | 120 +++++++++++++++
.../springboot-starter-flow/schema-reader.md | 114 +++++++++++++++
.../groovy-script-runtime.md | 112 ++++++++++++++
.../token-gateway.md | 137 ++++++++++++++++++
.../springboot-starter/domain-event.md | 112 ++++++++++++++
.../springboot-starter/domain-proxy.md | 79 ++++++++++
docs/capabilities/springboot/spring-boot.md | 125 ++++++++++++++++
docs/conventions/index.md | 6 +
13 files changed, 1082 insertions(+)
create mode 100644 CLAUDE.md
create mode 100644 docs/capabilities/index.md
create mode 100644 docs/capabilities/springboot-starter-data-authorization/jdbc-proxy.md
create mode 100644 docs/capabilities/springboot-starter-data-authorization/sql-interceptor.md
create mode 100644 docs/capabilities/springboot-starter-data-fast/jpa-repository.md
create mode 100644 docs/capabilities/springboot-starter-flow/schema-reader.md
create mode 100644 docs/capabilities/springboot-starter-script/groovy-script-runtime.md
create mode 100644 docs/capabilities/springboot-starter-security/token-gateway.md
create mode 100644 docs/capabilities/springboot-starter/domain-event.md
create mode 100644 docs/capabilities/springboot-starter/domain-proxy.md
create mode 100644 docs/capabilities/springboot/spring-boot.md
create mode 100644 docs/conventions/index.md
diff --git a/.gitignore b/.gitignore
index 9d3ebbe1..109a1d6f 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 00000000..859eb28a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,38 @@
+# 项目开发指引
+
+
+## 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 00000000..0739ebd0
--- /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 00000000..8726c833
--- /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 00000000..a4b64c83
--- /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 00000000..73c2db0a
--- /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 00000000..73183b71
--- /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 00000000..5ee431fd
--- /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 00000000..f5fb25d4
--- /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 00000000..86d12e12
--- /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 00000000..56de9415
--- /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 00000000..b1424310
--- /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 00000000..04276e5b
--- /dev/null
+++ b/docs/conventions/index.md
@@ -0,0 +1,6 @@
+# 规范(Conventions)
+
+> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。
+
+_暂无已注册的规范文档。_
+
From d18185ca5458177cad497cb874cb18997ae23de8 Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Tue, 9 Jun 2026 15:05:32 +0800
Subject: [PATCH 3/4] =?UTF-8?q?docs(CLAUDE.md):=20=E8=A1=A5=E5=85=A8?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=9E=B6=E6=9E=84=E3=80=81=E6=A8=A1=E5=9D=97?=
=?UTF-8?q?=E6=8B=93=E6=89=91=E3=80=81=E5=B8=B8=E7=94=A8=E5=91=BD=E4=BB=A4?=
=?UTF-8?q?=E4=B8=8E=E5=85=B3=E9=94=AE=E5=86=B3=E7=AD=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
保留 PKR 知识查阅约束(HTML 注释标记幂等保护),新增内容:
- 项目概览:DDD 落地框架 + 四大支柱定义
- 模块拓扑图:6 个 starter 模块单向依赖关系
- 关键架构决策 7 条:领域事件总线、CGLIB 实体代理、
Groovy 脚本热加载、JWT/Redis 双模 Token、行+列双层权限、
泛型显式声明规则、javax vs jakarta 适配
- 常用命令:构建/单测/单模块测试/travis 与 ossrh profile
- 文档与知识库指针:README/event.md/wiki/docs/
Co-Authored-By: Claude Opus 4.8
---
CLAUDE.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 70 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 859eb28a..f1c6c136 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,4 +1,73 @@
-# 项目开发指引
+# 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.54)是一套基于 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 知识查阅(编码前必须)
From 56d4857f95db4dd5fac613371bb658828da71158 Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Thu, 6 Aug 2026 21:31:59 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=B4=E6=97=B6?=
=?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=BA=BF=E7=A8=8B=E6=B3=84=E6=BC=8F=EF=BC=88?=
=?UTF-8?q?OOM=20=E6=A0=B9=E5=9B=A0=EF=BC=89=E5=B9=B6=E5=8D=87=E7=BA=A7?=
=?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=202.10.55?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- TempGroovyScriptContext 改用共享 daemon 调度线程,根治每脚本 new Timer() 的原生线程泄漏
- 覆盖/删除/清空/cache-miss 时 cancel 定时任务,ConcurrentHashMap 防并发
- 修正绝对到期时间戳误传为相对延迟的清理 bug(原实现约 55 年后才触发,自动清理从未生效)
- 原子化 remove(key, value) 防止过期任务误删被刷新脚本
- 新增回归测试 TempGroovyScriptContextTest(线程数不增长 + 到期自动清理)
- 版本号 2.10.54 → 2.10.55(全部 pom + CLAUDE.md)
Co-Authored-By: Claude
---
CLAUDE.md | 2 +-
pom.xml | 2 +-
springboot-starter-data-authorization/pom.xml | 2 +-
springboot-starter-data-fast/pom.xml | 2 +-
springboot-starter-flow/pom.xml | 2 +-
springboot-starter-script/pom.xml | 2 +-
.../script/temp/TempGroovyScriptContext.java | 91 +++++++++++++++----
.../temp/TempGroovyScriptContextTest.java | 84 +++++++++++++++++
springboot-starter-security/pom.xml | 2 +-
springboot-starter/pom.xml | 2 +-
10 files changed, 164 insertions(+), 27 deletions(-)
create mode 100644 springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java
diff --git a/CLAUDE.md b/CLAUDE.md
index f1c6c136..fa13ff48 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概览
-`com.codingapi.springboot:springboot-parent`(v2.10.54)是一套基于 Spring Boot 2.7.18 + JDK 8 的**领域驱动设计(DDD)落地框架**,围绕"事件风暴 + 流程编排 + 数据权限 + 动态脚本"四大支柱提供可插拔的 starter 模块。框架代码本身即是规范,业务方按需引入 starter 即可获得对应能力,无需重复造轮子。
+`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`。
diff --git a/pom.xml b/pom.xml
index 06fa73a7..1048c798 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
com.codingapi.springboot
springboot-parent
- 2.10.54
+ 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 5ac2c8da..b66c5499 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.54
+ 2.10.55
springboot-starter-data-authorization
diff --git a/springboot-starter-data-fast/pom.xml b/springboot-starter-data-fast/pom.xml
index 28fc23f3..1c5de9a5 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.54
+ 2.10.55
4.0.0
diff --git a/springboot-starter-flow/pom.xml b/springboot-starter-flow/pom.xml
index 1e8dce5f..2f6123fb 100644
--- a/springboot-starter-flow/pom.xml
+++ b/springboot-starter-flow/pom.xml
@@ -6,7 +6,7 @@
springboot-parent
com.codingapi.springboot
- 2.10.54
+ 2.10.55
springboot-starter-flow
diff --git a/springboot-starter-script/pom.xml b/springboot-starter-script/pom.xml
index 800c2eab..6e96c3b2 100644
--- a/springboot-starter-script/pom.xml
+++ b/springboot-starter-script/pom.xml
@@ -6,7 +6,7 @@
com.codingapi.springboot
springboot-parent
- 2.10.54
+ 2.10.55
springboot-starter-script
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 8ccb2cc7..4b3c447a 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 00000000..d7fbd7c9
--- /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 9bec5645..50b0e24b 100644
--- a/springboot-starter-security/pom.xml
+++ b/springboot-starter-security/pom.xml
@@ -6,7 +6,7 @@
com.codingapi.springboot
springboot-parent
- 2.10.54
+ 2.10.55
springboot-starter-security
diff --git a/springboot-starter/pom.xml b/springboot-starter/pom.xml
index 41d5f931..84cadab4 100644
--- a/springboot-starter/pom.xml
+++ b/springboot-starter/pom.xml
@@ -5,7 +5,7 @@
com.codingapi.springboot
springboot-parent
- 2.10.54
+ 2.10.55
springboot-starter