diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..ca30ed8e6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(echo \"exit: $?\")" + ] + }, + "enabledPlugins": { + "pkr@pkr-marketplace": true + } +} diff --git a/.gitignore b/.gitignore index 5c67716d3..9ece17937 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,18 @@ build/ *.db *.trace.db +### macOS ### +.DS_Store + ### jars ### /jars/ + + +### qwen ### +.qwen/ + +### codegraph ### +.codegraph + +### serena ### +.serena \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..55d2c51b4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,194 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +springboot-framework 是一个基于 Spring Boot 3.3.5(Java 17)的多模块企业级开发框架,由 CodingApi 团队维护。核心定位是为领域驱动设计(DDD)与事件风暴提供开箱即用的基础设施,当前版本为 `3.4.55`。 + +**回复语言**:请使用中文进行回答。 + +## 常用命令 + +```bash +# 全量构建 +./mvnw clean install + +# 仅运行测试(CI 使用) +./mvnw clean test -P travis + +# 运行单个模块的测试 +./mvnw test -pl springboot-starter +./mvnw test -pl springboot-starter-flow +./mvnw test -pl springboot-starter-security + +# 运行单个测试类 +./mvnw test -pl springboot-starter -Dtest=SomeTestClass + +# 运行 example 示例应用(端口 8090,H2 文件数据库) +./mvnw spring-boot:run -pl example/example-server +``` + +### 前端(admin-ui / mobile-ui) + +```bash +cd admin-ui # 或 mobile-ui +npm install +npm run dev # 开发模式(代理后端) +npm run mock # Mock 模式(前端 mock 数据) +npm run build # 生产构建 +npm test # Jest 测试 +``` + +- admin-ui 基于 React 18 + Ant Design 5 + Ant Design Pro Components + Rsbuild +- mobile-ui 基于 React 18 + Ant Design Mobile 5 + Rsbuild +- 两者都使用 Module Federation 做微前端集成 + +## 模块架构 + +### Starter 模块(框架核心) + +| 模块 | 职责 | +|------|------| +| `springboot-starter` | 核心基础:统一 Response DTO、分页 PageRequest/Filter、事件系统、国际化异常、事务管理 | +| `springboot-starter-security` | Spring Security + JWT 无状态认证 / Redis 有状态认证,加解密支持 | +| `springboot-starter-data-fast` | JPA 增强:FastRepository 支持动态过滤查询、HQL 构建、SearchRequest | +| `springboot-starter-data-authorization` | 数据权限:通过 JDBC Connection/Statement 代理拦截 SQL,透明注入权限条件 | +| `springboot-starter-flow` | 工作流引擎:流程定义、节点流转、审批、委托、会签、数据快照、事件通知 | +| `springboot-starter-script` | Groovy 脚本引擎:运行时编译、LRU 缓存、热更新,提供 REST API | + +### 自动配置注册 + +各 starter 模块同时维护 `META-INF/spring.factories`(兼容旧版)和 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`(Spring Boot 3.x 标准)。新增 starter 时需同时注册这两种文件。 + +### 模块间依赖关系 + +``` +springboot-starter (核心,无外部依赖) + ├── springboot-starter-security (依赖 starter) + ├── springboot-starter-data-fast (依赖 starter) + ├── springboot-starter-data-authorization (依赖 starter + JSqlParser) + ├── springboot-starter-flow (依赖 starter) + └── springboot-starter-script (依赖 starter + Groovy) +``` + +## 核心架构模式 + +### 事件系统(Event System) + +框架自建的发布-订阅事件机制,是 DDD 领域事件的核心基础设施: + +- **`IEvent`** — 事件标记接口,分为 `ISyncEvent`(同步)和 `IAsyncEvent`(异步) +- **`IHandler`** — 事件处理器,支持 `order()` 排序和 `error()` 异常回调 +- **`EventPusher.push(event, sync)`** — 事件推送入口 +- **`DomainEvent`** — 领域实体事件基类,派生 `DomainCreateEvent`、`DomainChangeEvent`、`DomainDeleteEvent` +- **`DomainChangeInterceptor`** — 通过代理拦截实体字段变更,自动推送 `DomainChangeEvent` + +Handler 通过 Spring Bean 自动注册,由 `HandlerBeanDefinitionRegistrar` 扫描所有 `IHandler` 实现并注册到 `ApplicationHandlerUtils`。 + +### 统一响应封装 + +```java +Response // 基础响应(success / errCode / errMessage) +SingleResponse // 单对象响应 +MultiResponse // 列表响应 +MapResponse // Map 响应 +``` + +### 数据查询(PageRequest + FastRepository) + +`PageRequest` 扩展了 Spring Data 的 `PageRequest`,增加了 `RequestFilter` 动态过滤条件: + +```java +PageRequest request = PageRequest.of(0, 20); +request.addFilter("name", "张三"); +request.addFilter("age", Relation.GT, 18); +Page page = userRepository.findAll(request); +``` + +`FastRepository.findAll(PageRequest)` 自动根据 Filter 构建 Example 或 HQL 查询。 + +### 数据权限(SQL 拦截) + +`ConnectionProxy` → `PreparedStatementProxy` / `StatementProxy` 代理链,在 SQL 执行前通过 `SQLRunningContext.intercept(sql)` 注入权限条件,实现透明的行级数据过滤。使用 JSqlParser 解析和改写 SQL。 + +### 工作流引擎 + +核心流程:`FlowWork`(流程定义)→ `FlowNode`(节点)→ `FlowRecord`(审批记录)→ `FlowBackup`(版本快照)。发起流程通过 `FlowStartService.startFlow()` 驱动,审批通过 `FlowNodeService` 逐节点流转,每个状态变更推送 `FlowApprovalEvent`。 + +## DDD 示例项目结构(example 模块) + +``` +example/ + example-server — Spring Boot 启动入口 + example-interface — 接口层:Controller(API)、Handler(事件处理)、Runner + example-app — 应用层 + example-app-query — 查询服务(CQRS Query 侧) + example-app-cmd-domain — 命令服务(CQRS Command 侧,领域编排) + example-app-cmd-meta — 元数据命令服务 + example-domain — 领域层 + example-domain-leave — 请假领域(Entity、Repository、Service) + example-domain-user — 用户领域(Entity、Repository、Service、Event、Gateway) + example-infra — 基础设施层 + example-infra-jpa — JPA 持久化实现 + example-infra-flow — 工作流集成 + example-infra-security — 安全配置 +``` + +遵循 DDD 分层依赖规则:`interface → app → domain ← infra`。domain 层定义 Repository 接口,infra 层提供实现。 + +## 框架配置项 + +在 `application.properties` 中可用的配置前缀: + +```properties +# 安全模块 +codingapi.security.jwt.enable=true # 启用 JWT 认证 +codingapi.security.redis.enable=true # 启用 Redis 有状态认证 +codingapi.security.ignore-urls=/open/**,/#/** # 免认证 URL 列表 + +# 框架核心 +codingapi.framework.handler-thread-pool-size=20 # 事件异步线程池大小 +``` + +## 关键依赖版本 + +见根 `pom.xml` 的 `` 区块。主要版本:Groovy 4.0.24、JSqlParser 5.0、Fastjson 2.0.53、JJWT 0.12.6、H2 2.3.232、Kryo 5.6.2。 + + + +## 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/agents/capabilities/springboot-starter-data-authorization/sql-interception.md b/docs/agents/capabilities/springboot-starter-data-authorization/sql-interception.md new file mode 100644 index 000000000..93abe1e4e --- /dev/null +++ b/docs/agents/capabilities/springboot-starter-data-authorization/sql-interception.md @@ -0,0 +1,215 @@ +--- +name: springboot-starter-data-authorization/sql-interception +module: springboot-starter-data-authorization +description: SQL 拦截数据权限,通过 JDBC 代理透明注入权限条件实现行级数据过滤 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-data-authorization +import: "com.codingapi.springboot:springboot-starter-data-authorization" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级应用中,不同角色的用户只能看到自己有权限访问的数据(行级数据权限)。传统实现方式存在以下痛点: + +- **侵入性强**:需要在每个 Service/Repository 方法中手动拼接权限条件,代码散落各处 +- **容易遗漏**:新增查询接口时可能忘记添加权限过滤,导致数据泄露 +- **维护困难**:权限规则变更时需要修改大量业务代码 +- **与业务耦合**:权限逻辑与业务查询逻辑混杂,违反单一职责原则 + +SQL 拦截数据权限通过 JDBC 代理层实现了完全透明的权限注入: + +- **零侵入**:业务代码无需任何修改,所有 SELECT 查询自动注入权限条件 +- **全覆盖**:无论通过 JPA、MyBatis、原生 JDBC 还是其他 ORM 框架执行的 SQL,都会被统一拦截 +- **灵活配置**:通过 `RowHandler` 接口按表名自定义权限规则,支持 WHERE 条件和 JOIN 关联两种注入模式 +- **可跳过**:提供 `skipDataAuthorization()` API,允许特定场景下临时绕过权限检查 +- **SQL 解析增强**:使用 JSqlParser 解析 SQL AST,精确识别表名和别名,支持子查询、UNION、JOIN 等复杂 SQL 结构 + +## 如何使用 + +### 1. 引入依赖 + +```xml + + com.codingapi.springboot + springboot-starter-data-authorization + +``` + +### 2. 架构概览 + +整个拦截链路由以下组件构成: + +``` +DataSource → ConnectionProxy → PreparedStatementProxy / StatementProxy + ↓ + SQLRunningContext.intercept(sql) + ↓ + SQLInterceptor (DefaultSQLInterceptor) + ↓ + DataPermissionSQLEnhancer (JSqlParser) + ↓ + RowHandler.handler(tableName, alias) + ↓ + Condition (WHERE / JOIN 条件注入) +``` + +### 3. 核心组件说明 + +#### ConnectionProxy + +JDBC `Connection` 的代理实现。在 `prepareStatement()`、`createStatement()`、`prepareCall()` 等方法中拦截 SQL,调用 `SQLRunningContext.intercept(sql)` 获取改写后的 SQL,并将 `SQLExecuteState` 传递给下游的 Statement 代理。 + +#### PreparedStatementProxy / StatementProxy + +JDBC `PreparedStatement` 和 `Statement` 的代理实现。在执行 `executeQuery()`、`execute()` 等方法时,确保使用经过权限改写的 SQL。对于直接传入 SQL 字符串的方法(如 `executeQuery(String sql)`),会再次调用 `SQLRunningContext.intercept()` 进行拦截。查询结果通过 `ResultSetProxy` 包装返回。 + +#### SQLRunningContext + +SQL 拦截的核心调度器(单例模式),负责: +- 从 `SQLInterceptorContext` 获取当前 `SQLInterceptor` 实例 +- 通过 `ThreadLocal skipInterceptor` 控制是否跳过拦截 +- 调用 `SQLInterceptor.beforeHandler()` 判断是否需要拦截(默认仅拦截 SELECT 语句) +- 调用 `SQLInterceptor.postHandler()` 执行 SQL 改写 +- 提供 `skipDataAuthorization(Supplier/Runnable)` API 临时跳过权限检查 + +#### DefaultSQLInterceptor + +默认的 SQL 拦截器实现,包含三个阶段的处理: +- `beforeHandler(sql)`:通过 `SQLUtils.isQuerySql()` 判断是否为查询语句,仅 SELECT 会被拦截 +- `postHandler(sql)`:创建 `DataPermissionSQLEnhancer`,使用 JSqlParser 解析 SQL 并通过 `RowHandler` 获取权限条件,返回增强后的 SQL +- `afterHandler(sql, newSql, exception)`:日志记录,当配置 `showSql=true` 时输出改写后的 SQL + +#### RowHandler + +行级权限处理器接口,由业务方实现: + +```java +public interface RowHandler { + Condition handler(String subSql, String tableName, String tableAlias); +} +``` + +返回值 `Condition` 支持两种注入模式: +- **WHERE 条件**:`Condition.customCondition("dept_id IN (1,2,3)")` — 在 WHERE 子句中追加 AND 条件 +- **JOIN 关联**:通过 `JoinConditionSQL` 添加 INNER/LEFT/RIGHT JOIN 关联表 + +### 4. 跳过数据权限 + +在某些管理操作或系统任务中需要绕过数据权限: + +```java +// 方式一:Lambda 表达式 +List allUsers = SQLRunningContext.getInstance() + .skipDataAuthorization(() -> userRepository.findAll()); + +// 方式二:Runnable +SQLRunningContext.getInstance() + .skipDataAuthorization(() -> { + reportService.generateMonthlyReport(); + }); +``` + +## 使用实例 + +### 示例一:按部门过滤数据 + +```java +@Component +public class DeptRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + // 仅对 employee 表注入权限条件 + if ("employee".equalsIgnoreCase(tableName)) { + List deptIds = SecurityContext.getCurrentDeptIds(); + if (deptIds == null || deptIds.isEmpty()) { + return Condition.emptyCondition(); // 无权限,返回 null 不注入 + } + String inClause = deptIds.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + return Condition.customCondition( + String.format("%s.dept_id IN (%s)", tableAlias, inClause) + ); + } + // 其他表不注入权限条件 + return Condition.emptyCondition(); + } +} +``` + +效果:原始 SQL `SELECT * FROM employee WHERE status = 'active'` 被改写为: +```sql +SELECT * FROM employee WHERE dept_id IN (1,2,3) AND status = 'active' +``` + +### 示例二:通过 JOIN 关联实现跨表权限 + +```java +@Component +public class ProjectRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + if ("project".equalsIgnoreCase(tableName)) { + Condition condition = new Condition(); + // 通过 JOIN 关联成员表,只查询当前用户参与的项目 + JoinConditionSQL joinSQL = new JoinConditionSQL( + "project_member pm", + JoinConditionSQL.Type.INNER, + String.format("pm.project_id = %s.id AND pm.user_id = %d", + tableAlias, SecurityContext.getCurrentUserId()) + ); + condition.addConditionSQL(joinSQL); + return condition; + } + return Condition.emptyCondition(); + } +} +``` + +效果:原始 SQL `SELECT * FROM project WHERE status = 'open'` 被改写为: +```sql +SELECT * FROM project +INNER JOIN project_member pm ON pm.project_id = project.id AND pm.user_id = 1001 +WHERE status = 'open' +``` + +### 示例三:自定义 SQLInterceptor + +如需替换默认的拦截逻辑(例如增加缓存或审计),可实现 `SQLInterceptor` 接口并注册为 Spring Bean: + +```java +@Component +public class AuditSQLInterceptor implements SQLInterceptor { + + @Override + public boolean beforeHandler(String sql) { + // 仅拦截 SELECT 且不包含系统表的查询 + return SQLUtils.isQuerySql(sql) && !sql.contains("sys_config"); + } + + @Override + public DataPermissionSQL postHandler(String sql) throws SQLException { + RowHandler rowHandler = RowHandlerContext.getInstance().getRowHandler(); + DataPermissionSQLEnhancer enhancer = new DataPermissionSQLEnhancer(sql, rowHandler); + return new DataPermissionSQL(sql, enhancer.getNewSQL(), enhancer.getTableAlias()); + } + + @Override + public void afterHandler(String sql, String newSql, SQLException exception) { + // 记录审计日志 + AuditLog.record(sql, newSql, exception); + } +} +``` + +### 内部工作原理 + +1. **连接代理**:DataSource 返回的 `Connection` 被包装为 `ConnectionProxy` +2. **SQL 拦截时机**:当调用 `connection.prepareStatement(sql)` 时,`ConnectionProxy` 立即调用 `SQLRunningContext.intercept(sql)` 对 SQL 进行改写 +3. **递归解析**:`DataPermissionSQLEnhancer` 使用 JSqlParser 解析 SQL AST,深度遍历 PlainSelect、SetOperationList(UNION)、子查询、JOIN 中的子 Select,对每个涉及的表调用 `RowHandler` +4. **条件注入**:`WhereConditionSQLHandler` 将 WHERE 条件通过 AND 拼接到原有 WHERE 子句;`JoinConditionSQLHandler` 向 FROM 子句追加 JOIN 关联 +5. **防重入**:`SQLRunningContext` 使用 ThreadLocal 标记,在拦截器内部执行的查询不会被二次拦截 diff --git a/docs/agents/capabilities/springboot-starter-data-fast/fast-repository.md b/docs/agents/capabilities/springboot-starter-data-fast/fast-repository.md new file mode 100644 index 000000000..22b7fbafa --- /dev/null +++ b/docs/agents/capabilities/springboot-starter-data-fast/fast-repository.md @@ -0,0 +1,168 @@ +--- +name: springboot-starter-data-fast/fast-repository +module: springboot-starter-data-fast +description: JPA 增强 Repository,支持 PageRequest 动态过滤查询和 HQL 构建 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-data-fast +import: "com.codingapi.springboot:springboot-starter-data-fast" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在标准 Spring Data JPA 开发中,动态条件查询通常需要手动编写 `Specification`、`QueryDSL` 或拼接 HQL,代码冗长且难以维护。当业务列表页面需要支持多字段组合筛选(等于、模糊、范围、IN 等)时,开发者往往要为每种查询场景编写独立的 Repository 方法或复杂的 Specification 构建逻辑。 + +`FastRepository` 通过扩展 `JpaRepository` 和 `JpaSpecificationExecutor`,提供了基于 `PageRequest` 的声明式动态查询能力: + +- **自动 Example 查询**:当 Filter 条件均为简单等值匹配时,自动转换为 Spring Data `Example` 查询,零额外代码 +- **HQL 动态构建**:当包含模糊、范围、IN 等复杂条件时,自动构建参数化 HQL,避免 SQL 注入风险 +- **SearchRequest 集成**:支持从 HTTP 请求参数中自动解析 filter、sort 条件,适用于前端列表页的通用查询接口 +- **OR/AND 组合过滤**:支持嵌套的 OR/AND 条件组合,满足复杂业务筛选需求 + +## 如何使用 + +### 1. 定义 Repository 接口 + +继承 `FastRepository` 即可获得全部动态查询能力: + +```java +public interface UserEntityRepository extends FastRepository { + // 标准 JpaRepository 方法仍然可用 + UserEntity getUserEntityByUsername(String username); +} +``` + +### 2. 使用 PageRequest 进行动态过滤查询 + +```java +// 创建分页请求并添加过滤条件 +PageRequest request = PageRequest.of(0, 20); +request.addFilter("name", "张三"); // 等值匹配 +request.addFilter("age", Relation.GT, 18); // 大于 +request.addFilter("email", Relation.LIKE, "gmail"); // 模糊查询 + +// 方式一:自动选择 Example 或 HQL(推荐) +Page page = repository.findAll(request); + +// 方式二:强制使用 HQL 查询(适合复杂条件) +Page page2 = repository.pageRequest(request); +``` + +### 3. 支持的过滤关系(Relation) + +| Relation | 说明 | HQL 示例 | +|----------|------|----------| +| EQ(默认) | 等于 | `name = ?1` | +| NEQ | 不等于 | `name != ?1` | +| GT | 大于 | `age > ?1` | +| LT | 小于 | `age < ?1` | +| GTE | 大于等于 | `age >= ?1` | +| LTE | 小于等于 | `age <= ?1` | +| LIKE | 全模糊 | `name LIKE ?1`(自动加 `%value%`) | +| LEFT_LIKE | 左模糊 | `name LIKE ?1`(自动加 `%value`) | +| RIGHT_LIKE | 右模糊 | `name LIKE ?1`(自动加 `value%`) | +| IN | 包含 | `id IN (?1)` | +| NOT_IN | 不包含 | `id NOT IN (?1)` | +| BETWEEN | 区间 | `age BETWEEN ?1 AND ?2` | +| IS_NULL | 为空 | `name IS NULL` | +| IS_NOT_NULL | 非空 | `name IS NOT NULL` | + +### 4. 使用 SearchRequest 从 HTTP 请求自动解析 + +```java +// 在 Controller 中使用 SearchRequest,自动从 URL 参数解析 filter 和 sort +@GetMapping("/users") +public MultiResponse list() { + SearchRequest searchRequest = new SearchRequest(); + searchRequest.addFilter("status", "active"); // 追加服务端固定条件 + Page page = userRepository.searchRequest(searchRequest); + return MultiResponse.of(page.getContent()); +} +``` + +前端通过 URL 参数传递动态条件: +- `?filter=eyJuYW1lIjpbIuW8oCJdfQ==`(Base64 编码的 JSON:`{"name":["张"]}`) +- `?sort=eyJjcmVhdGVUaW1lIjoiZGVzY2VuZCJ9`(Base64 编码的 JSON:`{"createTime":"descend"}`) + +### 5. OR / AND 组合条件 + +```java +PageRequest request = PageRequest.of(0, 20); + +// OR 条件:name = '张三' OR name = '李四' +request.orFilters( + new Filter("name", "张三"), + new Filter("name", "李四") +); + +// AND 条件组 +request.andFilter( + new Filter("age", Relation.GTE, 18), + new Filter("status", "active") +); +``` + +## 使用实例 + +### 完整示例:用户列表查询 + +```java +@Service +public class UserQueryService { + + @Resource + private UserEntityRepository userRepository; + + /** + * 基础动态查询 - 自动 Example/HQL + */ + public Page findUsers(String name, Integer minAge, String status) { + PageRequest request = PageRequest.of(0, 20); + if (name != null) { + request.addFilter("name", Relation.LIKE, name); + } + if (minAge != null) { + request.addFilter("age", Relation.GTE, minAge); + } + if (status != null) { + request.addFilter("status", status); + } + return userRepository.findAll(request); + } + + /** + * 复杂 HQL 查询 - 带排序 + */ + public Page findActiveUsersWithSort() { + PageRequest request = PageRequest.of(0, 20, Sort.by("createTime").descending()); + request.addFilter("status", "active"); + request.addFilter("age", Relation.BETWEEN, 18, 65); + return userRepository.pageRequest(request); + } + + /** + * 前端驱动的通用查询接口 + */ + public Page searchFromHttpRequest() { + SearchRequest searchRequest = new SearchRequest(); + // 追加服务端安全条件,防止越权查询 + searchRequest.addFilter("deleted", false); + return userRepository.searchRequest(searchRequest); + } +} +``` + +### 内部工作原理 + +`FastRepository.findAll(PageRequest)` 的执行流程: + +1. 检查 `request.hasFilter()` — 无过滤条件时直接委托给 Spring Data 的标准 `findAll(PageRequest)` +2. 有过滤条件时,通过 `ExampleBuilder` 尝试构建 `Example` 对象(仅处理等值匹配的属性) +3. 将 Example 与 PageRequest 一起传入 `findAll(Example, Pageable)` 执行查询 + +`FastRepository.pageRequest(PageRequest)` 的执行流程: + +1. 通过 `DynamicSQLBuilder` 根据 Filter 列表动态构建 HQL 语句和 COUNT 语句 +2. 所有值通过参数化绑定(`?1`, `?2`...),防止 SQL 注入 +3. 调用 `dynamicPageQuery(hql, countHql, request, params)` 执行分页查询 diff --git a/docs/agents/capabilities/springboot-starter-flow/workflow-engine.md b/docs/agents/capabilities/springboot-starter-flow/workflow-engine.md new file mode 100644 index 000000000..ce900011a --- /dev/null +++ b/docs/agents/capabilities/springboot-starter-flow/workflow-engine.md @@ -0,0 +1,147 @@ +--- +name: springboot-starter-flow/workflow-engine +module: springboot-starter-flow +description: 工作流引擎,支持流程定义、节点流转、审批、委托、会签和数据快照 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-flow +import: "com.codingapi.springboot:springboot-starter-flow" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级应用中,审批流程(如请假、报销、采购等)是高频且复杂的业务场景。传统硬编码方式存在以下痛点: + +- **流程变更成本高**:每次调整审批节点或流转规则都需要修改代码并重新部署 +- **审批模式单一**:难以同时支持会签、或签、传阅、退回、委托等多种审批形态 +- **状态追踪困难**:流程实例的当前节点、历史审批记录、数据快照缺乏统一管理 +- **事件通知分散**:审批通过、拒绝、转办等状态变更需要在多处手动触发通知逻辑 + +`workflow-engine` 提供了一套轻量级的嵌入式工作流引擎,将流程定义与业务代码解耦。通过 Builder 模式声明式构建流程,引擎自动处理节点流转、操作者匹配、数据快照绑定和事件推送,让开发者专注于业务逻辑本身。 + +## 如何使用 + +### 核心概念 + +| 概念 | 类 | 说明 | +|------|-----|------| +| 流程定义 | `FlowWork` | 描述一个完整的审批流程,包含节点集合、关系集合、启用状态等 | +| 流程节点 | `FlowNode` | 流程中的单个环节(开始/审批/传阅/结束),配置审批类型、操作者匹配器、超时时间等 | +| 节点关系 | `FlowRelation` | 定义节点之间的流转规则,支持条件触发(`OutTrigger`)和退回标记 | +| 流程构建器 | `FlowWorkBuilder` | 链式 API 构建 `FlowWork`,内部自动校验节点和关系的完整性 | +| 发起服务 | `FlowStartService` | 发起新流程实例,创建流程备份、数据快照和首条待办记录 | +| 节点服务 | `FlowNodeService` | 驱动节点流转:加载下一节点、匹配操作者、创建审批记录、处理传阅跳过 | +| 审批事件 | `FlowApprovalEvent` | 同步事件,覆盖创建/待办/通过/拒绝/转办/撤回/完成/催办/抄送/退回等 14 种状态 | +| 流程操作者 | `IFlowOperator` | 用户接口,支持委托 (`entrustOperator()`) 和管理员强制干预 | + +### 构建流程 + +使用 `FlowWorkBuilder` 声明式定义流程: + +```java +FlowWork work = FlowWorkBuilder.builder(operator) + .title("请假审批流程") + .description("员工请假审批") + .skipIfSameApprover(true) // 相同审批人自动跳过 + .postponedMax(3) // 最大延期次数 + .nodes() + .node("发起", "start", "startView", ApprovalType.UN_SIGN, startMatcher) + .node("部门审批", "dept_approve", "approveView", ApprovalType.SIGN, deptMatcher, true, false) + .node("HR备案", "hr_record", "hrView", ApprovalType.UN_SIGN, hrMatcher) + .node("结束", "over", "overView", ApprovalType.UN_SIGN, endMatcher) + .relations() + .relation("发起->部门审批", "start", "dept_approve") + .relation("部门审批->HR备案", "dept_approve", "hr_record") + .relation("HR备案->结束", "hr_record", "over") + .build(); +``` + +构建时 `build()` 会自动调用 `enable()` → `verify()`,校验以下内容: +- 必须存在 `start` 和 `over` 节点及其关联关系 +- 节点 code 不能重复 +- 每个节点的 `titleGenerator` 和 `operatorMatcher` 不能为空 + +### 发起流程 + +通过 `FlowStartService` 发起流程实例: + +```java +FlowStartService startService = new FlowStartService( + workCode, operator, bindData, advice, repositoryHolder); +FlowResult result = startService.startFlow(); +``` + +启动过程依次执行:加载并校验流程定义 → 创建版本快照(`FlowBackup`)→ 保存流程实例 → 序列化绑定数据 → 从 start 节点创建待办记录 → 推送 `FlowApprovalEvent`。 + +### 监听审批事件 + +实现 `IHandler` 即可接收所有审批状态变更: + +```java +@Component +public class LeaveApprovalHandler implements IHandler { + @Override + public void handle(FlowApprovalEvent event) { + if (event.isTodo()) { + // 发送待办通知 + } + if (event.isPass()) { + // 审批通过处理 + } + if (event.isFinish()) { + // 流程结束归档 + } + } +} +``` + +事件通过框架的 `EventPusher` 同步推送,在同一个事务内完成。 + +## 使用实例 + +以下示例展示一个完整的请假审批流程定义与发起: + +```java +// 1. 定义操作者匹配器 +OperatorMatcher startMatcher = OperatorMatcher.any(); // 发起人 +OperatorMatcher deptMatcher = OperatorMatcher.script( // 部门负责人 + "session.bindData.deptLeaderId"); +OperatorMatcher hrMatcher = OperatorMatcher.script( // HR + "session.bindData.hrUserId"); +OperatorMatcher endMatcher = OperatorMatcher.any(); + +// 2. 构建流程 +FlowWork leaveFlow = FlowWorkBuilder.builder(currentUser) + .title("请假审批") + .skipIfSameApprover(true) + .nodes() + .node("发起申请", "start", "leaveStart", ApprovalType.UN_SIGN, startMatcher) + .node("部门审批", "dept", "leaveApprove", ApprovalType.SIGN, deptMatcher, true, false) + .node("HR确认", "hr", "leaveHr", ApprovalType.UN_SIGN, hrMatcher) + .node("结束", "over", "leaveEnd", ApprovalType.UN_SIGN, endMatcher) + .relations() + .relation("提交", "start", "dept") + .relation("批准", "dept", "hr") + .relation("确认", "hr", "over") + .build(); + +// 3. 准备业务数据 +LeaveRequest leave = new LeaveRequest(); +leave.setUserId(currentUser.getUserId()); +leave.setDays(3); +leave.setDeptLeaderId(deptLeaderId); +leave.setHrUserId(hrUserId); + +// 4. 发起流程 +FlowStartService service = new FlowStartService( + leaveFlow.getCode(), currentUser, leave, "申请年假3天", repoHolder); +FlowResult result = service.startFlow(); + +// 5. 获取待办记录 +List records = result.getRecords(); +records.forEach(r -> System.out.println( + "待办: " + r.getTitle() + " -> " + r.getCurrentOperator().getName())); +``` + +当部门审批人点击通过后,引擎自动流转至 HR 确认节点;若设置了退回关系,审批人可选择退回至指定节点。全程数据快照通过 `BindDataSnapshot` 持久化,确保审批过程中业务数据不可变。 diff --git a/docs/agents/capabilities/springboot-starter-script/groovy-runtime.md b/docs/agents/capabilities/springboot-starter-script/groovy-runtime.md new file mode 100644 index 000000000..b55477f56 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter-script/groovy-runtime.md @@ -0,0 +1,158 @@ +--- +name: springboot-starter-script/groovy-runtime +module: springboot-starter-script +description: Groovy 脚本运行时引擎,支持运行时编译、LRU 缓存、热更新和 REST API +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-script +import: "com.codingapi.springboot:springboot-starter-script" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业应用中,部分业务逻辑需要频繁调整但不希望每次都经历"改代码→编译→部署"的完整周期。典型场景包括: + +- **动态规则计算**:促销折扣、费率计算、风控阈值等业务规则经常变化 +- **自定义报表/导出**:不同租户或部门的报表格式差异大,用脚本比硬编码更灵活 +- **流程条件表达式**:工作流节点的条件判断、操作者匹配等需要运行时求值 +- **临时数据处理**:运维脚本、数据修复等一次性任务 + +`groovy-runtime` 提供了嵌入式的 Groovy 脚本执行引擎,具备以下特性: + +- **运行时编译执行**:无需重启应用即可加载和执行新脚本 +- **LRU 编译缓存**:基于 SHA256 指纹缓存已编译的 Script 对象,避免重复编译开销 +- **三级存储**:内存缓存 → 临时存储 → 持久化仓库,兼顾性能和可靠性 +- **事务集成**:支持只读(READONLY)和提交(COMMIT)两种事务模式 +- **REST API**:内置 Controller 提供脚本编译、查询、保存接口,方便管理端对接 + +## 如何使用 + +### 核心组件 + +| 组件 | 职责 | +|------|------| +| `GroovyScriptRuntime` | 底层执行引擎:GroovyShell 封装、LRU 编译缓存、invoke/run 方法、事务控制 | +| `GroovyScriptRuntimeContext` | Runtime 的单例上下文,从配置读取 `shellMaxCacheSize` 初始化 | +| `GroovyScript` | 脚本领域对象:封装 key/script/method/returnType/binds 等元信息,提供 compile/run/invoke 快捷方法 | +| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository | +| `GroovyScriptController` | REST 端点 `/api/groovy-script/*`,提供 compile/getScript/getMetadata/save 接口 | + +### 直接执行脚本 + +通过 `GroovyScriptRuntimeContext` 单例直接运行脚本: + +```java +GroovyScriptRuntimeContext ctx = GroovyScriptRuntimeContext.getInstance(); + +// 简单执行 +String result = ctx.run("'hello ' + name", String.class, + TransactionMode.DEFAULT, Map.of("name", "world")); + +// 调用脚本中的函数 +int sum = ctx.invoke("add", "def add(a,b){ a + b }", int.class, + TransactionMode.DEFAULT, null, 3, 5); +``` + +### 使用 GroovyScript 对象 + +对于需要持久化和复用的脚本,使用 `GroovyScript` 封装: + +```java +GroovyScript script = GroovyScript.builder("discount_calc") + .script(""" + def calculate(price, rate) { + return price * rate + } + """) + .method("calculate") + .returnType(BigDecimal.class) + .description("折扣计算脚本") + .tag("pricing") + .build(); + +// 编译并缓存 +script.compile(true); + +// 执行 +BigDecimal result = script.invoke(Map.of(), new BigDecimal("100"), new BigDecimal("0.85")); + +// 持久化到仓库 +script.save(); +``` + +### 事务模式 + +`TransactionMode` 支持三种模式: + +| 模式 | 行为 | +|------|------| +| `DEFAULT` | 不参与事务,由调用方自行控制 | +| `READONLY` | 在只读事务中执行,适合查询类脚本 | +| `COMMIT` | 在可提交事务中执行,适合写入类脚本 | + +### REST API + +内置的 `GroovyScriptController` 提供以下端点: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/groovy-script/compile` | 编译脚本(body: `{script, cache}`) | +| GET | `/api/groovy-script/getScript?key=` | 获取脚本内容 | +| GET | `/api/groovy-script/getMetadata?key=` | 获取脚本元数据 | +| POST | `/api/groovy-script/save` | 保存脚本(编译+持久化) | + +## 使用实例 + +以下示例展示一个完整的动态折扣计算场景: + +```java +// 1. 创建并保存折扣计算脚本 +GroovyScript discountScript = GroovyScript.builder("member_discount") + .script(""" + def calcDiscount(amount, memberLevel) { + switch(memberLevel) { + case 'GOLD': return amount * 0.8 + case 'SILVER': return amount * 0.9 + default: return amount + } + } + """) + .method("calcDiscount") + .returnType(BigDecimal.class) + .description("会员折扣计算") + .typeOne("pricing") + .typeTwo("discount") + .build(); + +// 编译并持久化 +discountScript.compile(true); +discountScript.save(); + +// 2. 后续使用时直接从缓存获取 +GroovyScript cached = GroovyScriptCacheContext.getInstance() + .getGroovyScript("member_discount"); + +// 3. 执行业务计算 +BigDecimal originalAmount = new BigDecimal("500"); +BigDecimal finalPrice = cached.invoke( + TransactionMode.READONLY, null, originalAmount, "GOLD"); +// finalPrice = 400.0 + +// 4. 热更新:修改脚本后重新保存即可生效 +cached.setScript(""" + def calcDiscount(amount, memberLevel) { + switch(memberLevel) { + case 'DIAMOND': return amount * 0.7 + case 'GOLD': return amount * 0.8 + case 'SILVER': return amount * 0.9 + default: return amount + } + } +"""); +cached.compile(true); +cached.save(); +// 下次 invoke 自动使用新版本 +``` + +缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。 diff --git a/docs/agents/capabilities/springboot-starter-security/auth-gateway.md b/docs/agents/capabilities/springboot-starter-security/auth-gateway.md new file mode 100644 index 000000000..900b79df4 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter-security/auth-gateway.md @@ -0,0 +1,195 @@ +--- +name: springboot-starter-security/auth-gateway +module: springboot-starter-security +description: 安全认证网关,支持 JWT 无状态认证和 Redis 有状态认证两种模式 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-security +import: "com.codingapi.springboot:springboot-starter-security" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级 Web 应用中,用户认证是安全体系的第一道防线。Spring Security 原生提供的表单登录和 Session 机制不适合前后端分离架构,开发者通常需要自行实现以下能力: + +- **Token 生命周期管理**:创建、解析、过期校验、自动续期 +- **多认证模式切换**:JWT 无状态模式适合微服务和移动端,Redis 有状态模式适合需要服务端主动踢人的管理后台 +- **登录流程定制**:在认证前后插入自定义逻辑(验证码校验、登录日志、额外业务数据注入等) +- **统一 JSON 响应**:认证成功/失败均返回标准 `Response` 格式,而非 Spring Security 默认的重定向或 HTML 页面 + +auth-gateway 将上述能力封装为开箱即用的安全网关层,通过 `TokenGateway` 策略接口抽象 Token 的创建与解析,配合 `MyLoginFilter`(登录拦截)和 `MyAuthenticationFilter`(请求鉴权拦截)两个 Servlet Filter,实现完整的认证闭环。开发者只需通过配置选择 JWT 或 Redis 模式,即可零代码获得生产级认证能力。 + +## 如何使用 + +### 1. 引入依赖 + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +### 2. 选择认证模式 + +通过 `application.properties` 启用其中一种模式(二选一): + +**JWT 无状态模式:** + +```properties +codingapi.security.jwt.enable=true +codingapi.security.jwt.secret-key=your-secret-key-must-be-at-least-32-chars +codingapi.security.jwt.valid-time=900000 # Token 有效期 15 分钟(毫秒) +codingapi.security.jwt.rest-time=600000 # 10 分钟后自动续期(毫秒) +``` + +**Redis 有状态模式:** + +```properties +codingapi.security.redis.enable=true +codingapi.security.redis.valid-time=900000 +codingapi.security.redis.rest-time=600000 +``` + +### 3. 配置安全策略 + +```properties +# 需要认证的 URL 模式(逗号分隔) +codingapi.security.authenticated-urls=/api/** + +# 免认证 URL 模式 +codingapi.security.ignore-urls=/open/**,/#/** + +# 登录接口地址 +codingapi.security.login-processing-url=/user/login + +# 登出接口地址 +codingapi.security.logout-url=/user/logout +``` + +### 4. 核心 API + +| 类 | 说明 | +|---|---| +| `TokenGateway` | Token 策略接口,提供 `create()` 和 `parser()` 方法。框架根据配置自动注入 `JWTTokenGatewayImpl` 或 `RedisTokenGatewayImpl` | +| `Token` | Token 数据对象,包含 username、authorities、extra、expireTime、remindTime 等字段,支持 `verify()` 过期校验和 `canRestToken()` 自动续期判断 | +| `TokenContext` | 线程安全的 Token 上下文工具类。`TokenContext.current()` 获取当前登录用户的 Token;`TokenContext.pushExtra()` / `getExtra()` 传递额外业务数据 | +| `SecurityLoginHandler` | 登录扩展点接口。实现 `preHandle()` 可在认证前做自定义校验;实现 `postHandle()` 可定制登录成功响应 | +| `AuthenticationTokenFilter` | 鉴权后扩展点接口。每次请求通过 Token 验证后回调,可用于刷新用户缓存等场景 | + +### 5. 自定义 UserDetailsService + +框架默认提供内存用户(admin/user),生产环境需自行实现: + +```java +@Service +public class CustomUserDetailsService implements UserDetailsService { + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 从数据库加载用户信息 + } +} +``` + +## 使用实例 + +### 示例 1:自定义登录处理器 + +在登录前校验验证码,登录后返回额外的用户信息: + +```java +@Component +public class CustomSecurityLoginHandler implements SecurityLoginHandler { + + @Override + public void preHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest) throws Exception { + // 校验验证码 + String captcha = loginRequest.getString("captcha"); + if (captcha == null || !captchaService.verify(captcha)) { + throw new AuthenticationServiceException("验证码错误"); + } + } + + @Override + public LoginResponse postHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest, UserDetails user, Token token) { + LoginResponse loginResponse = new LoginResponse(); + loginResponse.setToken(token.getToken()); + loginResponse.setUsername(token.getUsername()); + loginResponse.setAuthorities(token.getAuthorities()); + // 附加用户头像等信息 + Map data = new HashMap<>(); + data.put("avatar", "/static/avatar/default.png"); + loginResponse.setData(data); + return loginResponse; + } +} +``` + +### 示例 2:在业务代码中获取当前用户 + +```java +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + + @GetMapping + public SingleResponse> getProfile() { + // 从 SecurityContext 获取当前 Token + Token token = TokenContext.current(); + String username = token.getUsername(); + List roles = token.getAuthorities(); + + // 解析 extra 中的自定义数据 + UserInfo extra = token.parseExtra(UserInfo.class); + + Map profile = new HashMap<>(); + profile.put("username", username); + profile.put("roles", roles); + profile.put("department", extra != null ? extra.getDepartment() : null); + return SingleResponse.of(profile); + } +} +``` + +### 示例 3:鉴权后刷新用户缓存 + +```java +@Component +public class CacheRefreshFilter implements AuthenticationTokenFilter { + + private final UserService userService; + + public CacheRefreshFilter(UserService userService) { + this.userService = userService; + } + + @Override + public void doFilter(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + Token token = TokenContext.current(); + // 每次认证通过后刷新用户权限缓存 + userService.refreshPermissionCache(token.getUsername()); + } +} +``` + +### 前端调用示例 + +```javascript +// 登录 +const res = await fetch('/user/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'admin' }) +}); +const { data } = await res.json(); +// data.token → 保存 Token + +// 携带 Token 访问受保护接口 +const profile = await fetch('/api/profile', { + headers: { 'Authorization': data.token } +}); +``` diff --git a/docs/agents/capabilities/springboot-starter/domain-proxy.md b/docs/agents/capabilities/springboot-starter/domain-proxy.md new file mode 100644 index 000000000..db21dd95e --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/domain-proxy.md @@ -0,0 +1,126 @@ +--- +name: springboot-starter/domain-proxy +module: springboot-starter +description: 领域实体变更代理,通过 CGLIB 代理拦截实体字段变更并自动推送领域事件 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)实践中,领域实体的状态变更需要产生对应的领域事件(创建、变更、删除、持久化),以便下游处理器执行副作用操作(如发送通知、更新缓存、触发工作流等)。然而手动在每个 setter 方法中编写事件推送代码存在以下痛点: + +1. **侵入性强**:每个实体类的修改方法都需要显式调用 `EventPusher.push()`,业务逻辑与事件机制耦合。 +2. **容易遗漏**:开发者可能忘记在某些字段变更后推送事件,导致数据不一致。 +3. **变更追踪困难**:无法自动获取字段的旧值与新值,手动记录增加出错风险。 +4. **嵌套对象变更不可见**:当实体包含子对象时,子对象字段的变更更难被感知和追踪。 + +`domain-proxy` 能力通过 CGLIB 动态代理透明地拦截实体方法调用,自动比较字段值变化并推送 `DomainChangeEvent`,同时提供完整的实体生命周期事件(创建、持久化、删除),让领域事件的发布对业务代码零侵入。 + +## 如何使用 + +### 核心组件 + +| 组件 | 说明 | +|------|------| +| `IDomain` | 领域实体标记接口,提供 `persist()` 和 `delete()` 默认方法,分别推送 `DomainPersistEvent` 和 `DomainDeleteEvent` | +| `DomainProxyFactory` | 静态工厂类,通过 `create(Class, Object... args)` 创建代理实例,同时自动推送 `DomainCreateEvent` | +| `DomainChangeInterceptor` | CGLIB `MethodInterceptor` 实现,拦截带参数的方法调用,对比执行前后字段值差异,自动推送 `DomainChangeEvent` | +| `DomainEvent` | 领域事件基类,携带实体引用、实体类型和时间戳 | +| `DomainCreateEvent` | 实体创建事件,由 `DomainProxyFactory.create()` 自动推送 | +| `DomainChangeEvent` | 实体字段变更事件,包含 `fieldName`、`oldValue`、`newValue` | +| `DomainDeleteEvent` | 实体删除事件,通过 `IDomain.delete()` 推送 | +| `DomainPersistEvent` | 实体持久化事件,通过 `IDomain.persist()` 推送 | + +### 使用步骤 + +1. **定义领域实体**:创建实体类并实现 `IDomain` 接口。实体类需要有公开的构造函数供代理工厂反射调用。 + +2. **通过工厂创建实例**:使用 `DomainProxyFactory.create(EntityClass.class, constructorArgs...)` 代替 `new` 关键字创建实体。工厂会自动创建 CGLIB 代理并推送 `DomainCreateEvent`。 + +3. **正常调用业务方法**:对代理对象调用任何带参数的方法后,拦截器会自动比较所有字段(包括嵌套对象)的值变化,若有变更则推送 `DomainChangeEvent`。 + +4. **触发生命周期事件**:在适当时机调用 `entity.persist()` 推送持久化事件,或调用 `entity.delete()` 推送删除事件。 + +5. **注册事件处理器**:编写 `IHandler`、`IHandler` 等处理器 Bean,框架会自动扫描并注册。 + +### 注意事项 + +- 代理仅拦截**带参数**的方法调用,无参方法(如 getter)不会触发变更检测。 +- 支持基本类型(String、数值、布尔、枚举等)的直接比较,以及嵌套对象的递归字段比较。 +- 事件推送通过 `EventPusher` 进入框架的事件系统,遵循同步/异步分发和循环检测机制。 + +## 使用实例 + +### 定义领域实体 + +```java +public class User implements IDomain { + + @Getter + private final long id; + + @Getter + private String name; + + @Getter + private String email; + + public User(String name, String email) { + this.id = System.currentTimeMillis(); + this.name = name; + this.email = email; + } + + public void changeName(String name) { + this.name = name; + } + + public void changeEmail(String email) { + this.email = email; + } +} +``` + +### 创建代理并触发事件 + +```java +// 通过工厂创建代理实例,自动推送 DomainCreateEvent +User user = DomainProxyFactory.create(User.class, "张三", "zhangsan@example.com"); + +// 调用带参方法后,拦截器自动检测字段变更并推送 DomainChangeEvent +user.changeName("李四"); // → DomainChangeEvent(fieldName="name", oldValue="张三", newValue="李四") +user.changeEmail("li@example.com"); // → DomainChangeEvent(fieldName="email", ...) + +// 手动触发持久化事件 +user.persist(); // → DomainPersistEvent + +// 手动触发删除事件 +user.delete(); // → DomainDeleteEvent +``` + +### 编写事件处理器 + +```java +@Component +public class UserChangeHandler implements IHandler { + + @Override + public void handler(DomainChangeEvent event) { + log.info("用户字段变更: {} = {} → {}", + event.getFieldName(), event.getOldValue(), event.getNewValue()); + } +} + +@Component +public class UserCreateHandler implements IHandler { + + @Override + public void handler(DomainCreateEvent event) { + log.info("新用户创建: {}", event.getEntity().getClass().getSimpleName()); + } +} +``` diff --git a/docs/agents/capabilities/springboot-starter/event-system.md b/docs/agents/capabilities/springboot-starter/event-system.md new file mode 100644 index 000000000..5e7ea7ead --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/event-system.md @@ -0,0 +1,272 @@ +--- +name: springboot-starter/event-system +module: springboot-starter +description: 事件发布-订阅系统,支持同步/异步事件、事件循环检测、事务事件 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)架构中,领域事件是实现聚合间解耦、触发副作用的核心机制。然而在实际开发中,事件系统面临以下痛点: + +1. **事件与主业务事务的边界模糊**:开发者容易将事件处理与主业务强绑定,导致事务范围膨胀、性能下降。框架需要明确"事件对主业务可成功可失败"的设计原则。 +2. **同步与异步事件的区分困难**:部分场景要求事件在同一事务内同步执行(如数据校验),部分场景则需异步解耦(如发送通知)。手动管理线程池和执行模式增加复杂度。 +3. **事件循环调用风险**:当 Handler A 处理 EventX 时又推送了 EventY,而 EventY 的 Handler 又推送了 EventX,就会形成无限循环。缺乏自动检测机制会导致栈溢出或系统挂起。 +4. **多 Handler 执行的异常隔离**:同一事件可能被多个 Handler 订阅,某个 Handler 抛异常不应影响其他 Handler 的执行,同时需要收集所有异常统一上报。 +5. **Handler 注册与排序繁琐**:手动注册处理器、维护执行顺序的代码分散且易出错。 + +本模块提供了一套完整的发布-订阅事件基础设施,通过 `EventPusher` 统一入口、`IEvent` 类型体系区分同步/异步、`EventTraceContext` + `EventStackContext` 实现事件链路追踪与循环检测、`ApplicationHandlerUtils` 自动匹配并按序分发 Handler,以及可选的事务提交后触发模式(`SpringTransactionEventHandler`)。 + +## 如何使用 + +### 核心接口 + +| 接口/类 | 说明 | +|---------|------| +| `IEvent` | 事件标记接口,继承 `Serializable`。默认视为同步事件 | +| `ISyncEvent` | 同步事件标记接口,继承 `IEvent`。事件在当前线程同步执行 | +| `IAsyncEvent` | 异步事件标记接口,继承 `IEvent`。事件在线程池中异步执行 | +| `IHandler` | 事件处理器接口,泛型 `T` 指定订阅的事件类型 | +| `EventPusher` | 事件推送静态工具类,是发布事件的唯一入口 | +| `@Handler` | 注解,标记一个类为事件处理器 Bean(也可使用 `@Component`/`@Service`) | + +### IHandler 接口方法 + +```java +public interface IHandler { + // 执行顺序,数值越小越先执行,默认为 0 + default int order() { return 0; } + + // 事件处理逻辑 + void handler(T event); + + // 异常回调,默认重新抛出异常(阻止后续 Handler 执行) + // 可覆盖此方法实现异常吞没或自定义处理 + default void error(Exception exception) throws Exception { + throw exception; + } +} +``` + +### 事件推送 + +```java +// 推送事件(自动根据 IEvent 子接口判断同步/异步) +EventPusher.push(new MyEvent(data)); + +// 显式声明允许循环事件(跳过循环检测) +EventPusher.push(new MyEvent(data), true); +``` + +事件类型的判定规则: +- 实现 `IAsyncEvent` → 异步执行(线程池) +- 实现 `ISyncEvent` → 同步执行(当前线程) +- 仅实现 `IEvent` → 默认同步执行 + +### 配置项 + +```properties +# 启用事务事件模式(事件在事务提交后触发) +codingapi.framework.event.transaction.enable=true + +# 异步事件线程池大小(默认值见 PropertiesContext) +codingapi.framework.handler-thread-pool-size=20 +``` + +### 事件分发模式 + +框架通过 `SpringHandlerConfiguration` 自动装配事件分发器: + +- **默认模式**(`SpringDefaultEventHandler`):使用 Spring `@EventListener` 即时触发,同步事件在当前线程执行,异步事件提交到固定大小线程池。 +- **事务模式**(`SpringTransactionEventHandler`):当配置 `codingapi.framework.event.transaction.enable=true` 时激活,使用 `@TransactionalEventListener(phase = AFTER_COMMIT)` 确保事件在事务提交后才触发,避免读取到未提交的数据。设置 `fallbackExecution = true` 保证无事务上下文时也能正常执行。 + +两种模式互斥,事务模式优先(`@ConditionalOnMissingBean` 兜底默认模式)。 + +### 事件循环检测 + +框架通过 `EventTraceContext` 和 `EventStackContext` 实现同一条事件链路内的循环检测: + +1. 每次推送事件时生成/复用 traceId,将事件类压入该 traceId 对应的事件栈。 +2. 若同一 traceId 下出现相同事件类,立即抛出 `EventLoopException`,并附带完整的事件调用栈信息。 +3. 事件处理完成后自动清理 traceId 和事件栈。 +4. 若确实需要允许循环(如状态机重试),可调用 `EventPusher.push(event, true)` 跳过检测。 + +### Handler 异常处理策略 + +当某个 Handler 抛出异常时: +1. 若异常为 `EventLoopException`,直接向上抛出,终止后续所有 Handler。 +2. 否则调用该 Handler 的 `error(exception)` 回调。 +3. 若 `error()` 也抛出异常,标记为"有异常"并继续执行下一个 Handler。 +4. 所有 Handler 执行完毕后,若存在异常,统一包装为 `EventException` 抛出。 + +## 使用实例 + +### 1. 定义事件 + +```java +package com.example.domain.order.event; + +import com.codingapi.springboot.framework.event.ISyncEvent; +import lombok.Getter; +import lombok.AllArgsConstructor; + +/** + * 订单创建事件(同步) + */ +@Getter +@AllArgsConstructor +public class OrderCreatedEvent implements ISyncEvent { + private final String orderId; + private final String userId; + private final long amount; +} +``` + +```java +package com.example.domain.order.event; + +import com.codingapi.springboot.framework.event.IAsyncEvent; +import lombok.Getter; +import lombok.AllArgsConstructor; + +/** + * 订单通知事件(异步) + */ +@Getter +@AllArgsConstructor +public class OrderNotifyEvent implements IAsyncEvent { + private final String orderId; + private final String message; +} +``` + +### 2. 编写事件处理器 + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderCreatedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 订单创建后扣减库存(优先执行) + */ +@Slf4j +@Service +public class InventoryDeductHandler implements IHandler { + + @Override + public int order() { + return 1; // 最先执行 + } + + @Override + public void handler(OrderCreatedEvent event) { + log.info("扣减库存, orderId={}, amount={}", event.getOrderId(), event.getAmount()); + // inventoryService.deduct(event.getOrderId(), event.getAmount()); + } +} +``` + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderCreatedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 订单创建后记录积分(其次执行) + */ +@Slf4j +@Service +public class PointsRecordHandler implements IHandler { + + @Override + public int order() { + return 2; + } + + @Override + public void handler(OrderCreatedEvent event) { + log.info("记录积分, userId={}", event.getUserId()); + // pointsService.record(event.getUserId(), event.getAmount()); + } + + @Override + public void error(Exception exception) { + // 积分记录失败不影响其他 Handler,仅记录日志 + log.error("积分记录失败, 但不阻断流程", exception); + } +} +``` + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderNotifyEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 异步发送订单通知 + */ +@Slf4j +@Service +public class OrderNotifyHandler implements IHandler { + + @Override + public void handler(OrderNotifyEvent event) { + log.info("发送通知, orderId={}, message={}", event.getOrderId(), event.getMessage()); + // notificationService.send(event.getOrderId(), event.getMessage()); + } +} +``` + +### 3. 在领域服务中推送事件 + +```java +package com.example.domain.order.service; + +import com.codingapi.springboot.framework.event.EventPusher; +import com.example.domain.order.event.OrderCreatedEvent; +import com.example.domain.order.event.OrderNotifyEvent; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OrderService { + + @Transactional + public String createOrder(String userId, long amount) { + String orderId = generateOrderId(); + // ... 持久化订单实体 ... + + // 同步事件:在当前事务内执行库存扣减、积分记录 + EventPusher.push(new OrderCreatedEvent(orderId, userId, amount)); + + // 异步事件:事务外异步发送通知 + EventPusher.push(new OrderNotifyEvent(orderId, "您的订单已创建")); + + return orderId; + } +} +``` + +### 4. 启用事务事件模式(可选) + +若希望所有事件都在事务提交后再触发(避免 Handler 读到未提交数据),在 `application.properties` 中添加: + +```properties +codingapi.framework.event.transaction.enable=true +``` + +此时 `SpringTransactionEventHandler` 替代默认的 `SpringDefaultEventHandler`,所有事件将在 `AFTER_COMMIT` 阶段触发。 diff --git a/docs/agents/capabilities/springboot-starter/exception-handling.md b/docs/agents/capabilities/springboot-starter/exception-handling.md new file mode 100644 index 000000000..347de11b2 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/exception-handling.md @@ -0,0 +1,139 @@ +--- +name: springboot-starter/exception-handling +module: springboot-starter +description: 全局异常处理,统一拦截 Controller 层异常并返回标准 Response +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在 Spring MVC 项目中,Controller 层抛出的异常如果不做统一处理,会导致以下问题: + +- **响应格式不一致**:不同接口返回的错误结构各异(有的返回 HTML 错误页、有的返回纯文本、有的返回自定义 JSON),前端难以统一解析。 +- **错误码缺失**:原生异常只有 message,没有业务错误码(errCode),前端无法根据错误码做多语言提示或差异化逻辑。 +- **国际化困难**:错误信息硬编码在代码中,无法根据用户 Locale 动态切换语言。 +- **敏感信息泄露**:未捕获的异常可能将堆栈信息直接暴露给客户端。 + +`springboot-starter` 的异常处理能力通过 `HandlerExceptionResolver` 机制,在 Servlet 层面统一拦截所有 Controller 异常,将其转换为标准的 `{success, errCode, errMessage}` JSON 响应,同时结合 `LocaleMessageException` 和 `MessageSource` 实现错误信息的国际化管理。 + +## 如何使用 + +### 自动生效 + +引入 `springboot-starter` 依赖后,异常处理自动配置即可生效,无需额外注解或配置: + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +框架通过以下两个配置类自动注册: + +- **`ExceptionConfiguration`**:注册 `LocaleMessage` Bean,绑定 Spring `MessageSource`,在初始化时将自身注入 `MessageContext` 单例,为 `LocaleMessageException` 提供国际化消息解析能力。 +- **`BasicHandlerExceptionResolverConfiguration`**:当 classpath 中存在 `HandlerExceptionResolver`(即 Spring MVC 环境)时,注册 `ServletExceptionHandler` 作为全局异常解析器。 + +### 异常类型与响应映射 + +| 异常类型 | errCode | errMessage | 说明 | +|---------|---------|------------|------| +| `LocaleMessageException` | 异常中指定的 errCode | 国际化解析后的消息 | 业务异常,推荐使用 | +| 其他 `Exception` | `system.err` | `ex.getMessage()` | 未预期的系统异常 | + +### 抛出业务异常 + +在 Service 或 Controller 中抛出 `LocaleMessageException`,支持三种构造方式: + +```java +// 1. 直接使用错误码(从 messages.properties 中解析消息) +throw new LocaleMessageException("user.not.found"); + +// 2. 错误码 + 占位符参数 +throw LocaleMessageException.of("order.amount.exceed", maxAmount); + +// 3. 错误码 + 自定义消息(不走国际化) +throw new LocaleMessageException("custom.error", "自定义错误描述"); +``` + +### 配置国际化消息 + +在 `src/main/resources/messages.properties`(及对应的多语言文件)中定义错误消息: + +```properties +# messages.properties +user.not.found=用户不存在 +order.amount.exceed=订单金额超出限制,最大金额为 {0} + +# messages_en.properties +user.not.found=User not found +order.amount.exceed=Order amount exceeds limit, maximum is {0} +``` + +## 使用实例 + +### 完整示例:Controller 中抛出业务异常 + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + @Autowired + private UserService userService; + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + User user = userService.findById(id); + if (user == null) { + // 抛出国际化业务异常 + throw new LocaleMessageException("user.not.found"); + } + return SingleResponse.of(user); + } + + @PostMapping + public Response createUser(@RequestBody CreateUserRequest request) { + if (request.getAmount().compareTo(MAX_AMOUNT) > 0) { + // 带占位符参数的异常 + throw LocaleMessageException.of("order.amount.exceed", MAX_AMOUNT); + } + userService.create(request); + return Response.buildSuccess(); + } +} +``` + +当请求触发异常时,框架自动返回统一格式的 JSON 响应: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "用户不存在" +} +``` + +若当前请求的 Locale 为英文,则自动返回: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "User not found" +} +``` + +对于未预期的系统异常(如 NullPointerException),返回: + +```json +{ + "success": false, + "errCode": "system.err", + "errMessage": "Cannot invoke method on null object" +} +``` diff --git a/docs/agents/capabilities/springboot-starter/locale-message.md b/docs/agents/capabilities/springboot-starter/locale-message.md new file mode 100644 index 000000000..fb106df5b --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/locale-message.md @@ -0,0 +1,117 @@ +--- +name: springboot-starter/locale-message +module: springboot-starter +description: 国际化异常消息,支持多语言异常信息和本地化消息解析 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级应用中,异常消息往往需要面向不同语言和地区的用户展示本地化内容。直接在代码中硬编码中文或英文错误提示存在以下痛点: + +- **多语言维护困难**:错误消息散落在业务代码各处,新增语言时需要逐一修改源码。 +- **异常与消息耦合**:`RuntimeException` 只接受字符串消息,无法携带错误码进行统一拦截和翻译。 +- **占位符参数化缺失**:部分错误消息需要动态插入变量(如用户名、金额),拼接字符串容易出错且不利于翻译。 + +`locale-message` 能力通过 `LocaleMessageException` + Spring `MessageSource` 的组合,将错误码作为异常的语义标识,在抛出时自动根据当前请求的 `Locale` 从 `messages.properties` 资源文件中解析出对应的本地化消息,从而实现异常消息与业务代码的解耦和多语言支持。 + +## 如何使用 + +### 核心组件 + +| 类 | 职责 | +|----|------| +| `LocaleMessageException` | 国际化异常,支持仅传错误码、错误码+占位符参数、错误码+自定义消息等多种构造方式 | +| `LocaleMessage` | 封装 Spring `MessageSource`,提供按 code + args + locale 解析消息的能力 | +| `MessageContext` | 单例上下文,持有 `LocaleMessage` 实例,供异常类在静态上下文中获取本地化消息 | + +### 配置消息资源文件 + +在 `src/main/resources/` 下创建标准 Spring 消息资源文件: + +```properties +# messages.properties(默认/回退) +error.user.not.found=User not found: {0} +error.param.invalid=Invalid parameter: {0}, expected {1} + +# messages_zh_CN.properties +error.user.not.found=用户不存在: {0} +error.param.invalid=参数无效: {0},期望值 {1} +``` + +Spring Boot 会自动加载这些文件作为 `MessageSource`。 + +### 抛出国际化异常 + +`LocaleMessageException` 提供多种构造方式和静态工厂方法: + +```java +// 1. 仅传错误码 —— 自动从 messages.properties 解析消息 +throw new LocaleMessageException("error.user.not.found"); + +// 2. 错误码 + 占位符参数 +throw LocaleMessageException.of("error.user.not.found", "zhangsan"); + +// 3. 错误码 + 多个占位符参数 +throw LocaleMessageException.of("error.param.invalid", "age", "Integer"); + +// 4. 错误码 + 自定义消息(不走 MessageSource) +throw new LocaleMessageException("ERR_CUSTOM", "自定义错误消息"); + +// 5. 携带原始异常 +throw new LocaleMessageException("error.user.not.found", cause); +``` + +### 自动装配 + +框架启动时,`LocaleMessage` Bean 会被创建并调用 `init()` 方法,将自身注册到 `MessageContext` 单例中。`LocaleMessageException` 在构造时通过 `MessageContext.getInstance().getErrorMsg(errCode, args)` 获取本地化消息,整个过程对业务代码透明,无需手动注入任何 Bean。 + +当前请求的 `Locale` 由 Spring 的 `LocaleContextHolder` 提供(通常通过 `Accept-Language` 请求头或 `LocaleChangeInterceptor` 设置)。 + +## 使用实例 + +### 完整示例:用户查询服务 + +```java +@Service +public class UserService { + + @Autowired + private UserRepository userRepository; + + public User getUser(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> + LocaleMessageException.of("error.user.not.found", username) + ); + } + + public void updateUserAge(String username, int age) { + if (age < 0 || age > 150) { + throw LocaleMessageException.of("error.param.invalid", "age", "0~150"); + } + // ... + } +} +``` + +当客户端发送 `Accept-Language: zh-CN` 请求时,若用户不存在,返回的异常消息为 `"用户不存在: zhangsan"`;若未指定语言或使用默认 locale,则返回 `"User not found: zhangsan"`。 + +### 在全局异常处理器中统一捕获 + +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(LocaleMessageException.class) + public Response handleLocaleException(LocaleMessageException e) { + return Response.buildFailure(e.getErrCode(), e.getErrMessage()); + } +} +``` + +由于异常对象已经携带了本地化后的 `errMessage`,全局处理器只需直接提取即可,无需再做二次翻译。 diff --git a/docs/agents/capabilities/springboot-starter/page-request.md b/docs/agents/capabilities/springboot-starter/page-request.md new file mode 100644 index 000000000..ebb742188 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/page-request.md @@ -0,0 +1,177 @@ +--- +name: springboot-starter/page-request +module: springboot-starter +description: 动态分页查询请求,扩展 Spring PageRequest 支持 RequestFilter 动态过滤条件 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级后台管理系统中,列表查询是最常见的业务场景之一。Spring Data 原生的 `PageRequest` 仅支持分页参数(页码、大小、排序),无法携带动态过滤条件。开发者通常需要为每个查询接口手动编写 Specification、QueryDSL 或自定义 HQL,导致大量重复的查询构建代码。 + +`PageRequest` 能力解决了以下痛点: + +- **动态过滤**:在分页请求对象上直接附加任意字段、任意比较关系的过滤条件,无需为每种查询组合编写独立方法。 +- **前端驱动查询**:通过 `SearchRequest` 自动解析 HTTP 请求参数(包括 Base64 编码的 `filter`、`sort`、`params` JSON),将前端传入的筛选/排序规则转换为类型安全的过滤条件,减少 Controller 层样板代码。 +- **复杂条件组合**:支持 AND/OR 嵌套组合过滤,满足多条件联合查询需求。 +- **与 FastRepository 无缝集成**:`FastRepository.findAll(PageRequest)` 和 `pageRequest(PageRequest)` 自动根据过滤条件构建 Example 或 HQL 查询,开发者只需关注业务逻辑。 + +## 如何使用 + +### 核心类说明 + +| 类 | 职责 | +|---|------| +| `PageRequest` | 继承 Spring `PageRequest`,增加 `RequestFilter` 和链式 `addFilter` API | +| `RequestFilter` | 过滤条件容器,管理 `Filter` 列表并提供按 key 读取过滤值的快捷方法 | +| `Filter` | 单个过滤条件,包含字段名(key)、比较关系(Relation)和值 | +| `Relation` | 枚举,定义了 14 种比较关系:EQUAL、NOT_EQUAL、LIKE、LEFT_LIKE、RIGHT_LIKE、BETWEEN、IN、NOT_IN、IS_NULL、IS_NOT_NULL、GREATER_THAN、LESS_THAN、GREATER_THAN_EQUAL、LESS_THAN_EQUAL | +| `SearchRequest` | 从当前 `HttpServletRequest` 自动解析分页、排序、过滤参数并生成 `PageRequest` | + +### 创建 PageRequest + +```java +// 基本分页(第 0 页,每页 20 条) +PageRequest request = PageRequest.of(0, 20); + +// 带排序 +PageRequest request = PageRequest.of(0, 20, Sort.by("createTime").descending()); +``` + +### 添加过滤条件 + +```java +// 等值过滤(默认 EQUAL) +request.addFilter("name", "张三"); + +// 指定比较关系 +request.addFilter("age", Relation.GREATER_THAN, 18); +request.addFilter("createTime", Relation.BETWEEN, startDate, endDate); +request.addFilter("status", Relation.IN, "ACTIVE", "PENDING"); + +// AND / OR 组合 +request.andFilter( + Filter.as("deptId", 1), + Filter.as("roleId", 2) +); + +request.orFilters( + Filter.as("name", Relation.LIKE, "张"), + Filter.as("email", Relation.LIKE, "zhang") +); +``` + +### 读取过滤值 + +```java +String name = request.getStringFilter("name"); +int age = request.getIntFilter("age", 0); +boolean hasConditions = request.hasFilter(); +``` + +### 配合 FastRepository 使用 + +```java +// findAll — 简单过滤走 Example 查询 +Page page = userRepository.findAll(request); + +// pageRequest — 复杂过滤走 HQL 动态查询 +Page page = userRepository.pageRequest(request); + +// searchRequest — 从 HTTP 请求自动解析 +Page page = userRepository.searchRequest(searchRequest); +``` + +### SearchRequest 自动解析 + +`SearchRequest` 从当前 HTTP 请求中提取以下参数: + +| 参数 | 格式 | 说明 | +|------|------|------| +| `current` | int | 页码(从 0 开始) | +| `pageSize` | int | 每页大小 | +| `sort` | Base64(JSON) | 排序规则,如 `{"createTime":"descend"}` | +| `filter` | Base64(JSON) | 过滤条件,如 `{"status":["ACTIVE"]}` | +| `params` | Base64(JSON Array) | 指定字段的比较关系,如 `[{"key":"age","type":"GREATER_THAN"}]` | +| 其他参数 | string | 作为 EQUAL 过滤条件自动添加 | + +## 使用实例 + +### 示例 1:Service 层手动构建动态查询 + +```java +@Service +public class UserQueryService { + + @Autowired + private UserRepository userRepository; + + public Page searchUsers(String keyword, Integer minAge, String status) { + PageRequest request = PageRequest.of(0, 20); + request.addSort(Sort.by("createTime").descending()); + + if (keyword != null) { + request.addFilter("name", Relation.LIKE, keyword); + } + if (minAge != null) { + request.addFilter("age", Relation.GREATER_THAN_EQUAL, minAge); + } + if (status != null) { + request.addFilter("status", status); + } + + return userRepository.findAll(request); + } +} +``` + +### 示例 2:Controller 层使用 SearchRequest 自动绑定 + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + @Autowired + private UserRepository userRepository; + + @GetMapping + public MultiResponse list(SearchRequest searchRequest) { + // 自动从 HTTP 参数解析 current、pageSize、sort、filter + Page page = userRepository.searchRequest(searchRequest); + return MultiResponse.of(page.getContent(), (int) page.getTotalElements()); + } +} +``` + +前端请求示例: +``` +GET /api/users?current=0&pageSize=20 + &sort=eyJjcmVhdGVUaW1lIjoiZGVzY2VuZCJ9 + &filter=eyJzdGF0dXMiOlsiQUNUSVZFIiwiUEVORElORyJdfQ== + ¶ms=W3sia2V5IjoiYWdlIiwidHlwZSI6IkdSRUFURVJfVEhBTiJ9XQ== + &deptId=1 +``` + +其中 `filter` 解码后为 `{"status":["ACTIVE","PENDING"]}`,`params` 解码后为 `[{"key":"age","type":"GREATER_THAN"}]`,`deptId` 作为额外 EQUAL 条件自动加入。 + +### 示例 3:AND/OR 组合过滤 + +```java +PageRequest request = PageRequest.of(0, 20); + +// (deptId=1 AND roleId=2) OR (name LIKE '%admin%') +request.orFilters( + Filter.and( + Filter.as("deptId", 1), + Filter.as("roleId", 2) + ), + Filter.as("name", Relation.LIKE, "admin") +); + +Page page = userRepository.pageRequest(request); +``` diff --git a/docs/agents/capabilities/springboot-starter/response-dto.md b/docs/agents/capabilities/springboot-starter/response-dto.md new file mode 100644 index 000000000..e6a12f589 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/response-dto.md @@ -0,0 +1,172 @@ +--- +name: springboot-starter/response-dto +module: springboot-starter +description: 统一响应封装,提供 Response/SingleResponse/MultiResponse/MapResponse 四种响应类型 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级 REST API 开发中,前后端交互需要统一的响应契约。若每个接口各自定义返回结构,会导致以下问题: + +- **前端解析复杂**:不同接口的成功/失败字段命名不一致,前端需逐接口适配 +- **错误处理分散**:缺乏统一的 `errCode` / `errMessage` 规范,全局异常拦截难以标准化 +- **分页结构不统一**:列表接口的总数、数据字段名各异,通用分页组件无法复用 +- **序列化行为不可控**:响应对象在缓存、消息队列等场景下需要可靠的 JSON 序列化能力 + +`response-dto` 提供了四种标准响应类型,覆盖无数据、单对象、列表/分页、键值对四类返回场景,使所有 API 输出遵循同一契约。基类 `Response` 实现了 `JsonSerializable` 接口,支持通过 Fastjson 进行一致的 JSON 序列化。 + +## 如何使用 + +### 类层次结构 + +``` +JsonSerializable (interface) + └── Response — 基础响应(success / errCode / errMessage) + ├── SingleResponse — 单对象响应,data 字段为泛型 T + ├── MultiResponse — 列表响应,data 字段包含 total + list + └── MapResponse — 键值对响应,data 字段为 Map +``` + +### 核心 API + +| 类 | 静态工厂方法 | 说明 | +|---|---|---| +| `Response` | `buildSuccess()` | 构建成功响应(无业务数据) | +| `Response` | `buildFailure(errCode, errMessage)` | 构建失败响应 | +| `SingleResponse` | `of(T data)` | 包装单个业务对象 | +| `SingleResponse` | `empty()` | 返回 data=null 的成功响应 | +| `MultiResponse` | `of(Collection data, long total)` | 包装集合 + 总数 | +| `MultiResponse` | `of(Page page)` | 直接包装 Spring Data Page 对象 | +| `MultiResponse` | `of(Collection data)` | 包装集合,total 自动取 size | +| `MultiResponse` | `empty()` | 返回空列表的成功响应 | +| `MapResponse` | `create()` | 创建空的键值对响应 | +| `MapResponse` | `empty()` | 返回 data=null 的键值对响应 | +| `MapResponse` | `add(key, value)` | 链式添加键值对 | + +### JSON 序列化 + +所有响应类均继承自 `Response`,而 `Response` 实现了 `JsonSerializable` 接口,可直接调用 `toJson()` 方法获取 JSON 字符串: + +```java +String json = response.toJson(); // 使用 Fastjson 序列化 +``` + +### Maven 依赖 + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +## 使用实例 + +### 1. 无数据的操作响应 + +```java +@PostMapping("/save") +public Response save(@RequestBody UserRequest request) { + userService.save(request); + return Response.buildSuccess(); +} +``` + +返回 JSON: +```json +{ "success": true, "errCode": null, "errMessage": null } +``` + +### 2. 返回单个对象 + +```java +@GetMapping("/detail") +public SingleResponse detail(@RequestParam Long id) { + UserVO user = userQueryService.getById(id); + return SingleResponse.of(user); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { "id": 1, "name": "张三", "email": "zhangsan@example.com" } +} +``` + +### 3. 返回列表(含分页) + +```java +@GetMapping("/list") +public MultiResponse list(SearchRequest searchRequest) { + Page page = userQueryService.page(searchRequest); + return MultiResponse.of(page); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "total": 100, + "list": [ + { "id": 1, "name": "张三" }, + { "id": 2, "name": "李四" } + ] + } +} +``` + +### 4. 返回键值对数据 + +```java +@GetMapping("/dashboard/stats") +public MapResponse dashboardStats() { + return MapResponse.create() + .add("userCount", 1024) + .add("orderCount", 5678) + .add("todayRevenue", 99800.50); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "userCount": 1024, + "orderCount": 5678, + "todayRevenue": 99800.50 + } +} +``` + +### 5. 返回错误响应 + +```java +@PostMapping("/login") +public Response login(@RequestBody LoginRequest request) { + if (!userService.authenticate(request)) { + return Response.buildFailure("AUTH_FAILED", "用户名或密码错误"); + } + return Response.buildSuccess(); +} +``` + +返回 JSON: +```json +{ "success": false, "errCode": "AUTH_FAILED", "errMessage": "用户名或密码错误" } +``` diff --git a/docs/agents/capabilities/springboot-starter/rest-client.md b/docs/agents/capabilities/springboot-starter/rest-client.md new file mode 100644 index 000000000..fa92c6fc0 --- /dev/null +++ b/docs/agents/capabilities/springboot-starter/rest-client.md @@ -0,0 +1,108 @@ +--- +name: springboot-starter/rest-client +module: springboot-starter +description: REST 客户端封装,提供 RestTemplate 上下文管理和 HTTPS 信任所有证书支持 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业内部系统集成和第三方 API 对接场景中,经常需要调用外部 HTTPS 服务。这些服务的证书可能由私有 CA 签发、已过期或主机名不匹配,导致标准 HTTP 客户端抛出 SSL 握手异常。同时,项目中多处使用 `RestTemplate` 时,如果每次都手动创建实例并配置底层 HttpClient,会导致代码重复且难以统一维护。 + +本能力通过两个核心类解决上述问题: + +- **TrustAnyHttpClientFactory** — 创建信任所有证书的 Apache HttpClient 5 实例,跳过服务端证书校验和主机名验证,适用于开发调试及内网可信环境下的 HTTPS 调用。 +- **RestTemplateContext** — 以单例模式持有预配置好的 `RestTemplate`(底层使用 TrustAnyHttpClientFactory),为全应用提供统一的 REST 客户端访问入口,避免重复创建和配置。 + +## 如何使用 + +### 依赖引入 + +在 Maven 项目中添加 springboot-starter 依赖即可: + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +### 获取 RestTemplate 实例 + +通过 `RestTemplateContext` 的单例方法获取已配置好 HTTPS 信任策略的 `RestTemplate`: + +```java +RestTemplate restTemplate = RestTemplateContext.getInstance().getRestTemplate(); +``` + +该实例底层使用 Apache HttpComponents 5 作为传输层,并已启用以下特性: + +- TLS 协议下信任所有服务端证书(`TrustAnyTrustManager`) +- 禁用主机名验证(`NoopHostnameVerifier`) +- 允许循环重定向 + +### 自定义 ClientHttpRequestFactory + +如果需要替换默认的请求工厂,可以使用 `restTemplate(ClientHttpRequestFactory)` 方法创建新的 `RestTemplate` 实例: + +```java +ClientHttpRequestFactory customFactory = new HttpComponentsClientHttpRequestFactory(customHttpClient); +RestTemplate customTemplate = RestTemplateContext.getInstance().restTemplate(customFactory); +``` + +### 直接使用 TrustAnyHttpClientFactory + +若仅需创建信任所有证书的 HttpClient 而不使用 RestTemplateContext 封装,可直接调用静态工厂方法: + +```java +HttpClient httpClient = TrustAnyHttpClientFactory.createTrustAnyHttpClient(); +``` + +返回的 `HttpClient` 基于 Apache HttpClient 5,可用于任何需要绕过 SSL 校验的场景。 + +## 使用实例 + +### 示例 1:调用外部 HTTPS 接口 + +```java +@Service +public class ExternalApiService { + + private final RestTemplate restTemplate; + + public ExternalApiService() { + this.restTemplate = RestTemplateContext.getInstance().getRestTemplate(); + } + + public String fetchRemoteData(String url) { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return response.getBody(); + } + + public T postJson(String url, Object request, Class responseType) { + return restTemplate.postForObject(url, request, responseType); + } +} +``` + +### 示例 2:独立使用 TrustAnyHttpClientFactory + +```java +// 在不依赖 RestTemplateContext 的场景下,单独创建信任所有证书的 HttpClient +HttpClient httpClient = TrustAnyHttpClientFactory.createTrustAnyHttpClient(); + +// 配合 Spring 的 HttpComponentsClientHttpRequestFactory 构建自定义 RestTemplate +HttpComponentsClientHttpRequestFactory factory = + new HttpComponentsClientHttpRequestFactory(httpClient); +factory.setConnectTimeout(Duration.ofSeconds(5)); +factory.setReadTimeout(Duration.ofSeconds(30)); + +RestTemplate restTemplate = new RestTemplate(factory); +String result = restTemplate.getForObject("https://internal-api.example.com/data", String.class); +``` + +> ⚠️ **安全提示**:信任所有证书仅适用于开发调试和内网可信环境。在生产环境中应正确配置受信任的 CA 证书链,避免中间人攻击风险。 diff --git a/docs/agents/conventions/springboot-starter-data-fast/dynamic-query-convention.md b/docs/agents/conventions/springboot-starter-data-fast/dynamic-query-convention.md new file mode 100644 index 000000000..3ee7ca78c --- /dev/null +++ b/docs/agents/conventions/springboot-starter-data-fast/dynamic-query-convention.md @@ -0,0 +1,148 @@ +--- +name: springboot-starter-data-fast/dynamic-query-convention +module: springboot-starter-data-fast +description: 动态查询规范,定义 PageRequest + RequestFilter + FastRepository 的使用模式 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter-data-fast +import: "com.codingapi.springboot:springboot-starter-data-fast" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在企业级应用中,列表查询往往需要根据前端传入的动态条件进行过滤。如果不遵守本规范,会导致以下问题: + +1. **SQL 注入风险**:在 Service 层手动拼接 SQL 字符串实现动态查询,极易引入 SQL 注入漏洞。 +2. **代码重复与维护困难**:每个实体的动态查询都手写 `if-else` 判断和原生 SQL,导致大量重复代码,新增过滤字段需修改多处。 +3. **分页逻辑不一致**:各模块自行处理分页参数(偏移量计算、排序),缺乏统一标准,容易出现边界错误。 +4. **CQRS 职责混乱**:查询逻辑与命令逻辑耦合在同一 Service 中,违反读写分离原则,增加系统复杂度。 +5. **类型安全缺失**:使用 Map 或 JSON 传递查询条件时,缺乏编译期检查,运行时才发现字段名拼写错误或类型不匹配。 +6. **框架能力浪费**:不使用 `FastRepository` 提供的自动 Example/HQL 构建能力,绕过框架自建查询机制,失去统一的 SQL 拦截(如数据权限)支持。 + +## 如何使用 + +### 核心规则 + +1. **列表查询统一使用 `PageRequest` 构建分页参数** + - 通过 `PageRequest.of(page, size)` 或 `new PageRequest()` 创建请求对象。 + - 禁止直接使用 Spring Data 原生 `org.springframework.data.domain.PageRequest` 来承载业务过滤条件。 + +2. **动态过滤条件通过 `PageRequest.addFilter()` 方法添加** + - 简单等值过滤:`pageRequest.addFilter("name", "张三")`,默认使用 `Relation.EQ`。 + - 指定关系过滤:`pageRequest.addFilter("age", Relation.GT, 18)`。 + - 组合过滤:使用 `andFilter(Filter...)` 和 `orFilters(Filter...)` 构建复杂条件。 + +3. **过滤关系使用 `Relation` 枚举** + - 可用关系包括:`EQ`、`GT`、`LT`、`GTE`、`LTE`、`LIKE`、`IN` 等。 + - 所有过滤关系必须通过枚举表达,禁止硬编码字符串比较运算符。 + +4. **Repository 接口需继承 `FastRepository`** + - 实体 Repository 必须扩展 `FastRepository` 以获得动态查询能力。 + - `FastRepository` 同时继承了 `JpaRepository`、`JpaSpecificationExecutor`、`DynamicRepository` 和 `DynamicNativeRepository`。 + +5. **调用 `FastRepository.findAll(PageRequest)` 执行动态查询** + - 当 `PageRequest` 包含 Filter 时,自动通过 `ExampleBuilder` 构建 Example 查询。 + - 需要 HQL 级别查询时,使用 `pageRequest(PageRequest)` 方法,自动通过 `DynamicSQLBuilder` 生成 HQL。 + - 禁止绕过 `FastRepository` 提供的默认方法自行编写动态查询逻辑。 + +6. **禁止在 Service 层手写原生 SQL 实现动态查询** + - 不允许使用 `@Query(nativeQuery = true)` 或 `EntityManager.createNativeQuery()` 拼接动态条件。 + - 所有动态过滤必须委托给 `FastRepository` 的 Filter 机制完成。 + +7. **查询服务(CQRS Query 侧)应独立于命令服务** + - 查询服务放在 `*-app-query` 模块中,仅负责读取操作。 + - 命令服务放在 `*-app-cmd-*` 模块中,负责写入与领域编排。 + - 两者不得互相依赖或合并为同一个类。 + +### 命名约定 + +- 查询服务类命名:`{Entity}QueryService`,位于 `*.query` 包下。 +- 命令服务类命名:`{Entity}CmdService`,位于 `*.cmd` 包下。 +- Controller 中的列表查询方法参数类型统一为 `PageRequest`。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. Repository 继承 FastRepository +public interface UserRepository extends FastRepository { +} + +// 2. 查询服务独立于命令服务 +@Service +public class UserQueryService { + + @Autowired + private UserRepository userRepository; + + public Page listUsers(String name, Integer minAge, int page, int size) { + PageRequest request = PageRequest.of(page, size); + + // 动态添加过滤条件 + if (StringUtils.hasText(name)) { + request.addFilter("name", Relation.LIKE, name); + } + if (minAge != null) { + request.addFilter("age", Relation.GTE, minAge); + } + + // 委托 FastRepository 自动构建查询 + return userRepository.findAll(request); + } +} + +// 3. Controller 接收 PageRequest +@GetMapping("/users") +public MultiResponse list( + @RequestParam(required = false) String name, + @RequestParam(required = false) Integer minAge, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = userQueryService.listUsers(name, minAge, page, size); + return ResponseUtils.toMultiResponse(result); +} +``` + +### ❌ 错误示例 + +```java +// 错误 1: Repository 未继承 FastRepository,丧失动态查询能力 +public interface UserRepository extends JpaRepository { +} + +// 错误 2: Service 中手写原生 SQL 拼接动态条件 +@Service +public class UserService { + + @PersistenceContext + private EntityManager entityManager; + + public List listUsers(String name, Integer minAge) { + StringBuilder sql = new StringBuilder("SELECT * FROM users WHERE 1=1"); + if (name != null) { + sql.append(" AND name LIKE '%").append(name).append("%'"); // SQL 注入风险! + } + if (minAge != null) { + sql.append(" AND age >= ").append(minAge); + } + return entityManager.createNativeQuery(sql.toString(), User.class).getResultList(); + } +} + +// 错误 3: 查询与命令逻辑混合在同一个 Service 中 +@Service +public class UserService { + public Page list(...) { /* 查询逻辑 */ } + public void createUser(...) { /* 命令逻辑 */ } + public void deleteUser(...) { /* 命令逻辑 */ } +} + +// 错误 4: 使用 Spring Data 原生 PageRequest,无法承载 Filter +@GetMapping("/users") +public Page list(org.springframework.data.domain.PageRequest pageable) { + // 丢失了框架的动态过滤能力 + return userRepository.findAll(pageable); +} +``` diff --git a/docs/agents/conventions/springboot-starter/ddd-layered-architecture.md b/docs/agents/conventions/springboot-starter/ddd-layered-architecture.md new file mode 100644 index 000000000..8245f78c1 --- /dev/null +++ b/docs/agents/conventions/springboot-starter/ddd-layered-architecture.md @@ -0,0 +1,225 @@ +--- +name: springboot-starter/ddd-layered-architecture +module: springboot-starter +description: DDD 分层架构规范,定义 interface/app/domain/infra 四层的依赖规则和职责划分 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +不遵守 DDD 分层架构规范会导致以下问题: + +1. **领域逻辑污染**:domain 层直接引用 infra 层实现类(如 JPA Repository、Redis Client),导致业务规则与基础设施耦合,无法独立演进和测试。 +2. **依赖方向混乱**:若 domain 反向依赖 app 或 interface,修改一个 API 接口可能牵连整个领域模型重构,变更成本指数级增长。 +3. **CQRS 读写混淆**:查询服务与命令服务混杂在同一模块中,复杂查询被迫走聚合根加载全量数据,性能瓶颈难以定位和优化。 +4. **接口层职责膨胀**:Controller 中直接编排业务流程、拼装 DTO、处理事件,导致接口层成为"上帝类",复用性和可维护性急剧下降。 +5. **Repository 契约缺失**:domain 层直接使用具体持久化实现而非接口,切换存储方案(如从 MySQL 迁移到 MongoDB)需要改动所有调用方代码。 + +## 如何使用 + +### 四层架构与依赖方向 + +``` +interface → app → domain ← infra +``` + +| 层级 | 模块命名约定 | 核心职责 | 允许依赖 | +|------|-------------|---------|---------| +| **interface**(接口层) | `*-interface` | Controller(REST API)、Handler(事件处理器)、Runner(启动任务) | app | +| **app**(应用层) | `*-app-query` / `*-app-cmd-*` | query:CQRS 查询服务;cmd:命令服务与领域编排 | domain | +| **domain**(领域层) | `*-domain-*` | Entity、ValueObject、DomainService、Repository 接口、DomainEvent | 无外部框架 | +| **infra**(基础设施层) | `*-infra-*` | Repository 实现、Gateway 实现、外部服务适配、持久化配置 | domain | + +### 核心规则 + +1. **依赖方向严格单向**:`interface → app → domain ← infra`。禁止反向依赖和跨层依赖。 +2. **domain 层纯业务**:domain 层只包含领域模型和业务逻辑,不依赖 Spring、JPA、Redis 等任何外部框架。 +3. **Repository 接口在 domain,实现在 infra**:domain 层定义 Repository 接口,infra 层提供具体实现并通过 Spring Bean 注入。 +4. **app 层按 CQRS 拆分**:查询服务(query)与命令服务(cmd)分模块,query 侧可直接读取视图/DTO,cmd 侧通过聚合根执行业务操作。 +5. **interface 层仅做适配**:Controller 只做参数校验、权限检查和响应封装,业务编排委托给 app 层;Handler 只负责事件监听与转发。 +6. **禁止 domain 引用 infra 实现类**:domain 层不得 import infra 包下的任何类,包括具体的 Repository 实现、DAO、Client 等。 + +### 包结构约定 + +``` +{bounded-context}/ + {context}-interface/ # 接口层 + controller/ + handler/ + runner/ + {context}-app/ # 应用层 + {context}-app-query/ # CQRS Query 侧 + {context}-app-cmd-domain/ # CQRS Command 侧(领域编排) + {context}-domain/ # 领域层 + model/ # Entity, ValueObject + repository/ # Repository 接口 + service/ # DomainService + event/ # DomainEvent + {context}-infra/ # 基础设施层 + jpa/ # Repository 实现 + gateway/ # 外部服务适配 +``` + +## 使用实例 + +### ✅ 正确示例 + +**domain 层定义 Repository 接口:** + +```java +// example-domain-user/src/.../repository/UserRepository.java +package com.example.domain.user.repository; + +public interface UserRepository { + User findById(Long id); + void save(User user); +} +``` + +**infra 层提供 Repository 实现:** + +```java +// example-infra-jpa/src/.../jpa/UserRepositoryImpl.java +package com.example.infra.jpa; + +import com.example.domain.user.repository.UserRepository; +import org.springframework.stereotype.Repository; + +@Repository +public class UserRepositoryImpl implements UserRepository { + private final UserJpaDao userJpaDao; + + @Override + public User findById(Long id) { + return userJpaDao.findById(id).map(UserConverter::toDomain).orElse(null); + } + + @Override + public void save(User user) { + userJpaDao.save(UserConverter.toEntity(user)); + } +} +``` + +**app 层按 CQRS 拆分:** + +```java +// example-app-query: 查询服务,直接返回 DTO +@Service +public class UserQueryService { + private final UserReadDao userReadDao; // 可直接读视图 + + public SingleResponse getUser(Long id) { + return SingleResponse.of(userReadDao.findUserDTO(id)); + } +} + +// example-app-cmd-domain: 命令服务,通过聚合根操作 +@Service +public class UserCommandService { + private final UserRepository userRepository; // domain 层接口 + + @Transactional + public Response createUser(CreateUserCmd cmd) { + User user = User.create(cmd.getName(), cmd.getEmail()); + userRepository.save(user); + return Response.success(); + } +} +``` + +**interface 层仅做适配:** + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + private final UserQueryService queryService; + private final UserCommandService commandService; + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + return queryService.getUser(id); // 委托 app 层 + } + + @PostMapping + public Response createUser(@Valid @RequestBody CreateUserCmd cmd) { + return commandService.createUser(cmd); // 委托 app 层 + } +} +``` + +### ❌ 错误示例 + +**domain 层直接引用 infra 实现类:** + +```java +// ❌ domain 层 import 了 infra 包的类 +package com.example.domain.user.service; + +import com.example.infra.jpa.UserJpaDao; // 违规!domain 不应依赖 infra + +@Service +public class UserService { + private final UserJpaDao userJpaDao; // 应使用 UserRepository 接口 + + public User findUser(Long id) { + return userJpaDao.findById(id).orElse(null); + } +} +``` + +**依赖方向反转:** + +```java +// ❌ domain 层反向依赖 app 层 +package com.example.domain.user.model; + +import com.example.app.cmd.domain.UserService; // 违规!domain 不应依赖 app + +public class User { + public void activate() { + UserService userService = ...; // 领域对象不应调用应用服务 + userService.sendActivationEmail(this); + } +} +``` + +**app 层未拆分 CQRS,查询走聚合根:** + +```java +// ❌ 查询和命令混在一起,列表查询加载完整聚合根 +@Service +public class UserService { + private final UserRepository userRepository; + + // 列表查询不应加载完整 User 聚合根 + public List listUsers(String keyword) { + return userRepository.findAll().stream() + .filter(u -> u.getName().contains(keyword)) + .collect(Collectors.toList()); // 内存过滤,性能灾难 + } +} +``` + +**Controller 中直接编排业务逻辑:** + +```java +// ❌ Controller 承担了本应属于 app 层的职责 +@PostMapping("/orders") +public Response createOrder(@RequestBody OrderCmd cmd) { + User user = userRepository.findById(cmd.getUserId()); // 应在 app 层 + Product product = productRepository.findById(cmd.getProductId()); // 应在 app 层 + if (product.getStock() < cmd.getQuantity()) { // 业务校验应在 domain + return Response.error("库存不足"); + } + Order order = new Order(user, product, cmd.getQuantity()); // 领域创建应在 app/cmd + orderRepository.save(order); // 应在 app 层 + eventPusher.push(new OrderCreatedEvent(order)); // 事件推送应在 app 层 + return Response.success(order.getId()); +} +``` diff --git a/docs/agents/conventions/springboot-starter/event-driven-convention.md b/docs/agents/conventions/springboot-starter/event-driven-convention.md new file mode 100644 index 000000000..c53b767cc --- /dev/null +++ b/docs/agents/conventions/springboot-starter/event-driven-convention.md @@ -0,0 +1,217 @@ +--- +name: springboot-starter/event-driven-convention +module: springboot-starter +description: 事件驱动开发规范,定义 IEvent/IHandler 的使用约定和事件处理模式 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +不遵守事件驱动开发规范会导致以下问题: + +1. **事务耦合风险**:将事件处理与主业务事务强绑定,导致分支业务的失败回滚整个主事务,破坏领域事件的独立性原则。当分支逻辑(如通知、日志、缓存更新)出错时,不应影响核心业务流程的提交。 + +2. **事件类型混乱**:不区分同步事件与异步事件,导致关键业务事件被异步执行产生时序问题,或非关键事件被同步阻塞影响主流程性能。 + +3. **Handler 注册失效**:未通过 `@Service` 注解将 Handler 注册为 Spring Bean,导致框架无法自动发现处理器,事件推送后无响应且难以排查。 + +4. **执行顺序不可控**:多个 Handler 订阅同一事件时未指定 `order()`,导致处理顺序依赖 Bean 加载顺序,在不同环境或重启后行为不一致。 + +5. **异常处理缺失**:未实现 `error()` 回调或直接在 `handler()` 中抛出未捕获异常,导致后续 Handler 被跳过且错误信息丢失,事件链中断而无法感知。 + +6. **绕过 EventPusher 直接调用**:手动实例化 Handler 或直接调用处理方法,绕过框架的循环检测、线程池调度和上下文传递机制,引发循环事件死锁或丢失链路追踪信息。 + +## 如何使用 + +### 规则 1:事件类必须实现 IEvent 接口 + +所有事件类必须实现 `IEvent` 接口。根据执行方式选择子接口: + +- **同步事件**:实现 `ISyncEvent`,在当前线程内按顺序执行所有 Handler +- **异步事件**:实现 `IAsyncEvent`,由框架线程池异步调度执行 + +事件类应为纯数据载体,不包含业务逻辑,并实现 `Serializable` 以支持序列化传输。 + +### 规则 2:事件处理器必须实现 IHandler 接口 + +Handler 通过泛型参数声明订阅的事件类型。框架在启动时通过 `HandlerBeanDefinitionRegistrar` 自动扫描所有 `IHandler` 实现并注册到 `ApplicationHandlerUtils`。 + +```java +public interface IHandler { + default int order() { return 0; } + void handler(T event); + default void error(Exception exception) throws Exception { throw exception; } +} +``` + +### 规则 3:事件处理器通过 @Service 注解注册为 Spring Bean + +Handler 必须标注 `@Service`(或 `@Component`)注解,确保被 Spring 容器管理。未注册为 Bean 的 Handler 不会被框架发现和调用。 + +### 规则 4:事件推送统一使用 EventPusher.push() 静态方法 + +所有事件推送必须通过 `EventPusher.push(event)` 发起,禁止手动调用 Handler。框架内置循环事件检测机制,当检测到事件循环推送时自动抛出异常。若确认需要允许循环事件,可使用 `EventPusher.push(event, true)` 关闭检测。 + +### 规则 5:事件不应与主业务强耦合事务绑定 + +事件处理的核心理念是**解耦**。事件对于主业务来说可成功可失败,成功与失败都不应强关联主体业务。若分支逻辑必须与主事务保持一致,应直接使用服务调用而非事件机制。 + +### 规则 6:多个 Handler 通过 order() 方法控制执行顺序 + +当多个 Handler 订阅同一事件时,通过重写 `order()` 方法指定执行优先级。数值越小越先执行,默认值为 0。相同 order 值的 Handler 执行顺序不确定。 + +### 规则 7:Handler 的 error() 回调处理异常 + +`error()` 方法接收 Handler 执行过程中抛出的异常。默认实现会重新抛出异常,这将**阻止后续 Handler 的执行**。若希望某个 Handler 的失败不影响其他 Handler,应在 `error()` 中记录日志而不重新抛出。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. 定义同步事件 +public class UserCreatedEvent implements ISyncEvent { + private final String userId; + private final String username; + + public UserCreatedEvent(String userId, String username) { + this.userId = userId; + this.username = username; + } + + public String getUserId() { return userId; } + public String getUsername() { return username; } +} + +// 2. 定义异步事件 +public class UserNotificationEvent implements IAsyncEvent { + private final String userId; + private final String message; + + public UserNotificationEvent(String userId, String message) { + this.userId = userId; + this.message = message; + } + + public String getUserId() { return userId; } + public String getMessage() { return message; } +} + +// 3. 注册 Handler 并指定执行顺序 +@Service +public class UserCacheRefreshHandler implements IHandler { + + @Override + public int order() { + return 1; // 优先刷新缓存 + } + + @Override + public void handler(UserCreatedEvent event) { + cacheService.refreshUserCache(event.getUserId()); + } + + @Override + public void error(Exception exception) { + // 缓存刷新失败不影响后续 Handler,仅记录日志 + log.warn("缓存刷新失败: {}", exception.getMessage()); + } +} + +@Service +public class UserAuditLogHandler implements IHandler { + + @Override + public int order() { + return 2; // 缓存之后写审计日志 + } + + @Override + public void handler(UserCreatedEvent event) { + auditService.logUserCreation(event.getUserId(), event.getUsername()); + } +} + +// 4. 在业务中推送事件 +@Service +public class UserService { + + public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + // 通过 EventPusher 推送,不直接调用 Handler + EventPusher.push(new UserCreatedEvent(user.getId(), user.getUsername())); + return user; + } +} +``` + +### ❌ 错误示例 + +```java +// 错误 1:事件未实现 IEvent 接口 +public class UserCreatedEvent { // ❌ 缺少 ISyncEvent / IAsyncEvent + private String userId; +} + +// 错误 2:Handler 未注册为 Spring Bean +public class UserCacheHandler implements IHandler { // ❌ 缺少 @Service + @Override + public void handler(UserCreatedEvent event) { + cacheService.refreshUserCache(event.getUserId()); + } +} + +// 错误 3:在事务中强绑定事件结果 +@Transactional +public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + try { + EventPusher.push(new UserCreatedEvent(user.getId(), user.getUsername())); + } catch (Exception e) { + throw new RuntimeException("事件处理失败,回滚事务", e); // ❌ 事件失败不应回滚主事务 + } + return user; +} + +// 错误 4:手动调用 Handler 绕过框架 +@Service +public class UserService { + @Autowired + private UserCacheRefreshHandler cacheHandler; + + public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + cacheHandler.handler(new UserCreatedEvent(user.getId(), user.getUsername())); // ❌ 绕过 EventPusher + return user; + } +} + +// 错误 5:未处理异常导致事件链中断 +@Service +public class FragileHandler implements IHandler { + @Override + public void handler(UserCreatedEvent event) { + externalService.notify(event.getUserId()); // 可能抛出异常 + } + // ❌ 未重写 error(),默认重新抛出异常,阻止后续 Handler 执行 +} + +// 错误 6:多个 Handler 未指定 order,执行顺序不可预测 +@Service +public class HandlerA implements IHandler { + @Override + public void handler(UserCreatedEvent event) { /* ... */ } + // ❌ 未重写 order(),默认 0,与 HandlerB 顺序不确定 +} + +@Service +public class HandlerB implements IHandler { + @Override + public void handler(UserCreatedEvent event) { /* ... */ } + // ❌ 未重写 order(),默认 0,与 HandlerA 顺序不确定 +} +``` diff --git a/docs/agents/conventions/springboot-starter/exception-handling-convention.md b/docs/agents/conventions/springboot-starter/exception-handling-convention.md new file mode 100644 index 000000000..3f2d53ffa --- /dev/null +++ b/docs/agents/conventions/springboot-starter/exception-handling-convention.md @@ -0,0 +1,152 @@ +--- +name: springboot-starter/exception-handling-convention +module: springboot-starter +description: 全局异常处理规范,定义异常抛出和捕获的标准模式 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +不遵守此规范会导致以下问题: + +1. **错误响应格式不一致**:不同开发者在 Controller 中自行 try-catch 并返回自定义格式的错误信息,导致前端无法统一解析 `errCode` / `errMessage` 字段,增加联调和维护成本。 +2. **缺失国际化支持**:直接抛出 `RuntimeException` 或硬编码中文消息,无法根据请求 Locale 切换语言,多语言场景下用户体验差且需要大量改造。 +3. **异常被吞没**:Controller 层使用 try-catch 捕获异常后仅打印日志或返回 null,调用方无法感知业务失败原因,排查线上问题时缺少关键上下文。 +4. **错误码体系混乱**:各模块自行定义错误码常量或直接拼接字符串,缺乏统一的 key → message 映射机制,同一个业务含义可能出现多个不同的 errCode。 +5. **全局拦截失效**:绕过框架的 `HandlerExceptionResolver` 机制手动处理异常,导致日志记录、监控埋点等横切关注点被跳过。 + +## 如何使用 + +### 规则 1:业务异常统一使用 LocaleMessageException + +所有业务校验失败、参数非法、权限不足等场景,必须抛出 `LocaleMessageException`,禁止直接使用 `RuntimeException`、`IllegalArgumentException` 等原生异常。 + +```java +// 构造方式一:errCode + 默认消息(推荐用于简单场景) +throw new LocaleMessageException("user.not.found", "用户不存在"); + +// 构造方式二:仅 errCode,消息从 message.properties 自动解析 +throw new LocaleMessageException("user.not.found"); + +// 构造方式三:带占位符参数,对应 properties 中 user.duplicate=用户名 {0} 已存在 +throw LocaleMessageException.of("user.duplicate", username); +``` + +### 规则 2:异常消息采用 message key + 默认消息格式 + +- 第一个参数为 **errCode**(即 i18n message key),用于前端匹配和国际化查找。 +- 第二个参数为 **默认消息**(defaultMessage),当 message.properties 中未配置该 key 时作为兜底展示。 +- errCode 命名采用 **点分隔小写** 格式,如 `order.status.invalid`、`auth.token.expired`。 + +### 规则 3:依赖 ExceptionConfiguration 全局拦截 + +框架通过 `ExceptionConfiguration` 注册 `LocaleMessage` Bean,并由 `BasicHandlerExceptionResolverConfiguration` 中的 `ServletExceptionHandler` 统一拦截所有 Controller 层异常: + +- `LocaleMessageException` → 提取 `errCode` 和 `errMessage`,返回标准 Response 格式。 +- 其他未知异常 → 使用默认 errCode `system.err`,返回异常原始消息。 + +**无需在任何 Controller 中添加额外的 @ExceptionHandler。** + +### 规则 4:异常响应统一返回 Response 格式 + +全局拦截器返回的 JSON 结构如下: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "用户不存在" +} +``` + +前端只需判断 `success === false` 即可进入统一错误处理流程。 + +### 规则 5:禁止在 Controller 中使用 try-catch 吞掉异常 + +Controller 方法应保持简洁,让异常自然向上抛出由全局处理器接管。如需对特定异常做差异化处理(如参数校验),应在 Service 层完成转换后再抛出 `LocaleMessageException`。 + +### 规则 6:禁止直接抛出 RuntimeException + +`RuntimeException` 没有 `errCode` 字段,全局拦截器只能将其归入 `system.err`,丢失了业务语义。所有可预见的业务异常都必须使用 `LocaleMessageException`。 + +## 使用实例 + +### ✅ 正确示例 + +```java +@Service +public class UserService { + + public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new LocaleMessageException("user.not.found", "用户不存在")); + } + + public void createUser(String username) { + if (userRepository.existsByUsername(username)) { + // 使用静态工厂方法 + 占位符参数 + throw LocaleMessageException.of("user.duplicate", username); + } + // ... + } +} + +@RestController +@RequestMapping("/users") +public class UserController { + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + // 不需要 try-catch,异常由全局处理器接管 + return SingleResponse.of(userService.getUser(id)); + } +} +``` + +对应的 `messages_zh_CN.properties`: + +```properties +user.not.found=用户不存在 +user.duplicate=用户名 {0} 已存在 +``` + +### ❌ 错误示例 + +```java +// 错误 1:直接抛出 RuntimeException,缺少 errCode +public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new RuntimeException("用户不存在")); +} + +// 错误 2:Controller 中 try-catch 吞掉异常 +@GetMapping("/{id}") +public SingleResponse getUser(@PathVariable Long id) { + try { + return SingleResponse.of(userService.getUser(id)); + } catch (Exception e) { + log.error("查询失败", e); + return null; // 前端收到 null,无法区分成功与失败 + } +} + +// 错误 3:Controller 中自行构建错误响应,绕过全局拦截 +@GetMapping("/{id}") +public ResponseEntity getUser(@PathVariable Long id) { + try { + return ResponseEntity.ok(SingleResponse.of(userService.getUser(id))); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("code", 500); + error.put("msg", e.getMessage()); + return ResponseEntity.status(500).body(error); + } +} + +// 错误 4:硬编码中文消息,不支持国际化 +throw new LocaleMessageException("err001", "这个用户找不到啊"); +``` diff --git a/docs/agents/conventions/springboot-starter/response-convention.md b/docs/agents/conventions/springboot-starter/response-convention.md new file mode 100644 index 000000000..4611f12ff --- /dev/null +++ b/docs/agents/conventions/springboot-starter/response-convention.md @@ -0,0 +1,156 @@ +--- +name: springboot-starter/response-convention +module: springboot-starter +description: 统一响应格式规范,定义 Controller 层返回值的标准格式 +status: 已实现 +scope: 后端 +source: 框架:springboot-starter +import: "com.codingapi.springboot:springboot-starter" +framework_version: "3.4.54" +--- + +## 解决什么问题 + +在 REST API 开发中,如果不对响应格式做统一约束,会导致以下问题: + +- **前端解析困难**:不同接口返回结构不一致(有的直接返回对象、有的返回 Map、有的包裹在自定义结构中),前端需要为每个接口单独适配,增加维护成本。 +- **错误处理碎片化**:没有统一的 `success` / `errCode` / `errMessage` 字段,前端无法用一套逻辑判断请求是否成功、展示错误提示。 +- **分页结构不统一**:列表接口各自定义分页字段名(`total` / `count` / `records` / `items`),前端分页组件难以复用。 +- **API 契约不稳定**:随意返回裸 Map 或临时 DTO,字段增减无感知,容易引发线上联调故障。 +- **国际化与监控缺失基础**:缺少标准化的错误码字段,后续接入国际化、链路追踪、告警分类时改造成本极高。 + +本规范通过强制使用框架提供的 Response 体系,确保所有 API 输出结构一致、可预测、可机器解析。 + +## 如何使用 + +### 核心规则 + +1. **Controller 方法返回值必须使用 Response 体系**:只能返回 `Response`、`SingleResponse`、`MultiResponse` 或 `MapResponse` 之一,禁止返回其他类型。 +2. **单个对象返回**:使用 `SingleResponse.of(data)`。 +3. **列表返回**:使用 `MultiResponse.of(list)` 或 `MultiResponse.of(page)`(接受 Spring Data `Page` 对象)。 +4. **无数据操作成功返回**:使用 `Response.buildSuccess()`。 +5. **失败返回**:使用 `Response.buildFailure(errCode, errMessage)`。 +6. **禁止直接返回 Map 或自定义 DTO 作为 API 响应**。 +7. **响应 JSON 结构固定包含**:`success`(boolean)、`errCode`(string)、`errMessage`(string)、`data`(业务数据,仅 SingleResponse/MultiResponse 携带)。 + +### 补充说明 + +- `SingleResponse.empty()` 用于查询可能为空但语义上成功的场景,返回 `{ success: true, data: null }`。 +- `MultiResponse.of(collection, total)` 用于手动分页场景;`MultiResponse.of(page)` 自动从 Spring Data Page 提取 total。 +- `MultiResponse.empty()` 返回空列表 `{ success: true, data: { total: 0, list: [] } }`。 +- 异常处理应通过全局异常处理器统一转换为 `Response.buildFailure(...)`,Controller 内不要 try-catch 后自行拼装错误响应。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. 单对象查询 +@GetMapping("/users/{id}") +public SingleResponse getUser(@PathVariable Long id) { + UserDTO user = userService.findById(id); + return SingleResponse.of(user); +} + +// 2. 分页列表查询 +@GetMapping("/users") +public MultiResponse listUsers(PageRequest pageRequest) { + Page page = userService.findAll(pageRequest); + return MultiResponse.of(page); +} + +// 3. 非分页列表查询 +@GetMapping("/roles") +public MultiResponse listRoles() { + List roles = roleService.findAll(); + return MultiResponse.of(roles); +} + +// 4. 写操作成功(无返回数据) +@PostMapping("/users") +public Response createUser(@RequestBody CreateUserCmd cmd) { + userService.create(cmd); + return Response.buildSuccess(); +} + +// 5. 业务校验失败 +@PostMapping("/orders") +public Response createOrder(@RequestBody CreateOrderCmd cmd) { + if (cmd.getQuantity() <= 0) { + return Response.buildFailure("INVALID_QUANTITY", "订单数量必须大于0"); + } + orderService.create(cmd); + return Response.buildSuccess(); +} +``` + +正确返回的 JSON 结构: + +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { "id": 1, "name": "张三" } +} +``` + +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "total": 100, + "list": [{ "id": 1, "name": "张三" }] + } +} +``` + +### ❌ 错误示例 + +```java +// 错误1:直接返回实体对象 +@GetMapping("/users/{id}") +public UserDTO getUser(@PathVariable Long id) { + return userService.findById(id); +} + +// 错误2:返回裸 Map +@GetMapping("/users/{id}") +public Map getUser(@PathVariable Long id) { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("data", userService.findById(id)); + return result; +} + +// 错误3:自定义响应结构 +@GetMapping("/users/{id}") +public Result getUser(@PathVariable Long id) { + return new Result<>(userService.findById(id)); +} + +// 错误4:Controller 内 try-catch 自行拼装错误 +@PostMapping("/users") +public Response createUser(@RequestBody CreateUserCmd cmd) { + try { + userService.create(cmd); + return Response.buildSuccess(); + } catch (Exception e) { + // 不应在 Controller 中捕获并手动构建错误响应 + Response resp = new Response(); + resp.setSuccess(false); + resp.setErrMessage(e.getMessage()); + return resp; + } +} + +// 错误5:列表接口不使用 MultiResponse +@GetMapping("/users") +public List listUsers() { + return userService.findAll(); +} +``` + +以上错误写法会导致前端无法统一解析响应、错误处理逻辑分散、API 契约不可靠等问题。 diff --git a/docs/capabilities/alibaba/fastjson.md b/docs/capabilities/alibaba/fastjson.md new file mode 100644 index 000000000..14ff82970 --- /dev/null +++ b/docs/capabilities/alibaba/fastjson.md @@ -0,0 +1,172 @@ +--- +name: alibaba/fastjson +module: alibaba +description: Fastjson JSON 序列化库,提供高性能 JSON 解析和序列化 +status: 已实现 +scope: 后端 +source: 框架:alibaba +import: "com.alibaba:fastjson" +framework_version: 2.0.53 +--- + +## 解决什么问题 + +在企业级 Java 应用中,JSON 是最常用的数据交换格式。Fastjson 解决了以下核心痛点: + +- **高性能序列化/反序列化**:相比 JDK 原生或其他 JSON 库,Fastjson 在解析速度和内存占用上具有显著优势,适合高并发、大数据量的接口场景 +- **灵活的类型转换**:支持将 JSON 字符串直接转换为 Java Bean、Map、List 等复杂对象,也支持泛型集合的反序列化 +- **丰富的定制能力**:通过注解和过滤器机制,可以精确控制字段的序列化/反序列化行为(如字段重命名、忽略空值、日期格式化等) +- **与 Spring Boot 无缝集成**:可作为 Spring MVC 的 HttpMessageConverter,替代默认的 Jackson 进行请求/响应的自动 JSON 处理 + +在本框架中,Fastjson 2.x(2.0.53)被用作底层 JSON 工具,为统一响应封装、事件系统数据传递、脚本引擎参数绑定等模块提供高效的序列化支撑。 + +## 如何使用 + +### Maven 依赖 + +```xml + + com.alibaba.fastjson2 + fastjson2 + 2.0.53 + +``` + +### 核心 API + +| 方法 | 说明 | +|------|------| +| `JSON.toJSONString(object)` | 将 Java 对象序列化为 JSON 字符串 | +| `JSON.parseObject(json, Class)` | 将 JSON 字符串反序列化为指定类型的 Java 对象 | +| `JSON.parseArray(json, Class)` | 将 JSON 数组字符串反序列化为 List | +| `JSON.toJSONBytes(object)` | 将 Java 对象序列化为 byte[](适合网络传输/缓存存储) | +| `JSONObject.parseObject(json)` | 解析为 JSONObject(类似 Map 的动态访问) | + +### 常用特性控制 + +通过 `JSONWriter.Feature` 和 `JSONReader.Feature` 枚举控制序列化/反序列化行为: + +- `JSONWriter.Feature.WriteMapNullValue` — 序列化时保留 null 值字段 +- `JSONWriter.Feature.PrettyFormat` — 格式化输出(带缩进换行) +- `JSONWriter.Feature.WriteEnumsUsingToString` — 枚举使用 toString() 而非 ordinal +- `JSONReader.Feature.SupportSmartMatch` — 智能匹配(忽略大小写和下划线差异) +- `JSONReader.Feature.IgnoreCheckClose` — 宽松解析,容忍非标准 JSON + +### 注解定制 + +- `@JSONField(name = "...")` — 自定义 JSON 字段名 +- `@JSONField(format = "yyyy-MM-dd HH:mm:ss")` — 日期格式化 +- `@JSONField(serialize = false)` — 排除该字段不参与序列化 +- `@JSONField(deserialize = false)` — 排除该字段不参与反序列化 + +## 使用实例 + +### 基础序列化与反序列化 + +```java +import com.alibaba.fastjson2.JSON; + +// 序列化 +User user = new User(); +user.setId(1L); +user.setName("张三"); +user.setAge(28); + +String json = JSON.toJSONString(user); +// {"age":28,"id":1,"name":"张三"} + +// 反序列化 +User parsed = JSON.parseObject(json, User.class); +System.out.println(parsed.getName()); // 张三 +``` + +### 泛型集合反序列化 + +```java +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.TypeReference; +import java.util.List; + +String jsonArray = "[{\"id\":1,\"name\":\"张三\"},{\"id\":2,\"name\":\"李四\"}]"; + +// 使用 TypeReference 保留泛型信息 +List users = JSON.parseObject(jsonArray, new TypeReference>() {}); +System.out.println(users.size()); // 2 +``` + +### 带特性的序列化 + +```java +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter; + +User user = new User(); +user.setId(1L); +user.setName(null); + +// 默认不输出 null 字段 +String compact = JSON.toJSONString(user); +// {"id":1} + +// 保留 null 值 + 格式化输出 +String pretty = JSON.toJSONString(user, + JSONWriter.Feature.WriteMapNullValue, + JSONWriter.Feature.PrettyFormat); +// { +// "age":null, +// "id":1, +// "name":null +// } +``` + +### 使用注解定制字段 + +```java +import com.alibaba.fastjson2.annotation.JSONField; +import java.time.LocalDateTime; + +public class Order { + + @JSONField(name = "order_id") + private Long orderId; + + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @JSONField(serialize = false) + private String internalCode; // 不参与序列化 + + // getter/setter ... +} + +Order order = new Order(); +order.setOrderId(1001L); +order.setCreateTime(LocalDateTime.now()); +order.setInternalCode("INTERNAL"); + +String json = JSON.toJSONString(order); +// {"createTime":"2026-06-08 14:30:00","order_id":1001} +// 注意:internalCode 未出现,orderId 被重命名为 order_id +``` + +### 动态 JSONObject 操作 + +```java +import com.alibaba.fastjson2.JSONObject; + +// 从字符串解析 +String json = "{\"name\":\"张三\",\"address\":{\"city\":\"杭州\",\"zip\":\"310000\"}}"; +JSONObject obj = JSONObject.parseObject(json); + +// 读取嵌套字段 +String city = obj.getJSONObject("address").getString("city"); +System.out.println(city); // 杭州 + +// 动态构建并输出 +JSONObject result = new JSONObject(); +result.put("code", 200); +result.put("message", "success"); +result.put("data", obj); + +System.out.println(result.toJSONString()); +``` diff --git a/docs/capabilities/apache/groovy.md b/docs/capabilities/apache/groovy.md new file mode 100644 index 000000000..cb666deda --- /dev/null +++ b/docs/capabilities/apache/groovy.md @@ -0,0 +1,178 @@ +--- +name: apache/groovy +module: apache +description: Groovy 动态脚本语言,运行在 JVM 上,支持动态类型和脚本化编程 +status: 已实现 +scope: 后端 +source: 框架:apache +import: "org.apache.groovy:groovy" +framework_version: 4.0.24 +--- + +## 解决什么问题 + +在企业级应用中,许多业务逻辑需要在不重启服务的前提下进行动态调整,例如: + +- **动态规则引擎**:促销折扣、风控策略、审批条件等频繁变更的业务规则,硬编码会导致频繁发版。 +- **可配置的计算逻辑**:报表字段计算、数据转换、自定义校验等逻辑需要由运营人员或实施人员灵活配置。 +- **热更新与灰度验证**:线上问题修复或新逻辑验证时,希望以脚本方式快速下发并即时生效,而非走完整的发布流程。 +- **降低扩展门槛**:让非核心开发人员(如实施顾问、数据分析师)也能通过简洁的 DSL 参与业务逻辑编写。 + +Groovy 作为运行在 JVM 上的动态语言,与 Java 完全兼容且语法更简洁。springboot-framework 的 `springboot-starter-script` 模块在此基础上封装了完整的脚本运行时,提供了编译缓存、LRU 淘汰、事务绑定、变量注入等企业级能力,使 Groovy 脚本能够安全、高效地嵌入到 Spring Boot 应用中。 + +## 如何使用 + +### 1. 引入依赖 + +在需要使用脚本能力的模块中添加 `springboot-starter-script` 依赖,该模块已包含 `org.apache.groovy:groovy:4.0.24`: + +```xml + + com.codingapi.springboot + springboot-starter-script + +``` + +### 2. 核心 API + +框架通过 `GroovyScriptRuntimeContext` 单例提供脚本运行时入口,主要方法如下: + +| 方法 | 说明 | +|------|------| +| `compile(script, cache)` | 编译脚本,`cache=true` 时使用 SHA256 作为 key 进行 LRU 缓存 | +| `run(script, returnType, transactionMode, binds)` | 执行整段脚本并返回结果 | +| `invoke(method, script, returnType, transactionMode, binds, args)` | 执行脚本中指定函数并返回结果 | +| `clearCache()` | 清空已编译的脚本缓存 | +| `cacheSize()` | 获取当前缓存中的脚本数量 | + +### 3. 高级封装:GroovyScript + +对于需要持久化管理的脚本,可使用 `GroovyScript` Builder 构建脚本对象,它集成了编译、执行、存储、元数据扫描等完整生命周期: + +```java +GroovyScript script = GroovyScript.builder("discount-rule-v1") + .script("def calculate(price, rate) { return price * rate }") + .method("calculate") + .returnType(BigDecimal.class) + .description("折扣计算脚本") + .tag("promotion") + .build(); +``` + +关键操作: + +- `script.compile(true)` — 预编译并缓存 +- `script.invoke(binds, args...)` — 调用指定函数 +- `script.run(binds)` — 执行整段脚本 +- `script.save()` — 持久化到 Repository +- `script.temp()` — 存入临时上下文(定时清理) +- `script.toMetadata()` — 提取脚本元数据信息 + +### 4. 事务模式 + +脚本执行支持三种事务模式,通过 `TransactionMode` 枚举控制: + +- `DEFAULT` — 不参与事务管理,直接执行 +- `READONLY` — 在只读事务中执行,适用于查询类脚本 +- `COMMIT` — 在可提交事务中执行,适用于涉及数据写入的脚本 + +### 5. 变量绑定 + +通过 `binds` Map 向脚本注入外部变量,脚本内可直接按变量名访问: + +```java +Map binds = new HashMap<>(); +binds.put("userService", userService); +binds.put("orderId", orderId); + +Object result = script.invoke(binds, param1, param2); +``` + +### 6. 缓存机制 + +- **编译缓存**:`GroovyScriptRuntime` 内部使用 `LinkedHashMap` 实现 LRU 缓存,最大容量通过配置项 `shellMaxCacheSize` 控制,超出时自动淘汰最久未使用的脚本。 +- **脚本对象缓存**:`GroovyScriptCacheContext` 维护脚本定义的 LRU 缓存(上限 10240),查找顺序为:内存缓存 → 临时上下文 → Repository 持久层。 + +## 使用实例 + +### 示例 1:执行简单计算脚本 + +```java +// 直接运行一段 Groovy 脚本 +String script = """ + def total = items.stream() + .mapToDouble { it.price * it.quantity } + .sum() + return BigDecimal.valueOf(total).setScale(2, RoundingMode.HALF_UP) + """; + +Map binds = Map.of("items", orderItems); +BigDecimal total = GroovyScriptRuntimeContext.getInstance() + .run(script, BigDecimal.class, TransactionMode.READONLY, binds); +``` + +### 示例 2:调用脚本中的函数 + +```java +String script = """ + def checkEligibility(user, minScore) { + return user.score >= minScore && user.status == 'ACTIVE' + } + """; + +Map binds = Map.of("minScore", 80); +Boolean eligible = GroovyScriptRuntimeContext.getInstance() + .invoke("checkEligibility", script, Boolean.class, + TransactionMode.DEFAULT, binds, currentUser); +``` + +### 示例 3:使用 GroovyScript 管理持久化脚本 + +```java +// 构建并保存脚本 +GroovyScript script = GroovyScript.builder("tax-calc-2024") + .script(""" + def calcTax(amount, region) { + def rate = region == 'CN' ? 0.13 : 0.08 + return (amount * rate).setScale(2, RoundingMode.HALF_UP) + } + """) + .method("calcTax") + .returnType(BigDecimal.class) + .description("2024年税率计算") + .tag("finance") + .build(); + +script.save(); // 持久化 +script.compile(true); // 预编译并缓存 + +// 后续使用时从缓存获取并执行 +GroovyScript cached = GroovyScriptCacheContext.getInstance() + .getGroovyScript("tax-calc-2024"); +BigDecimal tax = cached.invoke(TransactionMode.READONLY, null, + new BigDecimal("10000"), "CN"); +// tax = 1300.00 +``` + +### 示例 4:在事务中执行数据写入脚本 + +```java +String script = """ + def user = userRepository.findByUsername(username) + if (user != null) { + user.lastLoginTime = new Date() + user.loginCount += 1 + userRepository.save(user) + return true + } + return false + """; + +Map binds = Map.of( + "userRepository", userRepository, + "username", "admin" +); + +Boolean updated = GroovyScriptRuntimeContext.getInstance() + .run(script, Boolean.class, TransactionMode.COMMIT, binds); +``` diff --git a/docs/capabilities/esotericsoftware/kryo.md b/docs/capabilities/esotericsoftware/kryo.md new file mode 100644 index 000000000..b417d4fd1 --- /dev/null +++ b/docs/capabilities/esotericsoftware/kryo.md @@ -0,0 +1,167 @@ +--- +name: esotericsoftware/kryo +module: esotericsoftware +description: Kryo 高性能序列化框架,提供快速的 Java 对象序列化 +status: 已实现 +scope: 后端 +source: 框架:esotericsoftware +import: "com.esotericsoftware:kryo" +framework_version: 5.6.2 +--- + +## 解决什么问题 + +Kryo 是一个快速、高效的 Java 对象序列化框架,主要解决以下问题: + +1. **Java 原生序列化性能低下**:Java 内置的 `Serializable` 机制序列化后的字节流体积大、速度慢,不适合高性能场景。 +2. **存储与传输效率**:在需要将复杂对象持久化到数据库或通过网络传输时,Kryo 能显著减少数据体积并提升处理速度。 +3. **工作流快照存储**:在本框架的工作流引擎(`springboot-starter-flow`)中,流程定义数据需要以紧凑的二进制格式存储为版本快照,Kryo 提供了比 JSON/XML 更高效的序列化方案。 + +适用场景包括:缓存序列化、RPC 通信、对象深拷贝、数据库 BLOB 字段存储、分布式会话共享等对序列化性能敏感的场景。 + +## 如何使用 + +### Maven 依赖 + +```xml + + com.esotericsoftware + kryo + 5.6.2 + +``` + +### 核心 API + +| 类/接口 | 说明 | +|---------|------| +| `Kryo` | 序列化引擎核心类,负责注册类和执行序列化/反序列化操作 | +| `Output` | 输出流包装器,将序列化数据写入底层 OutputStream | +| `Input` | 输入流包装器,从底层 InputStream 读取序列化数据 | +| `Serializer` | 自定义序列化器接口,用于控制特定类型的序列化行为 | + +### 基本用法 + +1. 创建 `Kryo` 实例 +2. 注册需要序列化的类(可选但推荐,可减小输出体积) +3. 使用 `writeObject()` / `readObject()` 进行序列化/反序列化 + +> ⚠️ **注意**:`Kryo` 实例不是线程安全的,在多线程环境中应使用 `ThreadLocal` 或对象池进行管理。 + +## 使用实例 + +### 基础序列化与反序列化 + +```java +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; + +import java.io.ByteArrayOutputStream; + +// 定义实体类 +public class User implements Serializable { + private String name; + private int age; + + // getter/setter 省略 +} + +// 序列化 +Kryo kryo = new Kryo(); +kryo.register(User.class); + +ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); +Output output = new Output(outputStream); + +User user = new User("张三", 28); +kryo.writeObject(output, user); +output.close(); + +byte[] bytes = outputStream.toByteArray(); + +// 反序列化 +Input input = new Input(bytes); +User restored = kryo.readObject(input, User.class); +input.close(); + +System.out.println(restored.getName()); // 张三 +``` + +### 框架中的实际应用:工作流快照序列化 + +以下是 `springboot-starter-flow` 模块中使用 Kryo 对流程定义进行序列化的实际代码: + +```java +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; + +public class FlowWorkSerializable implements Serializable { + + private long id; + private String code; + private String title; + private List nodes; + private List relations; + // ... 其他字段 + + /** + * 将流程定义序列化为字节数组,用于持久化存储 + */ + public byte[] toSerializable() { + Kryo kryo = new Kryo(); + // 注册所有涉及的类型,减小序列化体积 + kryo.register(ArrayList.class); + kryo.register(FlowNodeSerializable.class); + kryo.register(FlowRelationSerializable.class); + kryo.register(FlowWorkSerializable.class); + kryo.register(ApprovalType.class); + kryo.register(NodeType.class); + kryo.register(FlowButton.class); + kryo.register(FlowButtonType.class); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + Output output = new Output(outputStream); + kryo.writeObject(output, this); + output.close(); + return outputStream.toByteArray(); + } + + /** + * 从字节数组反序列化恢复流程定义 + */ + public static FlowWorkSerializable fromSerializable(byte[] bytes) { + Kryo kryo = new Kryo(); + kryo.register(ArrayList.class); + kryo.register(FlowNodeSerializable.class); + kryo.register(FlowRelationSerializable.class); + kryo.register(FlowWorkSerializable.class); + kryo.register(ApprovalType.class); + kryo.register(NodeType.class); + kryo.register(FlowButton.class); + kryo.register(FlowButtonType.class); + + return kryo.readObject(new Input(bytes), FlowWorkSerializable.class); + } +} +``` + +### 线程安全的使用方式 + +```java +// 使用 ThreadLocal 保证线程安全 +private static final ThreadLocal kryoThreadLocal = ThreadLocal.withInitial(() -> { + Kryo kryo = new Kryo(); + kryo.register(User.class); + kryo.register(ArrayList.class); + kryo.setReferences(true); // 支持循环引用 + kryo.setRegistrationRequired(false); // 允许未注册的类(开发阶段方便调试) + return kryo; +}); + +// 使用时获取线程本地实例 +Kryo kryo = kryoThreadLocal.get(); +Output output = new Output(new ByteArrayOutputStream()); +kryo.writeObject(output, user); +``` diff --git a/docs/capabilities/index.md b/docs/capabilities/index.md new file mode 100644 index 000000000..0ee1ed796 --- /dev/null +++ b/docs/capabilities/index.md @@ -0,0 +1,34 @@ +# 能力(Capabilities) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +## ✅ 已实现 + +| 名称 | 模块 | 描述 | 范围 | 来源 | +|------|------|------|------|------| +| [alibaba/fastjson](./alibaba/fastjson.md) | alibaba | Fastjson JSON 序列化库,提供高性能 JSON 解析和序列化 | 后端 | 框架:alibaba | +| [apache/groovy](./apache/groovy.md) | apache | Groovy 动态脚本语言,运行在 JVM 上,支持动态类型和脚本化编程 | 后端 | 框架:apache | +| [esotericsoftware/kryo](./esotericsoftware/kryo.md) | esotericsoftware | Kryo 高性能序列化框架,提供快速的 Java 对象序列化 | 后端 | 框架:esotericsoftware | +| [jsonwebtoken/jjwt](./jsonwebtoken/jjwt.md) | jsonwebtoken | JJWT 库,提供 JWT 令牌生成、验证和解析 | 后端 | 框架:jsonwebtoken | +| [jsqlparser/jsqlparser](./jsqlparser/jsqlparser.md) | jsqlparser | JSqlParser SQL 解析器,支持 SQL 语句解析和改写 | 后端 | 框架:jsqlparser | +| [react/react](./react/react.md) | react | React UI 框架配合 Ant Design 组件库,用于构建前端用户界面 | 前端 | 框架:react | +| [redux/toolkit](./redux/toolkit.md) | redux | Redux Toolkit 状态管理库,提供简化的 Redux 状态管理模式 | 前端 | 框架:redux | +| [springboot/data-jpa](./springboot/data-jpa.md) | springboot | Spring Data JPA,提供 ORM 映射、Repository 抽象和分页支持 | 后端 | 框架:springboot | +| [springboot/ioc-container](./springboot/ioc-container.md) | springboot | Spring IoC 容器,提供依赖注入、AOP、事件发布等核心功能 | 后端 | 框架:springboot | +| [springboot/security](./springboot/security.md) | springboot | Spring Security,提供认证、授权和 CSRF 防护 | 后端 | 框架:springboot | +| [springboot-starter/domain-proxy](./springboot-starter/domain-proxy.md) | springboot-starter | 领域实体变更代理,通过 CGLIB 代理拦截实体字段变更并自动推送领域事件 | 后端 | 项目自有 | +| [springboot-starter/event-system](./springboot-starter/event-system.md) | springboot-starter | 事件发布-订阅系统,支持同步/异步事件、事件循环检测、事务事件 | 后端 | 项目自有 | +| [springboot-starter/exception-handling](./springboot-starter/exception-handling.md) | springboot-starter | 全局异常处理,统一拦截 Controller 层异常并返回标准 Response | 后端 | 项目自有 | +| [springboot-starter/locale-message](./springboot-starter/locale-message.md) | springboot-starter | 国际化异常消息,支持多语言异常信息和本地化消息解析 | 后端 | 项目自有 | +| [springboot-starter/page-request](./springboot-starter/page-request.md) | springboot-starter | 动态分页查询请求,扩展 Spring PageRequest 支持 RequestFilter 动态过滤条件 | 后端 | 项目自有 | +| [springboot-starter/response-dto](./springboot-starter/response-dto.md) | springboot-starter | 统一响应封装,提供 Response/SingleResponse/MultiResponse/MapResponse 四种响应类型 | 后端 | 项目自有 | +| [springboot-starter/rest-client](./springboot-starter/rest-client.md) | springboot-starter | REST 客户端封装,提供 RestTemplate 上下文管理和 HTTPS 信任所有证书支持 | 后端 | 项目自有 | +| [springboot-starter-data-authorization/sql-interception](./springboot-starter-data-authorization/sql-interception.md) | springboot-starter-data-authorization | SQL 拦截数据权限,通过 JDBC 代理透明注入权限条件实现行级数据过滤 | 后端 | 项目自有 | +| [springboot-starter-data-fast/fast-repository](./springboot-starter-data-fast/fast-repository.md) | springboot-starter-data-fast | JPA 增强 Repository,支持 PageRequest 动态过滤查询和 HQL 构建 | 后端 | 项目自有 | +| [springboot-starter-flow/workflow-engine](./springboot-starter-flow/workflow-engine.md) | springboot-starter-flow | 工作流引擎,支持流程定义、节点流转、审批、委托、会签和数据快照 | 后端 | 项目自有 | +| [springboot-starter-script/groovy-runtime](./springboot-starter-script/groovy-runtime.md) | springboot-starter-script | Groovy 脚本运行时引擎,支持运行时编译、LRU 缓存、热更新和 REST API | 后端 | 项目自有 | +| [springboot-starter-security/auth-gateway](./springboot-starter-security/auth-gateway.md) | springboot-starter-security | 安全认证网关,支持 JWT 无状态认证和 Redis 有状态认证两种模式 | 后端 | 项目自有 | + +--- + +**统计**: 共 22 篇 — 已实现 22 / 计划中 0 / 已废弃 0 diff --git a/docs/capabilities/jsonwebtoken/jjwt.md b/docs/capabilities/jsonwebtoken/jjwt.md new file mode 100644 index 000000000..78f63f6fb --- /dev/null +++ b/docs/capabilities/jsonwebtoken/jjwt.md @@ -0,0 +1,188 @@ +--- +name: jsonwebtoken/jjwt +module: jsonwebtoken +description: JJWT 库,提供 JWT 令牌生成、验证和解析 +status: 已实现 +scope: 后端 +source: 框架:jsonwebtoken +import: "io.jsonwebtoken:jjwt-api" +framework_version: 0.12.6 +--- + +## 解决什么问题 + +在微服务与前后端分离架构中,服务端需要一种无状态的认证机制来识别用户身份。传统 Session 方案依赖服务端存储,难以水平扩展且不适合移动端场景。 + +JJWT(Java JSON Web Token)提供了标准的 JWT 令牌生成、签名与解析能力,解决以下核心问题: + +- **无状态认证**:将用户信息(用户名、权限、额外数据)编码进自包含的 JWT 令牌中,服务端无需维护会话存储即可验证请求身份。 +- **防篡改签名**:通过 HMAC-SHA 算法对令牌进行签名,确保令牌内容在传输过程中不被伪造或修改。 +- **令牌生命周期管理**:支持配置有效期与续期提醒时间,配合框架的 `Token.canRestToken()` 机制实现自动续期,避免用户在活跃操作中频繁重新登录。 +- **统一令牌抽象**:框架通过 `TokenGateway` 接口封装了 JWT 与 Redis 两种令牌策略,业务层只需面向接口编程,切换认证模式无需改动业务代码。 + +## 如何使用 + +### 依赖引入 + +JJWT 作为 `springboot-starter-security` 模块的传递依赖自动引入,无需单独声明: + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +### 启用 JWT 认证 + +在 `application.properties` 中开启 JWT 并配置参数: + +```properties +# 启用 JWT 认证(默认 true) +codingapi.security.jwt.enable=true + +# HMAC 签名密钥(需大于 32 位字符串) +codingapi.security.jwt.secret-key=your-secret-key-at-least-32-characters + +# 令牌有效期(毫秒),默认 15 分钟 +codingapi.security.jwt.valid-time=900000 + +# 令牌续期提醒阈值(毫秒),默认 10 分钟后触发续期 +codingapi.security.jwt.rest-time=600000 +``` + +### 核心 API + +框架通过 `TokenGateway` 接口对外暴露令牌操作,JWT 模式下由 `JWTTokenGatewayImpl` → `JwtTokenGateway` 实现: + +| 方法 | 说明 | +|------|------| +| `create(username, authorities)` | 创建基础令牌,仅包含用户名和权限列表 | +| `create(username, authorities, extra)` | 创建带额外数据的令牌,extra 以 JSON 字符串存入 subject | +| `create(username, iv, authorities)` | 创建带加密向量(iv)的令牌,用于加解密场景 | +| `create(username, iv, authorities, extra)` | 完整创建方法,同时携带 iv 和额外数据 | +| `parser(sign)` | 解析并验证 JWT 签名,返回 `Token` 对象;签名无效或格式错误时抛出 `LocaleMessageException` | + +`Token` 对象提供以下辅助方法: + +| 方法 | 说明 | +|------|------| +| `verify()` | 校验令牌是否过期,过期则抛出 `TokenExpiredException` | +| `isExpire()` | 判断令牌是否已过期 | +| `canRestToken()` | 判断是否需要续期(未过期但已超过 restTime 阈值) | +| `parseExtra(Class)` | 将 extra 字段反序列化为指定类型 | +| `decodeIv()` | AES 解密 iv 字段 | +| `getAuthenticationToken()` | 转换为 Spring Security 的 `UsernamePasswordAuthenticationToken` | + +### 自动装配 + +当 `codingapi.security.jwt.enable=true` 时,`JWTSecurityConfiguration` 自动注册以下 Bean: + +- `SecurityJWTProperties` — JWT 配置属性 +- `JwtTokenGateway` — JWT 令牌生成与解析的核心组件 +- `TokenGateway`(`JWTTokenGatewayImpl`)— 统一的令牌网关接口实现 + +若项目中已存在自定义 `TokenGateway` Bean,自动装配不会覆盖(`@ConditionalOnMissingBean`)。 + +## 使用实例 + +### 注入 TokenGateway 创建与解析令牌 + +```java +@Service +public class AuthService { + + private final TokenGateway tokenGateway; + + public AuthService(TokenGateway tokenGateway) { + this.tokenGateway = tokenGateway; + } + + /** + * 用户登录后签发 JWT 令牌 + */ + public Token login(String username, List authorities) { + // 基础令牌 + return tokenGateway.create(username, authorities); + } + + /** + * 签发携带额外业务数据的令牌 + */ + public Token loginWithExtra(String username, List authorities, String tenantId) { + String extra = "{\"tenantId\":\"" + tenantId + "\"}"; + return tokenGateway.create(username, authorities, extra); + } + + /** + * 从请求头解析并验证令牌 + */ + public Token validateToken(String jwtSign) { + // 验签 + 解析,失败抛出 LocaleMessageException + Token token = tokenGateway.parser(jwtSign); + // 检查是否过期 + token.verify(); + return token; + } +} +``` + +### 在过滤器中使用令牌 + +```java +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final TokenGateway tokenGateway; + + public JwtAuthFilter(TokenGateway tokenGateway) { + this.tokenGateway = tokenGateway; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String sign = authHeader.substring(7); + try { + Token token = tokenGateway.parser(sign); + token.verify(); + + // 检查是否需要续期 + if (token.canRestToken()) { + // 重新签发令牌并通过响应头返回 + Token newToken = tokenGateway.create( + token.getUsername(), token.getAuthorities(), token.getExtra()); + response.setHeader("X-New-Token", newToken.getToken()); + } + + // 设置 Spring Security 认证上下文 + SecurityContextHolder.getContext() + .setAuthentication(token.getAuthenticationToken()); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + } + chain.doFilter(request, response); + } +} +``` + +### 解析令牌中的额外数据 + +```java +// 定义业务数据结构 +public class UserContext { + private String tenantId; + private String department; + // getter/setter +} + +// 从令牌中提取 +Token token = tokenGateway.parser(jwtSign); +UserContext ctx = token.parseExtra(UserContext.class); +String tenantId = ctx.getTenantId(); +``` diff --git a/docs/capabilities/jsqlparser/jsqlparser.md b/docs/capabilities/jsqlparser/jsqlparser.md new file mode 100644 index 000000000..67392c83a --- /dev/null +++ b/docs/capabilities/jsqlparser/jsqlparser.md @@ -0,0 +1,138 @@ +--- +name: jsqlparser/jsqlparser +module: jsqlparser +description: JSqlParser SQL 解析器,支持 SQL 语句解析和改写 +status: 已实现 +scope: 后端 +source: 框架:jsqlparser +import: "com.github.jsqlparser:jsqlparser" +framework_version: 5.0 +--- + +## 解决什么问题 + +在企业级应用中,经常需要在运行时对 SQL 语句进行动态分析和改写,典型场景包括: + +- **数据权限过滤**:在 SQL 执行前透明注入行级权限条件(如 `WHERE dept_id IN (...)`),无需修改业务代码 +- **SQL 审计与监控**:解析 SQL 结构以提取表名、操作类型等元信息 +- **多租户隔离**:自动为查询追加租户过滤条件 +- **SQL 合法性校验**:判断传入的 SQL 是否为合法的 SELECT 查询,防止误操作 + +JSqlParser 提供了完整的 SQL 语法树解析能力,使上述场景可以在不依赖特定数据库方言的前提下,以统一的方式对 SQL 进行结构化访问和改写。在本框架中,它作为 `springboot-starter-data-authorization` 模块的核心依赖,支撑了数据权限 SQL 增强器的实现。 + +## 如何使用 + +### Maven 依赖 + +```xml + + com.github.jsqlparser + jsqlparser + 5.0 + +``` + +### 核心 API + +| 类 / 接口 | 说明 | +|-----------|------| +| `CCJSqlParserUtil.parse(sql)` | 将 SQL 字符串解析为 `Statement` 语法树对象 | +| `Statement` | SQL 语句的顶层抽象,可向下转型为 `Select`、`Insert`、`Update`、`Delete` 等 | +| `Select` / `PlainSelect` | 查询语句节点,提供 `getFromItem()`、`getWhere()`、`getJoins()` 等方法访问子句 | +| `Expression` | WHERE / ON 条件表达式树,支持 `AndExpression`、`OrExpression`、`InExpression` 等 | +| `Table` | 表引用节点,包含表名和别名信息 | +| `Join` | JOIN 子句节点,可通过 `getRightItem()` 获取关联表或子查询 | +| `statement.toString()` | 将修改后的语法树重新序列化为 SQL 字符串 | + +### 典型使用流程 + +1. 调用 `CCJSqlParserUtil.parse(sql)` 获得 `Statement` 对象 +2. 通过 `instanceof` 判断语句类型并向下转型 +3. 遍历语法树节点,读取或修改表名、条件、JOIN 等信息 +4. 调用 `toString()` 输出改写后的 SQL + +## 使用实例 + +### 示例 1:判断 SQL 是否为 SELECT 查询 + +```java +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.Select; + +public class SQLUtils { + + public static boolean isQuerySql(String sql) { + if (sql == null || sql.trim().isEmpty()) { + return false; + } + try { + Statement statement = CCJSqlParserUtil.parse(sql); + return statement instanceof Select; + } catch (Exception e) { + return false; + } + } +} +``` + +### 示例 2:解析 SQL 并向 WHERE 子句注入权限条件 + +以下示例展示了框架中 `DataPermissionSQLEnhancer` 的核心思路——解析 SQL 后遍历所有表和 JOIN,按需追加数据权限过滤条件: + +```java +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.*; + +public class DataPermissionExample { + + public String enhanceWithPermission(String sql, Expression permissionCondition) throws Exception { + // 1. 解析 SQL + Statement statement = CCJSqlParserUtil.parse(sql); + + if (statement instanceof Select) { + PlainSelect plainSelect = ((Select) statement).getPlainSelect(); + + // 2. 获取原有 WHERE 条件 + Expression existingWhere = plainSelect.getWhere(); + + // 3. 用 AND 拼接权限条件 + if (existingWhere != null) { + plainSelect.setWhere(new AndExpression(existingWhere, permissionCondition)); + } else { + plainSelect.setWhere(permissionCondition); + } + + // 4. 处理 JOIN 中的表(类似逻辑,此处省略) + } + + // 5. 返回改写后的 SQL + return statement.toString(); + } +} +``` + +### 示例 3:递归处理子查询和 UNION + +对于包含子查询或 `UNION` 的复杂 SQL,需要递归遍历语法树以确保权限条件被注入到每一层查询中: + +```java +private void deepMatch(Select select) throws Exception { + if (select instanceof PlainSelect) { + PlainSelect plainSelect = select.getPlainSelect(); + enhanceDataPermissionInSelect(plainSelect); + } + if (select instanceof SetOperationList) { + SetOperationList setOperationList = select.getSetOperationList(); + for (Select subSelect : setOperationList.getSelects()) { + deepMatch(subSelect.getPlainSelect()); + } + } +} +``` + +> **注意**:JSqlParser 5.0 对 API 进行了部分调整(如 `ParenthesedSelect` 替代旧版 `SubSelect`),升级时请参考官方迁移指南。 diff --git a/docs/capabilities/react/react.md b/docs/capabilities/react/react.md new file mode 100644 index 000000000..884ef7521 --- /dev/null +++ b/docs/capabilities/react/react.md @@ -0,0 +1,174 @@ +--- +name: react/react +module: react +description: React UI 框架配合 Ant Design 组件库,用于构建前端用户界面 +status: 已实现 +scope: 前端 +source: 框架:react +import: "react" +framework_version: 18.3.1 +--- + +## 解决什么问题 + +在企业级中后台与移动端业务系统中,前端开发面临以下核心痛点: + +- **UI 一致性差**:多页面、多团队并行开发时,缺乏统一的组件体系导致视觉风格与交互行为不一致,用户体验割裂。 +- **重复造轮子**:表单、表格、弹窗、导航等通用界面元素在每个项目中被反复实现,研发效率低下且质量参差不齐。 +- **状态管理复杂**:随着业务增长,组件间数据流变得难以追踪,传统命令式 DOM 操作模式维护成本急剧上升。 +- **前后端对接低效**:缺少与后端统一响应结构(`Response` / `SingleResponse` / `MultiResponse`)和分页查询(`PageRequest` + `Filter`)相匹配的前端消费范式。 + +React 18 + Ant Design 5 的组合提供了声明式 UI 编程模型与开箱即用的高质量企业级组件库,使开发者能够以组合式、可预测的方式快速构建一致的用户界面,并与 springboot-framework 后端的统一响应和数据查询能力无缝衔接。 + +## 如何使用 + +### 依赖安装 + +```bash +# 在 admin-ui 或 mobile-ui 目录下 +npm install react@^18.3.1 react-dom@^18.3.1 antd@^5.x +``` + +### 项目集成要点 + +1. **组件引入**:按需导入 Ant Design 组件,配合 Rsbuild 自动完成 Tree Shaking,无需额外配置 babel-plugin-import。 +2. **主题定制**:通过 Ant Design 5 的 ConfigProvider + Design Token 体系统一管理品牌色、圆角、字号等设计变量,确保多模块视觉一致。 +3. **与后端对接**:封装统一的 HTTP 请求层,将后端 `Response.errCode / errMessage` 映射为全局提示;将 `MultiResponse.data` + `PageRequest` 参数直接绑定到 ProTable / ProList 的 `request` 属性,实现分页筛选零胶水代码。 +4. **状态管理**:轻量场景使用 React Context + useReducer;跨模块共享状态推荐使用 Zustand 或 valtio,避免过度引入 Redux。 +5. **微前端集成**:admin-ui 与 mobile-ui 均通过 Module Federation 暴露/消费远程模块,React 作为共享依赖(shared singleton)确保运行时只存在一个实例。 + +### 关键 API + +| API / 概念 | 说明 | +|------------|------| +| `useState` / `useEffect` / `useMemo` | React Hooks,声明式状态与副作用管理 | +| `` | Ant Design 全局配置:主题 token、国际化、组件尺寸 | +| `` / `` | Ant Design Pro Components,内置搜索表单、分页、列定义,天然适配后端 PageRequest | +| `message` / `notification` | 全局反馈,对接后端 Response 错误码 | +| `useRequest` (ahooks) | 异步数据请求 Hook,支持缓存、重试、轮询 | + +## 使用实例 + +### 基础列表页(对接后端 MultiResponse + PageRequest) + +```tsx +import React from 'react'; +import { ProTable } from '@ant-design/pro-components'; +import type { ProColumns } from '@ant-design/pro-components'; +import { message } from 'antd'; +import { request } from '@/utils/request'; // 封装的统一请求 + +interface UserItem { + id: string; + name: string; + email: string; + status: number; +} + +const columns: ProColumns[] = [ + { title: '姓名', dataIndex: 'name' }, + { title: '邮箱', dataIndex: 'email', copyable: true }, + { + title: '状态', + dataIndex: 'status', + valueEnum: { 1: { text: '启用', status: 'Success' }, 0: { text: '禁用', status: 'Error' } }, + }, +]; + +const UserList: React.FC = () => { + return ( + + headerTitle="用户管理" + columns={columns} + rowKey="id" + request={async (params) => { + // params 自动携带 current, pageSize 及搜索条件 + const res = await request.get('/api/users', { params }); + if (!res.success) { + message.error(res.errMessage); + return { data: [], total: 0, success: false }; + } + return { data: res.data, total: res.totalElements, success: true }; + }} + search={{ labelWidth: 'auto' }} + pagination={{ defaultPageSize: 20 }} + /> + ); +}; + +export default UserList; +``` + +### 表单提交(对接后端 SingleResponse) + +```tsx +import React from 'react'; +import { ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components'; +import { message } from 'antd'; +import { request } from '@/utils/request'; + +interface CreateUserProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const CreateUserModal: React.FC = ({ open, onClose, onSuccess }) => { + return ( + { + const res = await request.post('/api/users', values); + if (res.success) { + message.success('创建成功'); + onSuccess(); + return true; + } + message.error(res.errMessage || '创建失败'); + return false; + }} + > + + + + + ); +}; + +export default CreateUserModal; +``` + +### 主题定制与国际化 + +```tsx +import React from 'react'; +import { ConfigProvider, zhCN } from 'antd'; + +const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ); +}; + +export default App; +``` + +以上示例展示了 React + Ant Design 在 springboot-framework 项目中的典型用法:列表页自动对接 `PageRequest` 分页与过滤、表单提交消费统一 `Response` 结构、以及通过 `ConfigProvider` 实现全局主题管控。结合 Module Federation 微前端架构,各业务模块可独立开发部署,同时共享统一的 UI 基础与设计规范。 diff --git a/docs/capabilities/redux/toolkit.md b/docs/capabilities/redux/toolkit.md new file mode 100644 index 000000000..718688160 --- /dev/null +++ b/docs/capabilities/redux/toolkit.md @@ -0,0 +1,308 @@ +--- +name: redux/toolkit +module: redux +description: Redux Toolkit 状态管理库,提供简化的 Redux 状态管理模式 +status: 已实现 +scope: 前端 +source: 框架:redux +import: "@reduxjs/toolkit" +framework_version: 2.2.7 +--- + +## 解决什么问题 + +原生 Redux 存在样板代码过多、配置繁琐、不可变数据更新复杂等痛点。Redux Toolkit (RTK) 作为官方推荐的状态管理方案,解决了以下核心问题: + +- **减少样板代码**:通过 `createSlice` 自动生成 action creators 和 reducer,无需手写 switch-case 和常量定义 +- **简化不可变更新**:内置 Immer.js,允许以"可变"风格编写 reducer,自动转换为安全的不可变更新 +- **标准化异步逻辑**:`createAsyncThunk` 统一处理请求生命周期(pending / fulfilled / rejected),避免手动管理 loading/error 状态 +- **开箱即用的 Store 配置**:`configureStore` 默认集成 thunk 中间件、序列化检查、开发工具,零配置即可使用 +- **TypeScript 友好**:完整类型推导,减少手写类型注解的负担 +- **性能优化内置**:提供 `createSelector`(Reselect)用于派生数据的记忆化计算,避免不必要的重渲染 + +适用于需要全局状态共享、复杂业务状态流转、多组件协同的前端应用,如 admin-ui 的管理后台、mobile-ui 的业务流程等场景。 + +## 如何使用 + +### 安装与引入 + +```bash +npm install @reduxjs/toolkit react-redux +``` + +### 核心 API + +#### configureStore — 创建 Store + +```typescript +import { configureStore } from '@reduxjs/toolkit' + +const store = configureStore({ + reducer: { + user: userReducer, + order: orderReducer, + }, + // devTools、thunk、serializableCheck 均默认启用 +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch +``` + +#### createSlice — 定义状态切片 + +```typescript +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +interface UserState { + name: string + token: string | null +} + +const initialState: UserState = { name: '', token: null } + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + setUser(state, action: PayloadAction<{ name: string; token: string }>) { + // 直接"修改"state,Immer 自动处理不可变更新 + state.name = action.payload.name + state.token = action.payload.token + }, + logout(state) { + state.name = '' + state.token = null + }, + }, +}) + +export const { setUser, logout } = userSlice.actions +export default userSlice.reducer +``` + +#### createAsyncThunk — 异步操作 + +```typescript +import { createAsyncThunk } from '@reduxjs/toolkit' + +export const fetchUser = createAsyncThunk( + 'user/fetchUser', + async (userId: string, thunkAPI) => { + const response = await fetch(`/api/users/${userId}`) + return response.json() + } +) + +// 在 slice 的 extraReducers 中处理三种状态 +extraReducers: (builder) => { + builder + .addCase(fetchUser.pending, (state) => { state.loading = true }) + .addCase(fetchUser.fulfilled, (state, action) => { + state.loading = false + state.data = action.payload + }) + .addCase(fetchUser.rejected, (state, action) => { + state.loading = false + state.error = action.error.message ?? 'Unknown error' + }) +} +``` + +#### createSelector — 派生数据记忆化 + +```typescript +import { createSelector } from '@reduxjs/toolkit' + +const selectOrders = (state: RootState) => state.order.list +const selectFilter = (state: RootState) => state.order.filter + +export const selectFilteredOrders = createSelector( + [selectOrders, selectFilter], + (orders, filter) => orders.filter(o => o.status === filter) +) +``` + +### React 绑定 + +```typescript +import { useSelector, useDispatch } from 'react-redux' +import type { RootState, AppDispatch } from './store' + +// 带类型的 hooks +const useAppSelector = useSelector.withTypes() +const useAppDispatch = useDispatch.withTypes() +``` + +## 使用实例 + +### 完整的用户模块示例 + +```typescript +// features/user/userSlice.ts +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import { userService } from '../../services/userService' + +export interface UserInfo { + id: string + username: string + role: string +} + +interface UserState { + current: UserInfo | null + list: UserInfo[] + loading: boolean + error: string | null +} + +const initialState: UserState = { + current: null, + list: [], + loading: false, + error: null, +} + +// 异步:获取当前用户 +export const loadCurrentUser = createAsyncThunk( + 'user/loadCurrent', + async () => { + return await userService.getCurrentUser() + } +) + +// 异步:获取用户列表 +export const loadUserList = createAsyncThunk( + 'user/loadList', + async ({ keyword } = {}) => { + return await userService.list({ keyword }) + } +) + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + clearError(state) { + state.error = null + }, + resetUser(state) { + Object.assign(state, initialState) + }, + }, + extraReducers: (builder) => { + builder + // loadCurrentUser + .addCase(loadCurrentUser.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(loadCurrentUser.fulfilled, (state, action) => { + state.loading = false + state.current = action.payload + }) + .addCase(loadCurrentUser.rejected, (state, action) => { + state.loading = false + state.error = action.error.message ?? '加载失败' + }) + // loadUserList + .addCase(loadUserList.pending, (state) => { + state.loading = true + }) + .addCase(loadUserList.fulfilled, (state, action) => { + state.loading = false + state.list = action.payload + }) + .addCase(loadUserList.rejected, (state, action) => { + state.loading = false + state.error = action.error.message ?? '列表加载失败' + }) + }, +}) + +export const { clearError, resetUser } = userSlice.actions +export default userSlice.reducer +``` + +```tsx +// features/user/UserProfile.tsx +import { useEffect } from 'react' +import { useAppSelector, useAppDispatch } from '../../app/hooks' +import { loadCurrentUser } from './userSlice' + +export function UserProfile() { + const dispatch = useAppDispatch() + const { current, loading, error } = useAppSelector((state) => state.user) + + useEffect(() => { + dispatch(loadCurrentUser()) + }, [dispatch]) + + if (loading) return
加载中...
+ if (error) return
错误: {error}
+ if (!current) return
未登录
+ + return ( +
+

{current.username}

+

角色: {current.role}

+
+ ) +} +``` + +### RTK Query — 声明式数据获取 + +```typescript +// app/api.ts +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +export const api = createApi({ + reducerPath: 'api', + baseQuery: fetchBaseQuery({ baseUrl: '/api', credentials: 'include' }), + tagTypes: ['User'], + endpoints: (builder) => ({ + getUsers: builder.query({ + query: ({ keyword }) => `/users?keyword=${keyword ?? ''}`, + providesTags: ['User'], + }), + updateUser: builder.mutation>({ + query: (body) => ({ url: `/users/${body.id}`, method: 'PUT', body }), + invalidatesTags: ['User'], + }), + }), +}) + +export const { useGetUsersQuery, useUpdateUserMutation } = api +``` + +```tsx +// 组件中使用 RTK Query hooks +function UserList() { + const { data: users, isLoading, error } = useGetUsersQuery({ keyword: 'admin' }) + const [updateUser] = useUpdateUserMutation() + + if (isLoading) return + if (error) return + + return ( + ( + + ), + }, + ]} + /> + ) +} +``` + +此模式在 admin-ui 和 mobile-ui 中广泛使用,配合 Module Federation 微前端架构,各子应用可独立管理自身状态切片,同时通过共享 Store 实现跨应用状态同步。 diff --git a/docs/capabilities/springboot-starter-data-authorization/sql-interception.md b/docs/capabilities/springboot-starter-data-authorization/sql-interception.md new file mode 100644 index 000000000..eab7667c5 --- /dev/null +++ b/docs/capabilities/springboot-starter-data-authorization/sql-interception.md @@ -0,0 +1,221 @@ +--- +name: springboot-starter-data-authorization/sql-interception +module: springboot-starter-data-authorization +description: SQL 拦截数据权限,通过 JDBC 代理透明注入权限条件实现行级数据过滤 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-authorization" +symbols: + - SQLRunningContext + - ConnectionProxy + - PreparedStatementProxy + - StatementProxy + - DefaultSQLInterceptor +content_hash: d1ffbdc7fb8d45d6cc91248e02de5373e4ac8f0b417ecf34cd62ecf6709a2641 +--- + +## 解决什么问题 + +在企业级应用中,不同角色的用户只能看到自己有权限访问的数据(行级数据权限)。传统实现方式存在以下痛点: + +- **侵入性强**:需要在每个 Service/Repository 方法中手动拼接权限条件,代码散落各处 +- **容易遗漏**:新增查询接口时可能忘记添加权限过滤,导致数据泄露 +- **维护困难**:权限规则变更时需要修改大量业务代码 +- **与业务耦合**:权限逻辑与业务查询逻辑混杂,违反单一职责原则 + +SQL 拦截数据权限通过 JDBC 代理层实现了完全透明的权限注入: + +- **零侵入**:业务代码无需任何修改,所有 SELECT 查询自动注入权限条件 +- **全覆盖**:无论通过 JPA、MyBatis、原生 JDBC 还是其他 ORM 框架执行的 SQL,都会被统一拦截 +- **灵活配置**:通过 `RowHandler` 接口按表名自定义权限规则,支持 WHERE 条件和 JOIN 关联两种注入模式 +- **可跳过**:提供 `skipDataAuthorization()` API,允许特定场景下临时绕过权限检查 +- **SQL 解析增强**:使用 JSqlParser 解析 SQL AST,精确识别表名和别名,支持子查询、UNION、JOIN 等复杂 SQL 结构 + +## 如何使用 + +### 1. 引入依赖 + +```xml + + com.codingapi.springboot + springboot-starter-data-authorization + +``` + +### 2. 架构概览 + +整个拦截链路由以下组件构成: + +``` +DataSource → ConnectionProxy → PreparedStatementProxy / StatementProxy + ↓ + SQLRunningContext.intercept(sql) + ↓ + SQLInterceptor (DefaultSQLInterceptor) + ↓ + DataPermissionSQLEnhancer (JSqlParser) + ↓ + RowHandler.handler(tableName, alias) + ↓ + Condition (WHERE / JOIN 条件注入) +``` + +### 3. 核心组件说明 + +#### ConnectionProxy + +JDBC `Connection` 的代理实现。在 `prepareStatement()`、`createStatement()`、`prepareCall()` 等方法中拦截 SQL,调用 `SQLRunningContext.intercept(sql)` 获取改写后的 SQL,并将 `SQLExecuteState` 传递给下游的 Statement 代理。 + +#### PreparedStatementProxy / StatementProxy + +JDBC `PreparedStatement` 和 `Statement` 的代理实现。在执行 `executeQuery()`、`execute()` 等方法时,确保使用经过权限改写的 SQL。对于直接传入 SQL 字符串的方法(如 `executeQuery(String sql)`),会再次调用 `SQLRunningContext.intercept()` 进行拦截。查询结果通过 `ResultSetProxy` 包装返回。 + +#### SQLRunningContext + +SQL 拦截的核心调度器(单例模式),负责: +- 从 `SQLInterceptorContext` 获取当前 `SQLInterceptor` 实例 +- 通过 `ThreadLocal skipInterceptor` 控制是否跳过拦截 +- 调用 `SQLInterceptor.beforeHandler()` 判断是否需要拦截(默认仅拦截 SELECT 语句) +- 调用 `SQLInterceptor.postHandler()` 执行 SQL 改写 +- 提供 `skipDataAuthorization(Supplier/Runnable)` API 临时跳过权限检查 + +#### DefaultSQLInterceptor + +默认的 SQL 拦截器实现,包含三个阶段的处理: +- `beforeHandler(sql)`:通过 `SQLUtils.isQuerySql()` 判断是否为查询语句,仅 SELECT 会被拦截 +- `postHandler(sql)`:创建 `DataPermissionSQLEnhancer`,使用 JSqlParser 解析 SQL 并通过 `RowHandler` 获取权限条件,返回增强后的 SQL +- `afterHandler(sql, newSql, exception)`:日志记录,当配置 `showSql=true` 时输出改写后的 SQL + +#### RowHandler + +行级权限处理器接口,由业务方实现: + +```java +public interface RowHandler { + Condition handler(String subSql, String tableName, String tableAlias); +} +``` + +返回值 `Condition` 支持两种注入模式: +- **WHERE 条件**:`Condition.customCondition("dept_id IN (1,2,3)")` — 在 WHERE 子句中追加 AND 条件 +- **JOIN 关联**:通过 `JoinConditionSQL` 添加 INNER/LEFT/RIGHT JOIN 关联表 + +### 4. 跳过数据权限 + +在某些管理操作或系统任务中需要绕过数据权限: + +```java +// 方式一:Lambda 表达式 +List allUsers = SQLRunningContext.getInstance() + .skipDataAuthorization(() -> userRepository.findAll()); + +// 方式二:Runnable +SQLRunningContext.getInstance() + .skipDataAuthorization(() -> { + reportService.generateMonthlyReport(); + }); +``` + +## 使用实例 + +### 示例一:按部门过滤数据 + +```java +@Component +public class DeptRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + // 仅对 employee 表注入权限条件 + if ("employee".equalsIgnoreCase(tableName)) { + List deptIds = SecurityContext.getCurrentDeptIds(); + if (deptIds == null || deptIds.isEmpty()) { + return Condition.emptyCondition(); // 无权限,返回 null 不注入 + } + String inClause = deptIds.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + return Condition.customCondition( + String.format("%s.dept_id IN (%s)", tableAlias, inClause) + ); + } + // 其他表不注入权限条件 + return Condition.emptyCondition(); + } +} +``` + +效果:原始 SQL `SELECT * FROM employee WHERE status = 'active'` 被改写为: +```sql +SELECT * FROM employee WHERE dept_id IN (1,2,3) AND status = 'active' +``` + +### 示例二:通过 JOIN 关联实现跨表权限 + +```java +@Component +public class ProjectRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + if ("project".equalsIgnoreCase(tableName)) { + Condition condition = new Condition(); + // 通过 JOIN 关联成员表,只查询当前用户参与的项目 + JoinConditionSQL joinSQL = new JoinConditionSQL( + "project_member pm", + JoinConditionSQL.Type.INNER, + String.format("pm.project_id = %s.id AND pm.user_id = %d", + tableAlias, SecurityContext.getCurrentUserId()) + ); + condition.addConditionSQL(joinSQL); + return condition; + } + return Condition.emptyCondition(); + } +} +``` + +效果:原始 SQL `SELECT * FROM project WHERE status = 'open'` 被改写为: +```sql +SELECT * FROM project +INNER JOIN project_member pm ON pm.project_id = project.id AND pm.user_id = 1001 +WHERE status = 'open' +``` + +### 示例三:自定义 SQLInterceptor + +如需替换默认的拦截逻辑(例如增加缓存或审计),可实现 `SQLInterceptor` 接口并注册为 Spring Bean: + +```java +@Component +public class AuditSQLInterceptor implements SQLInterceptor { + + @Override + public boolean beforeHandler(String sql) { + // 仅拦截 SELECT 且不包含系统表的查询 + return SQLUtils.isQuerySql(sql) && !sql.contains("sys_config"); + } + + @Override + public DataPermissionSQL postHandler(String sql) throws SQLException { + RowHandler rowHandler = RowHandlerContext.getInstance().getRowHandler(); + DataPermissionSQLEnhancer enhancer = new DataPermissionSQLEnhancer(sql, rowHandler); + return new DataPermissionSQL(sql, enhancer.getNewSQL(), enhancer.getTableAlias()); + } + + @Override + public void afterHandler(String sql, String newSql, SQLException exception) { + // 记录审计日志 + AuditLog.record(sql, newSql, exception); + } +} +``` + +### 内部工作原理 + +1. **连接代理**:DataSource 返回的 `Connection` 被包装为 `ConnectionProxy` +2. **SQL 拦截时机**:当调用 `connection.prepareStatement(sql)` 时,`ConnectionProxy` 立即调用 `SQLRunningContext.intercept(sql)` 对 SQL 进行改写 +3. **递归解析**:`DataPermissionSQLEnhancer` 使用 JSqlParser 解析 SQL AST,深度遍历 PlainSelect、SetOperationList(UNION)、子查询、JOIN 中的子 Select,对每个涉及的表调用 `RowHandler` +4. **条件注入**:`WhereConditionSQLHandler` 将 WHERE 条件通过 AND 拼接到原有 WHERE 子句;`JoinConditionSQLHandler` 向 FROM 子句追加 JOIN 关联 +5. **防重入**:`SQLRunningContext` 使用 ThreadLocal 标记,在拦截器内部执行的查询不会被二次拦截 diff --git a/docs/capabilities/springboot-starter-data-fast/fast-repository.md b/docs/capabilities/springboot-starter-data-fast/fast-repository.md new file mode 100644 index 000000000..6643f6ef0 --- /dev/null +++ b/docs/capabilities/springboot-starter-data-fast/fast-repository.md @@ -0,0 +1,170 @@ +--- +name: springboot-starter-data-fast/fast-repository +module: springboot-starter-data-fast +description: JPA 增强 Repository,支持 PageRequest 动态过滤查询和 HQL 构建 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-fast" +symbols: + - FastRepository +content_hash: f840da63a739c7b4e23172d900dd2347480547290e1f947bc45f54f3e9916a53 +--- + +## 解决什么问题 + +在标准 Spring Data JPA 开发中,动态条件查询通常需要手动编写 `Specification`、`QueryDSL` 或拼接 HQL,代码冗长且难以维护。当业务列表页面需要支持多字段组合筛选(等于、模糊、范围、IN 等)时,开发者往往要为每种查询场景编写独立的 Repository 方法或复杂的 Specification 构建逻辑。 + +`FastRepository` 通过扩展 `JpaRepository` 和 `JpaSpecificationExecutor`,提供了基于 `PageRequest` 的声明式动态查询能力: + +- **自动 Example 查询**:当 Filter 条件均为简单等值匹配时,自动转换为 Spring Data `Example` 查询,零额外代码 +- **HQL 动态构建**:当包含模糊、范围、IN 等复杂条件时,自动构建参数化 HQL,避免 SQL 注入风险 +- **SearchRequest 集成**:支持从 HTTP 请求参数中自动解析 filter、sort 条件,适用于前端列表页的通用查询接口 +- **OR/AND 组合过滤**:支持嵌套的 OR/AND 条件组合,满足复杂业务筛选需求 + +## 如何使用 + +### 1. 定义 Repository 接口 + +继承 `FastRepository` 即可获得全部动态查询能力: + +```java +public interface UserEntityRepository extends FastRepository { + // 标准 JpaRepository 方法仍然可用 + UserEntity getUserEntityByUsername(String username); +} +``` + +### 2. 使用 PageRequest 进行动态过滤查询 + +```java +// 创建分页请求并添加过滤条件 +PageRequest request = PageRequest.of(0, 20); +request.addFilter("name", "张三"); // 等值匹配 +request.addFilter("age", Relation.GT, 18); // 大于 +request.addFilter("email", Relation.LIKE, "gmail"); // 模糊查询 + +// 方式一:自动选择 Example 或 HQL(推荐) +Page page = repository.findAll(request); + +// 方式二:强制使用 HQL 查询(适合复杂条件) +Page page2 = repository.pageRequest(request); +``` + +### 3. 支持的过滤关系(Relation) + +| Relation | 说明 | HQL 示例 | +|----------|------|----------| +| EQ(默认) | 等于 | `name = ?1` | +| NEQ | 不等于 | `name != ?1` | +| GT | 大于 | `age > ?1` | +| LT | 小于 | `age < ?1` | +| GTE | 大于等于 | `age >= ?1` | +| LTE | 小于等于 | `age <= ?1` | +| LIKE | 全模糊 | `name LIKE ?1`(自动加 `%value%`) | +| LEFT_LIKE | 左模糊 | `name LIKE ?1`(自动加 `%value`) | +| RIGHT_LIKE | 右模糊 | `name LIKE ?1`(自动加 `value%`) | +| IN | 包含 | `id IN (?1)` | +| NOT_IN | 不包含 | `id NOT IN (?1)` | +| BETWEEN | 区间 | `age BETWEEN ?1 AND ?2` | +| IS_NULL | 为空 | `name IS NULL` | +| IS_NOT_NULL | 非空 | `name IS NOT NULL` | + +### 4. 使用 SearchRequest 从 HTTP 请求自动解析 + +```java +// 在 Controller 中使用 SearchRequest,自动从 URL 参数解析 filter 和 sort +@GetMapping("/users") +public MultiResponse list() { + SearchRequest searchRequest = new SearchRequest(); + searchRequest.addFilter("status", "active"); // 追加服务端固定条件 + Page page = userRepository.searchRequest(searchRequest); + return MultiResponse.of(page.getContent()); +} +``` + +前端通过 URL 参数传递动态条件: +- `?filter=eyJuYW1lIjpbIuW8oCJdfQ==`(Base64 编码的 JSON:`{"name":["张"]}`) +- `?sort=eyJjcmVhdGVUaW1lIjoiZGVzY2VuZCJ9`(Base64 编码的 JSON:`{"createTime":"descend"}`) + +### 5. OR / AND 组合条件 + +```java +PageRequest request = PageRequest.of(0, 20); + +// OR 条件:name = '张三' OR name = '李四' +request.orFilters( + new Filter("name", "张三"), + new Filter("name", "李四") +); + +// AND 条件组 +request.andFilter( + new Filter("age", Relation.GTE, 18), + new Filter("status", "active") +); +``` + +## 使用实例 + +### 完整示例:用户列表查询 + +```java +@Service +public class UserQueryService { + + @Resource + private UserEntityRepository userRepository; + + /** + * 基础动态查询 - 自动 Example/HQL + */ + public Page findUsers(String name, Integer minAge, String status) { + PageRequest request = PageRequest.of(0, 20); + if (name != null) { + request.addFilter("name", Relation.LIKE, name); + } + if (minAge != null) { + request.addFilter("age", Relation.GTE, minAge); + } + if (status != null) { + request.addFilter("status", status); + } + return userRepository.findAll(request); + } + + /** + * 复杂 HQL 查询 - 带排序 + */ + public Page findActiveUsersWithSort() { + PageRequest request = PageRequest.of(0, 20, Sort.by("createTime").descending()); + request.addFilter("status", "active"); + request.addFilter("age", Relation.BETWEEN, 18, 65); + return userRepository.pageRequest(request); + } + + /** + * 前端驱动的通用查询接口 + */ + public Page searchFromHttpRequest() { + SearchRequest searchRequest = new SearchRequest(); + // 追加服务端安全条件,防止越权查询 + searchRequest.addFilter("deleted", false); + return userRepository.searchRequest(searchRequest); + } +} +``` + +### 内部工作原理 + +`FastRepository.findAll(PageRequest)` 的执行流程: + +1. 检查 `request.hasFilter()` — 无过滤条件时直接委托给 Spring Data 的标准 `findAll(PageRequest)` +2. 有过滤条件时,通过 `ExampleBuilder` 尝试构建 `Example` 对象(仅处理等值匹配的属性) +3. 将 Example 与 PageRequest 一起传入 `findAll(Example, Pageable)` 执行查询 + +`FastRepository.pageRequest(PageRequest)` 的执行流程: + +1. 通过 `DynamicSQLBuilder` 根据 Filter 列表动态构建 HQL 语句和 COUNT 语句 +2. 所有值通过参数化绑定(`?1`, `?2`...),防止 SQL 注入 +3. 调用 `dynamicPageQuery(hql, countHql, request, params)` 执行分页查询 diff --git a/docs/capabilities/springboot-starter-flow/workflow-engine.md b/docs/capabilities/springboot-starter-flow/workflow-engine.md new file mode 100644 index 000000000..0350e651d --- /dev/null +++ b/docs/capabilities/springboot-starter-flow/workflow-engine.md @@ -0,0 +1,154 @@ +--- +name: springboot-starter-flow/workflow-engine +module: springboot-starter-flow +description: 工作流引擎,支持流程定义、节点流转、审批、委托、会签和数据快照 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-flow" +symbols: + - FlowWork + - FlowNode + - FlowNodeService + - FlowWorkBuilder + - FlowStartService + - FlowApprovalEvent +content_hash: 0deb7127074232e52335663402b08ead2e42469e6813e555a3dc06e97362fd04 +--- + +## 解决什么问题 + +在企业级应用中,审批流程(如请假、报销、采购等)是高频且复杂的业务场景。传统硬编码方式存在以下痛点: + +- **流程变更成本高**:每次调整审批节点或流转规则都需要修改代码并重新部署 +- **审批模式单一**:难以同时支持会签、或签、传阅、退回、委托等多种审批形态 +- **状态追踪困难**:流程实例的当前节点、历史审批记录、数据快照缺乏统一管理 +- **事件通知分散**:审批通过、拒绝、转办等状态变更需要在多处手动触发通知逻辑 + +`workflow-engine` 提供了一套轻量级的嵌入式工作流引擎,将流程定义与业务代码解耦。通过 Builder 模式声明式构建流程,引擎自动处理节点流转、操作者匹配、数据快照绑定和事件推送,让开发者专注于业务逻辑本身。 + +## 如何使用 + +### 核心概念 + +| 概念 | 类 | 说明 | +|------|-----|------| +| 流程定义 | `FlowWork` | 描述一个完整的审批流程,包含节点集合、关系集合、启用状态等 | +| 流程节点 | `FlowNode` | 流程中的单个环节(开始/审批/传阅/结束),配置审批类型、操作者匹配器、超时时间等 | +| 节点关系 | `FlowRelation` | 定义节点之间的流转规则,支持条件触发(`OutTrigger`)和退回标记 | +| 流程构建器 | `FlowWorkBuilder` | 链式 API 构建 `FlowWork`,内部自动校验节点和关系的完整性 | +| 发起服务 | `FlowStartService` | 发起新流程实例,创建流程备份、数据快照和首条待办记录 | +| 节点服务 | `FlowNodeService` | 驱动节点流转:加载下一节点、匹配操作者、创建审批记录、处理传阅跳过 | +| 审批事件 | `FlowApprovalEvent` | 同步事件,覆盖创建/待办/通过/拒绝/转办/撤回/完成/催办/抄送/退回等 14 种状态 | +| 流程操作者 | `IFlowOperator` | 用户接口,支持委托 (`entrustOperator()`) 和管理员强制干预 | + +### 构建流程 + +使用 `FlowWorkBuilder` 声明式定义流程: + +```java +FlowWork work = FlowWorkBuilder.builder(operator) + .title("请假审批流程") + .description("员工请假审批") + .skipIfSameApprover(true) // 相同审批人自动跳过 + .postponedMax(3) // 最大延期次数 + .nodes() + .node("发起", "start", "startView", ApprovalType.UN_SIGN, startMatcher) + .node("部门审批", "dept_approve", "approveView", ApprovalType.SIGN, deptMatcher, true, false) + .node("HR备案", "hr_record", "hrView", ApprovalType.UN_SIGN, hrMatcher) + .node("结束", "over", "overView", ApprovalType.UN_SIGN, endMatcher) + .relations() + .relation("发起->部门审批", "start", "dept_approve") + .relation("部门审批->HR备案", "dept_approve", "hr_record") + .relation("HR备案->结束", "hr_record", "over") + .build(); +``` + +构建时 `build()` 会自动调用 `enable()` → `verify()`,校验以下内容: +- 必须存在 `start` 和 `over` 节点及其关联关系 +- 节点 code 不能重复 +- 每个节点的 `titleGenerator` 和 `operatorMatcher` 不能为空 + +### 发起流程 + +通过 `FlowStartService` 发起流程实例: + +```java +FlowStartService startService = new FlowStartService( + workCode, operator, bindData, advice, repositoryHolder); +FlowResult result = startService.startFlow(); +``` + +启动过程依次执行:加载并校验流程定义 → 创建版本快照(`FlowBackup`)→ 保存流程实例 → 序列化绑定数据 → 从 start 节点创建待办记录 → 推送 `FlowApprovalEvent`。 + +### 监听审批事件 + +实现 `IHandler` 即可接收所有审批状态变更: + +```java +@Component +public class LeaveApprovalHandler implements IHandler { + @Override + public void handle(FlowApprovalEvent event) { + if (event.isTodo()) { + // 发送待办通知 + } + if (event.isPass()) { + // 审批通过处理 + } + if (event.isFinish()) { + // 流程结束归档 + } + } +} +``` + +事件通过框架的 `EventPusher` 同步推送,在同一个事务内完成。 + +## 使用实例 + +以下示例展示一个完整的请假审批流程定义与发起: + +```java +// 1. 定义操作者匹配器 +OperatorMatcher startMatcher = OperatorMatcher.any(); // 发起人 +OperatorMatcher deptMatcher = OperatorMatcher.script( // 部门负责人 + "session.bindData.deptLeaderId"); +OperatorMatcher hrMatcher = OperatorMatcher.script( // HR + "session.bindData.hrUserId"); +OperatorMatcher endMatcher = OperatorMatcher.any(); + +// 2. 构建流程 +FlowWork leaveFlow = FlowWorkBuilder.builder(currentUser) + .title("请假审批") + .skipIfSameApprover(true) + .nodes() + .node("发起申请", "start", "leaveStart", ApprovalType.UN_SIGN, startMatcher) + .node("部门审批", "dept", "leaveApprove", ApprovalType.SIGN, deptMatcher, true, false) + .node("HR确认", "hr", "leaveHr", ApprovalType.UN_SIGN, hrMatcher) + .node("结束", "over", "leaveEnd", ApprovalType.UN_SIGN, endMatcher) + .relations() + .relation("提交", "start", "dept") + .relation("批准", "dept", "hr") + .relation("确认", "hr", "over") + .build(); + +// 3. 准备业务数据 +LeaveRequest leave = new LeaveRequest(); +leave.setUserId(currentUser.getUserId()); +leave.setDays(3); +leave.setDeptLeaderId(deptLeaderId); +leave.setHrUserId(hrUserId); + +// 4. 发起流程 +FlowStartService service = new FlowStartService( + leaveFlow.getCode(), currentUser, leave, "申请年假3天", repoHolder); +FlowResult result = service.startFlow(); + +// 5. 获取待办记录 +List records = result.getRecords(); +records.forEach(r -> System.out.println( + "待办: " + r.getTitle() + " -> " + r.getCurrentOperator().getName())); +``` + +当部门审批人点击通过后,引擎自动流转至 HR 确认节点;若设置了退回关系,审批人可选择退回至指定节点。全程数据快照通过 `BindDataSnapshot` 持久化,确保审批过程中业务数据不可变。 diff --git a/docs/capabilities/springboot-starter-script/groovy-runtime.md b/docs/capabilities/springboot-starter-script/groovy-runtime.md new file mode 100644 index 000000000..ac51d1aca --- /dev/null +++ b/docs/capabilities/springboot-starter-script/groovy-runtime.md @@ -0,0 +1,164 @@ +--- +name: springboot-starter-script/groovy-runtime +module: springboot-starter-script +description: Groovy 脚本运行时引擎,支持运行时编译、LRU 缓存、热更新和 REST API +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-script" +symbols: + - GroovyScriptRuntime + - GroovyScript + - GroovyScriptCacheContext + - GroovyScriptRuntimeContext + - GroovyScriptController +content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526 +--- + +## 解决什么问题 + +在企业应用中,部分业务逻辑需要频繁调整但不希望每次都经历"改代码→编译→部署"的完整周期。典型场景包括: + +- **动态规则计算**:促销折扣、费率计算、风控阈值等业务规则经常变化 +- **自定义报表/导出**:不同租户或部门的报表格式差异大,用脚本比硬编码更灵活 +- **流程条件表达式**:工作流节点的条件判断、操作者匹配等需要运行时求值 +- **临时数据处理**:运维脚本、数据修复等一次性任务 + +`groovy-runtime` 提供了嵌入式的 Groovy 脚本执行引擎,具备以下特性: + +- **运行时编译执行**:无需重启应用即可加载和执行新脚本 +- **LRU 编译缓存**:基于 SHA256 指纹缓存已编译的 Script 对象,避免重复编译开销 +- **三级存储**:内存缓存 → 临时存储 → 持久化仓库,兼顾性能和可靠性 +- **事务集成**:支持只读(READONLY)和提交(COMMIT)两种事务模式 +- **REST API**:内置 Controller 提供脚本编译、查询、保存接口,方便管理端对接 + +## 如何使用 + +### 核心组件 + +| 组件 | 职责 | +|------|------| +| `GroovyScriptRuntime` | 底层执行引擎:GroovyShell 封装、LRU 编译缓存、invoke/run 方法、事务控制 | +| `GroovyScriptRuntimeContext` | Runtime 的单例上下文,从配置读取 `shellMaxCacheSize` 初始化 | +| `GroovyScript` | 脚本领域对象:封装 key/script/method/returnType/binds 等元信息,提供 compile/run/invoke 快捷方法 | +| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository | +| `GroovyScriptController` | REST 端点 `/api/groovy-script/*`,提供 compile/getScript/getMetadata/save 接口 | + +### 直接执行脚本 + +通过 `GroovyScriptRuntimeContext` 单例直接运行脚本: + +```java +GroovyScriptRuntimeContext ctx = GroovyScriptRuntimeContext.getInstance(); + +// 简单执行 +String result = ctx.run("'hello ' + name", String.class, + TransactionMode.DEFAULT, Map.of("name", "world")); + +// 调用脚本中的函数 +int sum = ctx.invoke("add", "def add(a,b){ a + b }", int.class, + TransactionMode.DEFAULT, null, 3, 5); +``` + +### 使用 GroovyScript 对象 + +对于需要持久化和复用的脚本,使用 `GroovyScript` 封装: + +```java +GroovyScript script = GroovyScript.builder("discount_calc") + .script(""" + def calculate(price, rate) { + return price * rate + } + """) + .method("calculate") + .returnType(BigDecimal.class) + .description("折扣计算脚本") + .tag("pricing") + .build(); + +// 编译并缓存 +script.compile(true); + +// 执行 +BigDecimal result = script.invoke(Map.of(), new BigDecimal("100"), new BigDecimal("0.85")); + +// 持久化到仓库 +script.save(); +``` + +### 事务模式 + +`TransactionMode` 支持三种模式: + +| 模式 | 行为 | +|------|------| +| `DEFAULT` | 不参与事务,由调用方自行控制 | +| `READONLY` | 在只读事务中执行,适合查询类脚本 | +| `COMMIT` | 在可提交事务中执行,适合写入类脚本 | + +### REST API + +内置的 `GroovyScriptController` 提供以下端点: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/groovy-script/compile` | 编译脚本(body: `{script, cache}`) | +| GET | `/api/groovy-script/getScript?key=` | 获取脚本内容 | +| GET | `/api/groovy-script/getMetadata?key=` | 获取脚本元数据 | +| POST | `/api/groovy-script/save` | 保存脚本(编译+持久化) | + +## 使用实例 + +以下示例展示一个完整的动态折扣计算场景: + +```java +// 1. 创建并保存折扣计算脚本 +GroovyScript discountScript = GroovyScript.builder("member_discount") + .script(""" + def calcDiscount(amount, memberLevel) { + switch(memberLevel) { + case 'GOLD': return amount * 0.8 + case 'SILVER': return amount * 0.9 + default: return amount + } + } + """) + .method("calcDiscount") + .returnType(BigDecimal.class) + .description("会员折扣计算") + .typeOne("pricing") + .typeTwo("discount") + .build(); + +// 编译并持久化 +discountScript.compile(true); +discountScript.save(); + +// 2. 后续使用时直接从缓存获取 +GroovyScript cached = GroovyScriptCacheContext.getInstance() + .getGroovyScript("member_discount"); + +// 3. 执行业务计算 +BigDecimal originalAmount = new BigDecimal("500"); +BigDecimal finalPrice = cached.invoke( + TransactionMode.READONLY, null, originalAmount, "GOLD"); +// finalPrice = 400.0 + +// 4. 热更新:修改脚本后重新保存即可生效 +cached.setScript(""" + def calcDiscount(amount, memberLevel) { + switch(memberLevel) { + case 'DIAMOND': return amount * 0.7 + case 'GOLD': return amount * 0.8 + case 'SILVER': return amount * 0.9 + default: return amount + } + } +"""); +cached.compile(true); +cached.save(); +// 下次 invoke 自动使用新版本 +``` + +缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。 diff --git a/docs/capabilities/springboot-starter-security/auth-gateway.md b/docs/capabilities/springboot-starter-security/auth-gateway.md new file mode 100644 index 000000000..f3161fd1d --- /dev/null +++ b/docs/capabilities/springboot-starter-security/auth-gateway.md @@ -0,0 +1,201 @@ +--- +name: springboot-starter-security/auth-gateway +module: springboot-starter-security +description: 安全认证网关,支持 JWT 无状态认证和 Redis 有状态认证两种模式 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-security" +symbols: + - TokenGateway + - TokenContext + - Token + - MyLoginFilter + - MyAuthenticationFilter +content_hash: f387cbcf0fa1de5a7c51bf0d2afb16e342b53d42ad2031f6741cb6db4e4e2625 +--- + +## 解决什么问题 + +在企业级 Web 应用中,用户认证是安全体系的第一道防线。Spring Security 原生提供的表单登录和 Session 机制不适合前后端分离架构,开发者通常需要自行实现以下能力: + +- **Token 生命周期管理**:创建、解析、过期校验、自动续期 +- **多认证模式切换**:JWT 无状态模式适合微服务和移动端,Redis 有状态模式适合需要服务端主动踢人的管理后台 +- **登录流程定制**:在认证前后插入自定义逻辑(验证码校验、登录日志、额外业务数据注入等) +- **统一 JSON 响应**:认证成功/失败均返回标准 `Response` 格式,而非 Spring Security 默认的重定向或 HTML 页面 + +auth-gateway 将上述能力封装为开箱即用的安全网关层,通过 `TokenGateway` 策略接口抽象 Token 的创建与解析,配合 `MyLoginFilter`(登录拦截)和 `MyAuthenticationFilter`(请求鉴权拦截)两个 Servlet Filter,实现完整的认证闭环。开发者只需通过配置选择 JWT 或 Redis 模式,即可零代码获得生产级认证能力。 + +## 如何使用 + +### 1. 引入依赖 + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +### 2. 选择认证模式 + +通过 `application.properties` 启用其中一种模式(二选一): + +**JWT 无状态模式:** + +```properties +codingapi.security.jwt.enable=true +codingapi.security.jwt.secret-key=your-secret-key-must-be-at-least-32-chars +codingapi.security.jwt.valid-time=900000 # Token 有效期 15 分钟(毫秒) +codingapi.security.jwt.rest-time=600000 # 10 分钟后自动续期(毫秒) +``` + +**Redis 有状态模式:** + +```properties +codingapi.security.redis.enable=true +codingapi.security.redis.valid-time=900000 +codingapi.security.redis.rest-time=600000 +``` + +### 3. 配置安全策略 + +```properties +# 需要认证的 URL 模式(逗号分隔) +codingapi.security.authenticated-urls=/api/** + +# 免认证 URL 模式 +codingapi.security.ignore-urls=/open/**,/#/** + +# 登录接口地址 +codingapi.security.login-processing-url=/user/login + +# 登出接口地址 +codingapi.security.logout-url=/user/logout +``` + +### 4. 核心 API + +| 类 | 说明 | +|---|---| +| `TokenGateway` | Token 策略接口,提供 `create()` 和 `parser()` 方法。框架根据配置自动注入 `JWTTokenGatewayImpl` 或 `RedisTokenGatewayImpl` | +| `Token` | Token 数据对象,包含 username、authorities、extra、expireTime、remindTime 等字段,支持 `verify()` 过期校验和 `canRestToken()` 自动续期判断 | +| `TokenContext` | 线程安全的 Token 上下文工具类。`TokenContext.current()` 获取当前登录用户的 Token;`TokenContext.pushExtra()` / `getExtra()` 传递额外业务数据 | +| `SecurityLoginHandler` | 登录扩展点接口。实现 `preHandle()` 可在认证前做自定义校验;实现 `postHandle()` 可定制登录成功响应 | +| `AuthenticationTokenFilter` | 鉴权后扩展点接口。每次请求通过 Token 验证后回调,可用于刷新用户缓存等场景 | + +### 5. 自定义 UserDetailsService + +框架默认提供内存用户(admin/user),生产环境需自行实现: + +```java +@Service +public class CustomUserDetailsService implements UserDetailsService { + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 从数据库加载用户信息 + } +} +``` + +## 使用实例 + +### 示例 1:自定义登录处理器 + +在登录前校验验证码,登录后返回额外的用户信息: + +```java +@Component +public class CustomSecurityLoginHandler implements SecurityLoginHandler { + + @Override + public void preHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest) throws Exception { + // 校验验证码 + String captcha = loginRequest.getString("captcha"); + if (captcha == null || !captchaService.verify(captcha)) { + throw new AuthenticationServiceException("验证码错误"); + } + } + + @Override + public LoginResponse postHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest, UserDetails user, Token token) { + LoginResponse loginResponse = new LoginResponse(); + loginResponse.setToken(token.getToken()); + loginResponse.setUsername(token.getUsername()); + loginResponse.setAuthorities(token.getAuthorities()); + // 附加用户头像等信息 + Map data = new HashMap<>(); + data.put("avatar", "/static/avatar/default.png"); + loginResponse.setData(data); + return loginResponse; + } +} +``` + +### 示例 2:在业务代码中获取当前用户 + +```java +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + + @GetMapping + public SingleResponse> getProfile() { + // 从 SecurityContext 获取当前 Token + Token token = TokenContext.current(); + String username = token.getUsername(); + List roles = token.getAuthorities(); + + // 解析 extra 中的自定义数据 + UserInfo extra = token.parseExtra(UserInfo.class); + + Map profile = new HashMap<>(); + profile.put("username", username); + profile.put("roles", roles); + profile.put("department", extra != null ? extra.getDepartment() : null); + return SingleResponse.of(profile); + } +} +``` + +### 示例 3:鉴权后刷新用户缓存 + +```java +@Component +public class CacheRefreshFilter implements AuthenticationTokenFilter { + + private final UserService userService; + + public CacheRefreshFilter(UserService userService) { + this.userService = userService; + } + + @Override + public void doFilter(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + Token token = TokenContext.current(); + // 每次认证通过后刷新用户权限缓存 + userService.refreshPermissionCache(token.getUsername()); + } +} +``` + +### 前端调用示例 + +```javascript +// 登录 +const res = await fetch('/user/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'admin' }) +}); +const { data } = await res.json(); +// data.token → 保存 Token + +// 携带 Token 访问受保护接口 +const profile = await fetch('/api/profile', { + headers: { 'Authorization': data.token } +}); +``` diff --git a/docs/capabilities/springboot-starter/domain-proxy.md b/docs/capabilities/springboot-starter/domain-proxy.md new file mode 100644 index 000000000..93fc3cc2a --- /dev/null +++ b/docs/capabilities/springboot-starter/domain-proxy.md @@ -0,0 +1,134 @@ +--- +name: springboot-starter/domain-proxy +module: springboot-starter +description: 领域实体变更代理,通过 CGLIB 代理拦截实体字段变更并自动推送领域事件 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - DomainProxyFactory + - DomainChangeInterceptor + - IDomain + - DomainCreateEvent + - DomainChangeEvent + - DomainDeleteEvent + - DomainPersistEvent +content_hash: 94b780644f3676986d6041a009a7bf58e42543180ab2f6e1c0349244ea62032e +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)实践中,领域实体的状态变更需要产生对应的领域事件(创建、变更、删除、持久化),以便下游处理器执行副作用操作(如发送通知、更新缓存、触发工作流等)。然而手动在每个 setter 方法中编写事件推送代码存在以下痛点: + +1. **侵入性强**:每个实体类的修改方法都需要显式调用 `EventPusher.push()`,业务逻辑与事件机制耦合。 +2. **容易遗漏**:开发者可能忘记在某些字段变更后推送事件,导致数据不一致。 +3. **变更追踪困难**:无法自动获取字段的旧值与新值,手动记录增加出错风险。 +4. **嵌套对象变更不可见**:当实体包含子对象时,子对象字段的变更更难被感知和追踪。 + +`domain-proxy` 能力通过 CGLIB 动态代理透明地拦截实体方法调用,自动比较字段值变化并推送 `DomainChangeEvent`,同时提供完整的实体生命周期事件(创建、持久化、删除),让领域事件的发布对业务代码零侵入。 + +## 如何使用 + +### 核心组件 + +| 组件 | 说明 | +|------|------| +| `IDomain` | 领域实体标记接口,提供 `persist()` 和 `delete()` 默认方法,分别推送 `DomainPersistEvent` 和 `DomainDeleteEvent` | +| `DomainProxyFactory` | 静态工厂类,通过 `create(Class, Object... args)` 创建代理实例,同时自动推送 `DomainCreateEvent` | +| `DomainChangeInterceptor` | CGLIB `MethodInterceptor` 实现,拦截带参数的方法调用,对比执行前后字段值差异,自动推送 `DomainChangeEvent` | +| `DomainEvent` | 领域事件基类,携带实体引用、实体类型和时间戳 | +| `DomainCreateEvent` | 实体创建事件,由 `DomainProxyFactory.create()` 自动推送 | +| `DomainChangeEvent` | 实体字段变更事件,包含 `fieldName`、`oldValue`、`newValue` | +| `DomainDeleteEvent` | 实体删除事件,通过 `IDomain.delete()` 推送 | +| `DomainPersistEvent` | 实体持久化事件,通过 `IDomain.persist()` 推送 | + +### 使用步骤 + +1. **定义领域实体**:创建实体类并实现 `IDomain` 接口。实体类需要有公开的构造函数供代理工厂反射调用。 + +2. **通过工厂创建实例**:使用 `DomainProxyFactory.create(EntityClass.class, constructorArgs...)` 代替 `new` 关键字创建实体。工厂会自动创建 CGLIB 代理并推送 `DomainCreateEvent`。 + +3. **正常调用业务方法**:对代理对象调用任何带参数的方法后,拦截器会自动比较所有字段(包括嵌套对象)的值变化,若有变更则推送 `DomainChangeEvent`。 + +4. **触发生命周期事件**:在适当时机调用 `entity.persist()` 推送持久化事件,或调用 `entity.delete()` 推送删除事件。 + +5. **注册事件处理器**:编写 `IHandler`、`IHandler` 等处理器 Bean,框架会自动扫描并注册。 + +### 注意事项 + +- 代理仅拦截**带参数**的方法调用,无参方法(如 getter)不会触发变更检测。 +- 支持基本类型(String、数值、布尔、枚举等)的直接比较,以及嵌套对象的递归字段比较。 +- 事件推送通过 `EventPusher` 进入框架的事件系统,遵循同步/异步分发和循环检测机制。 + +## 使用实例 + +### 定义领域实体 + +```java +public class User implements IDomain { + + @Getter + private final long id; + + @Getter + private String name; + + @Getter + private String email; + + public User(String name, String email) { + this.id = System.currentTimeMillis(); + this.name = name; + this.email = email; + } + + public void changeName(String name) { + this.name = name; + } + + public void changeEmail(String email) { + this.email = email; + } +} +``` + +### 创建代理并触发事件 + +```java +// 通过工厂创建代理实例,自动推送 DomainCreateEvent +User user = DomainProxyFactory.create(User.class, "张三", "zhangsan@example.com"); + +// 调用带参方法后,拦截器自动检测字段变更并推送 DomainChangeEvent +user.changeName("李四"); // → DomainChangeEvent(fieldName="name", oldValue="张三", newValue="李四") +user.changeEmail("li@example.com"); // → DomainChangeEvent(fieldName="email", ...) + +// 手动触发持久化事件 +user.persist(); // → DomainPersistEvent + +// 手动触发删除事件 +user.delete(); // → DomainDeleteEvent +``` + +### 编写事件处理器 + +```java +@Component +public class UserChangeHandler implements IHandler { + + @Override + public void handler(DomainChangeEvent event) { + log.info("用户字段变更: {} = {} → {}", + event.getFieldName(), event.getOldValue(), event.getNewValue()); + } +} + +@Component +public class UserCreateHandler implements IHandler { + + @Override + public void handler(DomainCreateEvent event) { + log.info("新用户创建: {}", event.getEntity().getClass().getSimpleName()); + } +} +``` diff --git a/docs/capabilities/springboot-starter/event-system.md b/docs/capabilities/springboot-starter/event-system.md new file mode 100644 index 000000000..1c5256f7f --- /dev/null +++ b/docs/capabilities/springboot-starter/event-system.md @@ -0,0 +1,283 @@ +--- +name: springboot-starter/event-system +module: springboot-starter +description: 事件发布-订阅系统,支持同步/异步事件、事件循环检测、事务事件 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - EventPusher + - IEvent + - IHandler + - ISyncEvent + - IAsyncEvent + - DomainEventContext + - ApplicationHandlerUtils + - SpringHandlerConfiguration + - EventTraceContext + - EventStackContext +content_hash: f911f56fe4d24daddc91a560d340759aa3c208f085ebc0aca84954d8b8d4ae9a +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)架构中,领域事件是实现聚合间解耦、触发副作用的核心机制。然而在实际开发中,事件系统面临以下痛点: + +1. **事件与主业务事务的边界模糊**:开发者容易将事件处理与主业务强绑定,导致事务范围膨胀、性能下降。框架需要明确"事件对主业务可成功可失败"的设计原则。 +2. **同步与异步事件的区分困难**:部分场景要求事件在同一事务内同步执行(如数据校验),部分场景则需异步解耦(如发送通知)。手动管理线程池和执行模式增加复杂度。 +3. **事件循环调用风险**:当 Handler A 处理 EventX 时又推送了 EventY,而 EventY 的 Handler 又推送了 EventX,就会形成无限循环。缺乏自动检测机制会导致栈溢出或系统挂起。 +4. **多 Handler 执行的异常隔离**:同一事件可能被多个 Handler 订阅,某个 Handler 抛异常不应影响其他 Handler 的执行,同时需要收集所有异常统一上报。 +5. **Handler 注册与排序繁琐**:手动注册处理器、维护执行顺序的代码分散且易出错。 + +本模块提供了一套完整的发布-订阅事件基础设施,通过 `EventPusher` 统一入口、`IEvent` 类型体系区分同步/异步、`EventTraceContext` + `EventStackContext` 实现事件链路追踪与循环检测、`ApplicationHandlerUtils` 自动匹配并按序分发 Handler,以及可选的事务提交后触发模式(`SpringTransactionEventHandler`)。 + +## 如何使用 + +### 核心接口 + +| 接口/类 | 说明 | +|---------|------| +| `IEvent` | 事件标记接口,继承 `Serializable`。默认视为同步事件 | +| `ISyncEvent` | 同步事件标记接口,继承 `IEvent`。事件在当前线程同步执行 | +| `IAsyncEvent` | 异步事件标记接口,继承 `IEvent`。事件在线程池中异步执行 | +| `IHandler` | 事件处理器接口,泛型 `T` 指定订阅的事件类型 | +| `EventPusher` | 事件推送静态工具类,是发布事件的唯一入口 | +| `@Handler` | 注解,标记一个类为事件处理器 Bean(也可使用 `@Component`/`@Service`) | + +### IHandler 接口方法 + +```java +public interface IHandler { + // 执行顺序,数值越小越先执行,默认为 0 + default int order() { return 0; } + + // 事件处理逻辑 + void handler(T event); + + // 异常回调,默认重新抛出异常(阻止后续 Handler 执行) + // 可覆盖此方法实现异常吞没或自定义处理 + default void error(Exception exception) throws Exception { + throw exception; + } +} +``` + +### 事件推送 + +```java +// 推送事件(自动根据 IEvent 子接口判断同步/异步) +EventPusher.push(new MyEvent(data)); + +// 显式声明允许循环事件(跳过循环检测) +EventPusher.push(new MyEvent(data), true); +``` + +事件类型的判定规则: +- 实现 `IAsyncEvent` → 异步执行(线程池) +- 实现 `ISyncEvent` → 同步执行(当前线程) +- 仅实现 `IEvent` → 默认同步执行 + +### 配置项 + +```properties +# 启用事务事件模式(事件在事务提交后触发) +codingapi.framework.event.transaction.enable=true + +# 异步事件线程池大小(默认值见 PropertiesContext) +codingapi.framework.handler-thread-pool-size=20 +``` + +### 事件分发模式 + +框架通过 `SpringHandlerConfiguration` 自动装配事件分发器: + +- **默认模式**(`SpringDefaultEventHandler`):使用 Spring `@EventListener` 即时触发,同步事件在当前线程执行,异步事件提交到固定大小线程池。 +- **事务模式**(`SpringTransactionEventHandler`):当配置 `codingapi.framework.event.transaction.enable=true` 时激活,使用 `@TransactionalEventListener(phase = AFTER_COMMIT)` 确保事件在事务提交后才触发,避免读取到未提交的数据。设置 `fallbackExecution = true` 保证无事务上下文时也能正常执行。 + +两种模式互斥,事务模式优先(`@ConditionalOnMissingBean` 兜底默认模式)。 + +### 事件循环检测 + +框架通过 `EventTraceContext` 和 `EventStackContext` 实现同一条事件链路内的循环检测: + +1. 每次推送事件时生成/复用 traceId,将事件类压入该 traceId 对应的事件栈。 +2. 若同一 traceId 下出现相同事件类,立即抛出 `EventLoopException`,并附带完整的事件调用栈信息。 +3. 事件处理完成后自动清理 traceId 和事件栈。 +4. 若确实需要允许循环(如状态机重试),可调用 `EventPusher.push(event, true)` 跳过检测。 + +### Handler 异常处理策略 + +当某个 Handler 抛出异常时: +1. 若异常为 `EventLoopException`,直接向上抛出,终止后续所有 Handler。 +2. 否则调用该 Handler 的 `error(exception)` 回调。 +3. 若 `error()` 也抛出异常,标记为"有异常"并继续执行下一个 Handler。 +4. 所有 Handler 执行完毕后,若存在异常,统一包装为 `EventException` 抛出。 + +## 使用实例 + +### 1. 定义事件 + +```java +package com.example.domain.order.event; + +import com.codingapi.springboot.framework.event.ISyncEvent; +import lombok.Getter; +import lombok.AllArgsConstructor; + +/** + * 订单创建事件(同步) + */ +@Getter +@AllArgsConstructor +public class OrderCreatedEvent implements ISyncEvent { + private final String orderId; + private final String userId; + private final long amount; +} +``` + +```java +package com.example.domain.order.event; + +import com.codingapi.springboot.framework.event.IAsyncEvent; +import lombok.Getter; +import lombok.AllArgsConstructor; + +/** + * 订单通知事件(异步) + */ +@Getter +@AllArgsConstructor +public class OrderNotifyEvent implements IAsyncEvent { + private final String orderId; + private final String message; +} +``` + +### 2. 编写事件处理器 + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderCreatedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 订单创建后扣减库存(优先执行) + */ +@Slf4j +@Service +public class InventoryDeductHandler implements IHandler { + + @Override + public int order() { + return 1; // 最先执行 + } + + @Override + public void handler(OrderCreatedEvent event) { + log.info("扣减库存, orderId={}, amount={}", event.getOrderId(), event.getAmount()); + // inventoryService.deduct(event.getOrderId(), event.getAmount()); + } +} +``` + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderCreatedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 订单创建后记录积分(其次执行) + */ +@Slf4j +@Service +public class PointsRecordHandler implements IHandler { + + @Override + public int order() { + return 2; + } + + @Override + public void handler(OrderCreatedEvent event) { + log.info("记录积分, userId={}", event.getUserId()); + // pointsService.record(event.getUserId(), event.getAmount()); + } + + @Override + public void error(Exception exception) { + // 积分记录失败不影响其他 Handler,仅记录日志 + log.error("积分记录失败, 但不阻断流程", exception); + } +} +``` + +```java +package com.example.handler; + +import com.codingapi.springboot.framework.event.IHandler; +import com.example.domain.order.event.OrderNotifyEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 异步发送订单通知 + */ +@Slf4j +@Service +public class OrderNotifyHandler implements IHandler { + + @Override + public void handler(OrderNotifyEvent event) { + log.info("发送通知, orderId={}, message={}", event.getOrderId(), event.getMessage()); + // notificationService.send(event.getOrderId(), event.getMessage()); + } +} +``` + +### 3. 在领域服务中推送事件 + +```java +package com.example.domain.order.service; + +import com.codingapi.springboot.framework.event.EventPusher; +import com.example.domain.order.event.OrderCreatedEvent; +import com.example.domain.order.event.OrderNotifyEvent; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OrderService { + + @Transactional + public String createOrder(String userId, long amount) { + String orderId = generateOrderId(); + // ... 持久化订单实体 ... + + // 同步事件:在当前事务内执行库存扣减、积分记录 + EventPusher.push(new OrderCreatedEvent(orderId, userId, amount)); + + // 异步事件:事务外异步发送通知 + EventPusher.push(new OrderNotifyEvent(orderId, "您的订单已创建")); + + return orderId; + } +} +``` + +### 4. 启用事务事件模式(可选) + +若希望所有事件都在事务提交后再触发(避免 Handler 读到未提交数据),在 `application.properties` 中添加: + +```properties +codingapi.framework.event.transaction.enable=true +``` + +此时 `SpringTransactionEventHandler` 替代默认的 `SpringDefaultEventHandler`,所有事件将在 `AFTER_COMMIT` 阶段触发。 diff --git a/docs/capabilities/springboot-starter/exception-handling.md b/docs/capabilities/springboot-starter/exception-handling.md new file mode 100644 index 000000000..fa5dd32e4 --- /dev/null +++ b/docs/capabilities/springboot-starter/exception-handling.md @@ -0,0 +1,142 @@ +--- +name: springboot-starter/exception-handling +module: springboot-starter +description: 全局异常处理,统一拦截 Controller 层异常并返回标准 Response +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - ExceptionConfiguration + - BasicHandlerExceptionResolverConfiguration +content_hash: e2cf2bcf396be0bf0a00caf77f61e89c1f00b90213d98196d70971b61a7e0f54 +--- + +## 解决什么问题 + +在 Spring MVC 项目中,Controller 层抛出的异常如果不做统一处理,会导致以下问题: + +- **响应格式不一致**:不同接口返回的错误结构各异(有的返回 HTML 错误页、有的返回纯文本、有的返回自定义 JSON),前端难以统一解析。 +- **错误码缺失**:原生异常只有 message,没有业务错误码(errCode),前端无法根据错误码做多语言提示或差异化逻辑。 +- **国际化困难**:错误信息硬编码在代码中,无法根据用户 Locale 动态切换语言。 +- **敏感信息泄露**:未捕获的异常可能将堆栈信息直接暴露给客户端。 + +`springboot-starter` 的异常处理能力通过 `HandlerExceptionResolver` 机制,在 Servlet 层面统一拦截所有 Controller 异常,将其转换为标准的 `{success, errCode, errMessage}` JSON 响应,同时结合 `LocaleMessageException` 和 `MessageSource` 实现错误信息的国际化管理。 + +## 如何使用 + +### 自动生效 + +引入 `springboot-starter` 依赖后,异常处理自动配置即可生效,无需额外注解或配置: + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +框架通过以下两个配置类自动注册: + +- **`ExceptionConfiguration`**:注册 `LocaleMessage` Bean,绑定 Spring `MessageSource`,在初始化时将自身注入 `MessageContext` 单例,为 `LocaleMessageException` 提供国际化消息解析能力。 +- **`BasicHandlerExceptionResolverConfiguration`**:当 classpath 中存在 `HandlerExceptionResolver`(即 Spring MVC 环境)时,注册 `ServletExceptionHandler` 作为全局异常解析器。 + +### 异常类型与响应映射 + +| 异常类型 | errCode | errMessage | 说明 | +|---------|---------|------------|------| +| `LocaleMessageException` | 异常中指定的 errCode | 国际化解析后的消息 | 业务异常,推荐使用 | +| 其他 `Exception` | `system.err` | `ex.getMessage()` | 未预期的系统异常 | + +### 抛出业务异常 + +在 Service 或 Controller 中抛出 `LocaleMessageException`,支持三种构造方式: + +```java +// 1. 直接使用错误码(从 messages.properties 中解析消息) +throw new LocaleMessageException("user.not.found"); + +// 2. 错误码 + 占位符参数 +throw LocaleMessageException.of("order.amount.exceed", maxAmount); + +// 3. 错误码 + 自定义消息(不走国际化) +throw new LocaleMessageException("custom.error", "自定义错误描述"); +``` + +### 配置国际化消息 + +在 `src/main/resources/messages.properties`(及对应的多语言文件)中定义错误消息: + +```properties +# messages.properties +user.not.found=用户不存在 +order.amount.exceed=订单金额超出限制,最大金额为 {0} + +# messages_en.properties +user.not.found=User not found +order.amount.exceed=Order amount exceeds limit, maximum is {0} +``` + +## 使用实例 + +### 完整示例:Controller 中抛出业务异常 + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + @Autowired + private UserService userService; + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + User user = userService.findById(id); + if (user == null) { + // 抛出国际化业务异常 + throw new LocaleMessageException("user.not.found"); + } + return SingleResponse.of(user); + } + + @PostMapping + public Response createUser(@RequestBody CreateUserRequest request) { + if (request.getAmount().compareTo(MAX_AMOUNT) > 0) { + // 带占位符参数的异常 + throw LocaleMessageException.of("order.amount.exceed", MAX_AMOUNT); + } + userService.create(request); + return Response.buildSuccess(); + } +} +``` + +当请求触发异常时,框架自动返回统一格式的 JSON 响应: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "用户不存在" +} +``` + +若当前请求的 Locale 为英文,则自动返回: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "User not found" +} +``` + +对于未预期的系统异常(如 NullPointerException),返回: + +```json +{ + "success": false, + "errCode": "system.err", + "errMessage": "Cannot invoke method on null object" +} +``` diff --git a/docs/capabilities/springboot-starter/locale-message.md b/docs/capabilities/springboot-starter/locale-message.md new file mode 100644 index 000000000..d4fca5f3c --- /dev/null +++ b/docs/capabilities/springboot-starter/locale-message.md @@ -0,0 +1,121 @@ +--- +name: springboot-starter/locale-message +module: springboot-starter +description: 国际化异常消息,支持多语言异常信息和本地化消息解析 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - LocaleMessageException + - LocaleMessage + - MessageContext +content_hash: 1480d3ed2cf99ad0eb3cd955540f5bc263adcf0196bc8f0b3f79e713eb16b6ad +--- + +## 解决什么问题 + +在企业级应用中,异常消息往往需要面向不同语言和地区的用户展示本地化内容。直接在代码中硬编码中文或英文错误提示存在以下痛点: + +- **多语言维护困难**:错误消息散落在业务代码各处,新增语言时需要逐一修改源码。 +- **异常与消息耦合**:`RuntimeException` 只接受字符串消息,无法携带错误码进行统一拦截和翻译。 +- **占位符参数化缺失**:部分错误消息需要动态插入变量(如用户名、金额),拼接字符串容易出错且不利于翻译。 + +`locale-message` 能力通过 `LocaleMessageException` + Spring `MessageSource` 的组合,将错误码作为异常的语义标识,在抛出时自动根据当前请求的 `Locale` 从 `messages.properties` 资源文件中解析出对应的本地化消息,从而实现异常消息与业务代码的解耦和多语言支持。 + +## 如何使用 + +### 核心组件 + +| 类 | 职责 | +|----|------| +| `LocaleMessageException` | 国际化异常,支持仅传错误码、错误码+占位符参数、错误码+自定义消息等多种构造方式 | +| `LocaleMessage` | 封装 Spring `MessageSource`,提供按 code + args + locale 解析消息的能力 | +| `MessageContext` | 单例上下文,持有 `LocaleMessage` 实例,供异常类在静态上下文中获取本地化消息 | + +### 配置消息资源文件 + +在 `src/main/resources/` 下创建标准 Spring 消息资源文件: + +```properties +# messages.properties(默认/回退) +error.user.not.found=User not found: {0} +error.param.invalid=Invalid parameter: {0}, expected {1} + +# messages_zh_CN.properties +error.user.not.found=用户不存在: {0} +error.param.invalid=参数无效: {0},期望值 {1} +``` + +Spring Boot 会自动加载这些文件作为 `MessageSource`。 + +### 抛出国际化异常 + +`LocaleMessageException` 提供多种构造方式和静态工厂方法: + +```java +// 1. 仅传错误码 —— 自动从 messages.properties 解析消息 +throw new LocaleMessageException("error.user.not.found"); + +// 2. 错误码 + 占位符参数 +throw LocaleMessageException.of("error.user.not.found", "zhangsan"); + +// 3. 错误码 + 多个占位符参数 +throw LocaleMessageException.of("error.param.invalid", "age", "Integer"); + +// 4. 错误码 + 自定义消息(不走 MessageSource) +throw new LocaleMessageException("ERR_CUSTOM", "自定义错误消息"); + +// 5. 携带原始异常 +throw new LocaleMessageException("error.user.not.found", cause); +``` + +### 自动装配 + +框架启动时,`LocaleMessage` Bean 会被创建并调用 `init()` 方法,将自身注册到 `MessageContext` 单例中。`LocaleMessageException` 在构造时通过 `MessageContext.getInstance().getErrorMsg(errCode, args)` 获取本地化消息,整个过程对业务代码透明,无需手动注入任何 Bean。 + +当前请求的 `Locale` 由 Spring 的 `LocaleContextHolder` 提供(通常通过 `Accept-Language` 请求头或 `LocaleChangeInterceptor` 设置)。 + +## 使用实例 + +### 完整示例:用户查询服务 + +```java +@Service +public class UserService { + + @Autowired + private UserRepository userRepository; + + public User getUser(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> + LocaleMessageException.of("error.user.not.found", username) + ); + } + + public void updateUserAge(String username, int age) { + if (age < 0 || age > 150) { + throw LocaleMessageException.of("error.param.invalid", "age", "0~150"); + } + // ... + } +} +``` + +当客户端发送 `Accept-Language: zh-CN` 请求时,若用户不存在,返回的异常消息为 `"用户不存在: zhangsan"`;若未指定语言或使用默认 locale,则返回 `"User not found: zhangsan"`。 + +### 在全局异常处理器中统一捕获 + +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(LocaleMessageException.class) + public Response handleLocaleException(LocaleMessageException e) { + return Response.buildFailure(e.getErrCode(), e.getErrMessage()); + } +} +``` + +由于异常对象已经携带了本地化后的 `errMessage`,全局处理器只需直接提取即可,无需再做二次翻译。 diff --git a/docs/capabilities/springboot-starter/page-request.md b/docs/capabilities/springboot-starter/page-request.md new file mode 100644 index 000000000..bf94c95a5 --- /dev/null +++ b/docs/capabilities/springboot-starter/page-request.md @@ -0,0 +1,182 @@ +--- +name: springboot-starter/page-request +module: springboot-starter +description: 动态分页查询请求,扩展 Spring PageRequest 支持 RequestFilter 动态过滤条件 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - PageRequest + - RequestFilter + - SearchRequest + - Filter +content_hash: c4302ccd858320110405285846ca4d4087b4844a4334452fa63ee34b68fffa5f +--- + +## 解决什么问题 + +在企业级后台管理系统中,列表查询是最常见的业务场景之一。Spring Data 原生的 `PageRequest` 仅支持分页参数(页码、大小、排序),无法携带动态过滤条件。开发者通常需要为每个查询接口手动编写 Specification、QueryDSL 或自定义 HQL,导致大量重复的查询构建代码。 + +`PageRequest` 能力解决了以下痛点: + +- **动态过滤**:在分页请求对象上直接附加任意字段、任意比较关系的过滤条件,无需为每种查询组合编写独立方法。 +- **前端驱动查询**:通过 `SearchRequest` 自动解析 HTTP 请求参数(包括 Base64 编码的 `filter`、`sort`、`params` JSON),将前端传入的筛选/排序规则转换为类型安全的过滤条件,减少 Controller 层样板代码。 +- **复杂条件组合**:支持 AND/OR 嵌套组合过滤,满足多条件联合查询需求。 +- **与 FastRepository 无缝集成**:`FastRepository.findAll(PageRequest)` 和 `pageRequest(PageRequest)` 自动根据过滤条件构建 Example 或 HQL 查询,开发者只需关注业务逻辑。 + +## 如何使用 + +### 核心类说明 + +| 类 | 职责 | +|---|------| +| `PageRequest` | 继承 Spring `PageRequest`,增加 `RequestFilter` 和链式 `addFilter` API | +| `RequestFilter` | 过滤条件容器,管理 `Filter` 列表并提供按 key 读取过滤值的快捷方法 | +| `Filter` | 单个过滤条件,包含字段名(key)、比较关系(Relation)和值 | +| `Relation` | 枚举,定义了 14 种比较关系:EQUAL、NOT_EQUAL、LIKE、LEFT_LIKE、RIGHT_LIKE、BETWEEN、IN、NOT_IN、IS_NULL、IS_NOT_NULL、GREATER_THAN、LESS_THAN、GREATER_THAN_EQUAL、LESS_THAN_EQUAL | +| `SearchRequest` | 从当前 `HttpServletRequest` 自动解析分页、排序、过滤参数并生成 `PageRequest` | + +### 创建 PageRequest + +```java +// 基本分页(第 0 页,每页 20 条) +PageRequest request = PageRequest.of(0, 20); + +// 带排序 +PageRequest request = PageRequest.of(0, 20, Sort.by("createTime").descending()); +``` + +### 添加过滤条件 + +```java +// 等值过滤(默认 EQUAL) +request.addFilter("name", "张三"); + +// 指定比较关系 +request.addFilter("age", Relation.GREATER_THAN, 18); +request.addFilter("createTime", Relation.BETWEEN, startDate, endDate); +request.addFilter("status", Relation.IN, "ACTIVE", "PENDING"); + +// AND / OR 组合 +request.andFilter( + Filter.as("deptId", 1), + Filter.as("roleId", 2) +); + +request.orFilters( + Filter.as("name", Relation.LIKE, "张"), + Filter.as("email", Relation.LIKE, "zhang") +); +``` + +### 读取过滤值 + +```java +String name = request.getStringFilter("name"); +int age = request.getIntFilter("age", 0); +boolean hasConditions = request.hasFilter(); +``` + +### 配合 FastRepository 使用 + +```java +// findAll — 简单过滤走 Example 查询 +Page page = userRepository.findAll(request); + +// pageRequest — 复杂过滤走 HQL 动态查询 +Page page = userRepository.pageRequest(request); + +// searchRequest — 从 HTTP 请求自动解析 +Page page = userRepository.searchRequest(searchRequest); +``` + +### SearchRequest 自动解析 + +`SearchRequest` 从当前 HTTP 请求中提取以下参数: + +| 参数 | 格式 | 说明 | +|------|------|------| +| `current` | int | 页码(从 0 开始) | +| `pageSize` | int | 每页大小 | +| `sort` | Base64(JSON) | 排序规则,如 `{"createTime":"descend"}` | +| `filter` | Base64(JSON) | 过滤条件,如 `{"status":["ACTIVE"]}` | +| `params` | Base64(JSON Array) | 指定字段的比较关系,如 `[{"key":"age","type":"GREATER_THAN"}]` | +| 其他参数 | string | 作为 EQUAL 过滤条件自动添加 | + +## 使用实例 + +### 示例 1:Service 层手动构建动态查询 + +```java +@Service +public class UserQueryService { + + @Autowired + private UserRepository userRepository; + + public Page searchUsers(String keyword, Integer minAge, String status) { + PageRequest request = PageRequest.of(0, 20); + request.addSort(Sort.by("createTime").descending()); + + if (keyword != null) { + request.addFilter("name", Relation.LIKE, keyword); + } + if (minAge != null) { + request.addFilter("age", Relation.GREATER_THAN_EQUAL, minAge); + } + if (status != null) { + request.addFilter("status", status); + } + + return userRepository.findAll(request); + } +} +``` + +### 示例 2:Controller 层使用 SearchRequest 自动绑定 + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + @Autowired + private UserRepository userRepository; + + @GetMapping + public MultiResponse list(SearchRequest searchRequest) { + // 自动从 HTTP 参数解析 current、pageSize、sort、filter + Page page = userRepository.searchRequest(searchRequest); + return MultiResponse.of(page.getContent(), (int) page.getTotalElements()); + } +} +``` + +前端请求示例: +``` +GET /api/users?current=0&pageSize=20 + &sort=eyJjcmVhdGVUaW1lIjoiZGVzY2VuZCJ9 + &filter=eyJzdGF0dXMiOlsiQUNUSVZFIiwiUEVORElORyJdfQ== + ¶ms=W3sia2V5IjoiYWdlIiwidHlwZSI6IkdSRUFURVJfVEhBTiJ9XQ== + &deptId=1 +``` + +其中 `filter` 解码后为 `{"status":["ACTIVE","PENDING"]}`,`params` 解码后为 `[{"key":"age","type":"GREATER_THAN"}]`,`deptId` 作为额外 EQUAL 条件自动加入。 + +### 示例 3:AND/OR 组合过滤 + +```java +PageRequest request = PageRequest.of(0, 20); + +// (deptId=1 AND roleId=2) OR (name LIKE '%admin%') +request.orFilters( + Filter.and( + Filter.as("deptId", 1), + Filter.as("roleId", 2) + ), + Filter.as("name", Relation.LIKE, "admin") +); + +Page page = userRepository.pageRequest(request); +``` diff --git a/docs/capabilities/springboot-starter/response-dto.md b/docs/capabilities/springboot-starter/response-dto.md new file mode 100644 index 000000000..3794043fd --- /dev/null +++ b/docs/capabilities/springboot-starter/response-dto.md @@ -0,0 +1,177 @@ +--- +name: springboot-starter/response-dto +module: springboot-starter +description: 统一响应封装,提供 Response/SingleResponse/MultiResponse/MapResponse 四种响应类型 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - Response + - SingleResponse + - MultiResponse + - MapResponse +content_hash: 1dfe2559a6b514effb51735ca8ca49aba308959e02814c5862b842eed3b04fb1 +--- + +## 解决什么问题 + +在企业级 REST API 开发中,前后端交互需要统一的响应契约。若每个接口各自定义返回结构,会导致以下问题: + +- **前端解析复杂**:不同接口的成功/失败字段命名不一致,前端需逐接口适配 +- **错误处理分散**:缺乏统一的 `errCode` / `errMessage` 规范,全局异常拦截难以标准化 +- **分页结构不统一**:列表接口的总数、数据字段名各异,通用分页组件无法复用 +- **序列化行为不可控**:响应对象在缓存、消息队列等场景下需要可靠的 JSON 序列化能力 + +`response-dto` 提供了四种标准响应类型,覆盖无数据、单对象、列表/分页、键值对四类返回场景,使所有 API 输出遵循同一契约。基类 `Response` 实现了 `JsonSerializable` 接口,支持通过 Fastjson 进行一致的 JSON 序列化。 + +## 如何使用 + +### 类层次结构 + +``` +JsonSerializable (interface) + └── Response — 基础响应(success / errCode / errMessage) + ├── SingleResponse — 单对象响应,data 字段为泛型 T + ├── MultiResponse — 列表响应,data 字段包含 total + list + └── MapResponse — 键值对响应,data 字段为 Map +``` + +### 核心 API + +| 类 | 静态工厂方法 | 说明 | +|---|---|---| +| `Response` | `buildSuccess()` | 构建成功响应(无业务数据) | +| `Response` | `buildFailure(errCode, errMessage)` | 构建失败响应 | +| `SingleResponse` | `of(T data)` | 包装单个业务对象 | +| `SingleResponse` | `empty()` | 返回 data=null 的成功响应 | +| `MultiResponse` | `of(Collection data, long total)` | 包装集合 + 总数 | +| `MultiResponse` | `of(Page page)` | 直接包装 Spring Data Page 对象 | +| `MultiResponse` | `of(Collection data)` | 包装集合,total 自动取 size | +| `MultiResponse` | `empty()` | 返回空列表的成功响应 | +| `MapResponse` | `create()` | 创建空的键值对响应 | +| `MapResponse` | `empty()` | 返回 data=null 的键值对响应 | +| `MapResponse` | `add(key, value)` | 链式添加键值对 | + +### JSON 序列化 + +所有响应类均继承自 `Response`,而 `Response` 实现了 `JsonSerializable` 接口,可直接调用 `toJson()` 方法获取 JSON 字符串: + +```java +String json = response.toJson(); // 使用 Fastjson 序列化 +``` + +### Maven 依赖 + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +## 使用实例 + +### 1. 无数据的操作响应 + +```java +@PostMapping("/save") +public Response save(@RequestBody UserRequest request) { + userService.save(request); + return Response.buildSuccess(); +} +``` + +返回 JSON: +```json +{ "success": true, "errCode": null, "errMessage": null } +``` + +### 2. 返回单个对象 + +```java +@GetMapping("/detail") +public SingleResponse detail(@RequestParam Long id) { + UserVO user = userQueryService.getById(id); + return SingleResponse.of(user); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { "id": 1, "name": "张三", "email": "zhangsan@example.com" } +} +``` + +### 3. 返回列表(含分页) + +```java +@GetMapping("/list") +public MultiResponse list(SearchRequest searchRequest) { + Page page = userQueryService.page(searchRequest); + return MultiResponse.of(page); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "total": 100, + "list": [ + { "id": 1, "name": "张三" }, + { "id": 2, "name": "李四" } + ] + } +} +``` + +### 4. 返回键值对数据 + +```java +@GetMapping("/dashboard/stats") +public MapResponse dashboardStats() { + return MapResponse.create() + .add("userCount", 1024) + .add("orderCount", 5678) + .add("todayRevenue", 99800.50); +} +``` + +返回 JSON: +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "userCount": 1024, + "orderCount": 5678, + "todayRevenue": 99800.50 + } +} +``` + +### 5. 返回错误响应 + +```java +@PostMapping("/login") +public Response login(@RequestBody LoginRequest request) { + if (!userService.authenticate(request)) { + return Response.buildFailure("AUTH_FAILED", "用户名或密码错误"); + } + return Response.buildSuccess(); +} +``` + +返回 JSON: +```json +{ "success": false, "errCode": "AUTH_FAILED", "errMessage": "用户名或密码错误" } +``` diff --git a/docs/capabilities/springboot-starter/rest-client.md b/docs/capabilities/springboot-starter/rest-client.md new file mode 100644 index 000000000..579c94726 --- /dev/null +++ b/docs/capabilities/springboot-starter/rest-client.md @@ -0,0 +1,111 @@ +--- +name: springboot-starter/rest-client +module: springboot-starter +description: REST 客户端封装,提供 RestTemplate 上下文管理和 HTTPS 信任所有证书支持 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +symbols: + - RestTemplateContext + - TrustAnyHttpClientFactory +content_hash: 70d327e5685d61447513a8df011bccc1c0ab9153b4b1c1d3763096d86fd90e9c +--- + +## 解决什么问题 + +在企业内部系统集成和第三方 API 对接场景中,经常需要调用外部 HTTPS 服务。这些服务的证书可能由私有 CA 签发、已过期或主机名不匹配,导致标准 HTTP 客户端抛出 SSL 握手异常。同时,项目中多处使用 `RestTemplate` 时,如果每次都手动创建实例并配置底层 HttpClient,会导致代码重复且难以统一维护。 + +本能力通过两个核心类解决上述问题: + +- **TrustAnyHttpClientFactory** — 创建信任所有证书的 Apache HttpClient 5 实例,跳过服务端证书校验和主机名验证,适用于开发调试及内网可信环境下的 HTTPS 调用。 +- **RestTemplateContext** — 以单例模式持有预配置好的 `RestTemplate`(底层使用 TrustAnyHttpClientFactory),为全应用提供统一的 REST 客户端访问入口,避免重复创建和配置。 + +## 如何使用 + +### 依赖引入 + +在 Maven 项目中添加 springboot-starter 依赖即可: + +```xml + + com.codingapi.springboot + springboot-starter + +``` + +### 获取 RestTemplate 实例 + +通过 `RestTemplateContext` 的单例方法获取已配置好 HTTPS 信任策略的 `RestTemplate`: + +```java +RestTemplate restTemplate = RestTemplateContext.getInstance().getRestTemplate(); +``` + +该实例底层使用 Apache HttpComponents 5 作为传输层,并已启用以下特性: + +- TLS 协议下信任所有服务端证书(`TrustAnyTrustManager`) +- 禁用主机名验证(`NoopHostnameVerifier`) +- 允许循环重定向 + +### 自定义 ClientHttpRequestFactory + +如果需要替换默认的请求工厂,可以使用 `restTemplate(ClientHttpRequestFactory)` 方法创建新的 `RestTemplate` 实例: + +```java +ClientHttpRequestFactory customFactory = new HttpComponentsClientHttpRequestFactory(customHttpClient); +RestTemplate customTemplate = RestTemplateContext.getInstance().restTemplate(customFactory); +``` + +### 直接使用 TrustAnyHttpClientFactory + +若仅需创建信任所有证书的 HttpClient 而不使用 RestTemplateContext 封装,可直接调用静态工厂方法: + +```java +HttpClient httpClient = TrustAnyHttpClientFactory.createTrustAnyHttpClient(); +``` + +返回的 `HttpClient` 基于 Apache HttpClient 5,可用于任何需要绕过 SSL 校验的场景。 + +## 使用实例 + +### 示例 1:调用外部 HTTPS 接口 + +```java +@Service +public class ExternalApiService { + + private final RestTemplate restTemplate; + + public ExternalApiService() { + this.restTemplate = RestTemplateContext.getInstance().getRestTemplate(); + } + + public String fetchRemoteData(String url) { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return response.getBody(); + } + + public T postJson(String url, Object request, Class responseType) { + return restTemplate.postForObject(url, request, responseType); + } +} +``` + +### 示例 2:独立使用 TrustAnyHttpClientFactory + +```java +// 在不依赖 RestTemplateContext 的场景下,单独创建信任所有证书的 HttpClient +HttpClient httpClient = TrustAnyHttpClientFactory.createTrustAnyHttpClient(); + +// 配合 Spring 的 HttpComponentsClientHttpRequestFactory 构建自定义 RestTemplate +HttpComponentsClientHttpRequestFactory factory = + new HttpComponentsClientHttpRequestFactory(httpClient); +factory.setConnectTimeout(Duration.ofSeconds(5)); +factory.setReadTimeout(Duration.ofSeconds(30)); + +RestTemplate restTemplate = new RestTemplate(factory); +String result = restTemplate.getForObject("https://internal-api.example.com/data", String.class); +``` + +> ⚠️ **安全提示**:信任所有证书仅适用于开发调试和内网可信环境。在生产环境中应正确配置受信任的 CA 证书链,避免中间人攻击风险。 diff --git a/docs/capabilities/springboot/data-jpa.md b/docs/capabilities/springboot/data-jpa.md new file mode 100644 index 000000000..989b5f60b --- /dev/null +++ b/docs/capabilities/springboot/data-jpa.md @@ -0,0 +1,211 @@ +--- +name: springboot/data-jpa +module: springboot +description: Spring Data JPA,提供 ORM 映射、Repository 抽象和分页支持 +status: 已实现 +scope: 后端 +source: 框架:springboot +import: "org.springframework.boot:spring-boot-starter-data-jpa" +framework_version: 3.3.5 +--- + +## 解决什么问题 + +在企业级 Java 应用中,数据持久化是核心基础设施需求。直接使用 JDBC 或原生 JPA API 存在以下痛点: + +- **样板代码冗余**:每个实体都需要手动编写 CRUD 操作、SQL 拼接、结果集映射,重复劳动量大 +- **ORM 配置复杂**:原生 Hibernate/JPA 的 XML 或注解配置繁琐,事务管理和 EntityManager 生命周期需要手工控制 +- **分页与排序缺乏统一抽象**:不同数据库的分页语法差异大,业务层需要自行处理 offset/limit 逻辑 +- **查询方法命名约定缺失**:简单的条件查询也需要手写 JPQL/SQL,无法通过方法名自动推导 + +Spring Data JPA 通过 Repository 抽象层和自动实现机制,将上述问题封装为声明式接口,开发者只需定义接口和方法签名即可获得完整的持久化能力,大幅降低数据访问层的开发和维护成本。 + +## 如何使用 + +### 1. 添加依赖 + +在 `pom.xml` 中引入 Spring Boot Data JPA Starter: + +```xml + + org.springframework.boot + spring-boot-starter-data-jpa + +``` + +### 2. 配置数据源 + +在 `application.properties` 中配置数据库连接和 JPA 行为: + +```properties +spring.datasource.url=jdbc:h2:file:./data/demo +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +``` + +### 3. 定义实体类 + +使用 JPA 注解将 POJO 映射到数据库表: + +```java +@Entity +@Table(name = "t_user") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 64) + private String username; + + private String email; + + // getters & setters +} +``` + +### 4. 定义 Repository 接口 + +继承 `JpaRepository` 即可获得标准 CRUD、分页、排序等能力;通过方法命名约定可自动生成查询: + +```java +public interface UserRepository extends JpaRepository { + + List findByUsername(String username); + + Page findByEmailContaining(String keyword, Pageable pageable); + + Optional findByUsernameAndEmail(String username, String email); +} +``` + +### 5. 核心接口说明 + +| 接口 | 说明 | +|------|------| +| `CrudRepository` | 基础 CRUD 操作(save、findById、delete 等) | +| `PagingAndSortingRepository` | 继承 CrudRepository,增加分页和排序支持 | +| `JpaRepository` | 继承上述两者,增加批量操作、flush、Example 查询等 JPA 特有能力 | +| `Specification` | 用于构建动态查询条件的函数式接口 | +| `Pageable` / `PageRequest` | 分页参数抽象(页码、每页大小、排序) | +| `Page` | 分页结果封装(内容列表、总记录数、总页数) | + +### 6. 事务管理 + +Spring Data JPA 默认集成 Spring 声明式事务。Service 层方法添加 `@Transactional` 即可自动管理事务边界: + +```java +@Service +public class UserService { + + @Transactional + public User createUser(User user) { + return userRepository.save(user); + } +} +``` + +## 使用实例 + +### 基本 CRUD 操作 + +```java +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public User create(String username, String email) { + User user = new User(); + user.setUsername(username); + user.setEmail(email); + return userRepository.save(user); + } + + public Optional getById(Long id) { + return userRepository.findById(id); + } + + public void delete(Long id) { + userRepository.deleteById(id); + } +} +``` + +### 分页与排序查询 + +```java +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserRepository userRepository; + + @GetMapping + public Page list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String keyword) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending()); + + if (keyword != null && !keyword.isBlank()) { + return userRepository.findByEmailContaining(keyword, pageable); + } + return userRepository.findAll(pageable); + } +} +``` + +### Specification 动态查询 + +适用于多条件组合筛选场景,避免为每种组合定义单独的 Repository 方法: + +```java +public class UserSpecifications { + + public static Specification hasUsername(String username) { + return (root, query, cb) -> + username == null ? null : cb.equal(root.get("username"), username); + } + + public static Specification emailContains(String email) { + return (root, query, cb) -> + email == null ? null : cb.like(root.get("email"), "%" + email + "%"); + } +} + +// 在 Service 中组合使用 +@Service +@RequiredArgsConstructor +public class UserSearchService { + + private final UserRepository userRepository; + + public Page search(String username, String email, Pageable pageable) { + Specification spec = Specification + .where(UserSpecifications.hasUsername(username)) + .and(UserSpecifications.emailContains(email)); + return userRepository.findAll(spec, pageable); + } +} +``` + +### Example 查询 + +基于实体对象字段值自动匹配,适合简单等值条件查询: + +```java +User probe = new User(); +probe.setUsername("zhangsan"); + +ExampleMatcher matcher = ExampleMatcher.matching() + .withIgnoreCase() + .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING); + +List results = userRepository.findAll(Example.of(probe, matcher)); +``` diff --git a/docs/capabilities/springboot/ioc-container.md b/docs/capabilities/springboot/ioc-container.md new file mode 100644 index 000000000..9b6ba0257 --- /dev/null +++ b/docs/capabilities/springboot/ioc-container.md @@ -0,0 +1,218 @@ +--- +name: springboot/ioc-container +module: springboot +description: Spring IoC 容器,提供依赖注入、AOP、事件发布等核心功能 +status: 已实现 +scope: 后端 +source: 框架:springboot +import: "org.springframework.boot:spring-boot-starter" +framework_version: 3.3.5 +--- + +## 解决什么问题 + +在企业级 Java 应用开发中,对象之间的依赖关系往往错综复杂。如果由开发者手动创建和管理所有对象及其依赖,会导致代码高度耦合、难以测试、难以维护。Spring IoC(Inversion of Control)容器通过以下机制解决这些核心痛点: + +- **依赖注入(DI)**:将对象的创建和组装交由容器统一管理,业务代码无需关心依赖的来源与生命周期,从而实现组件间的解耦。 +- **声明式配置**:通过注解或 Java Config 描述 Bean 的定义与装配规则,替代冗长的工厂模式或手动 new 对象的方式。 +- **AOP 支持**:在不修改业务代码的前提下,以切面方式织入事务管理、日志记录、权限校验等横切关注点。 +- **事件驱动**:提供 `ApplicationEventPublisher` 发布-订阅机制,使模块间可以通过事件进行松耦合通信,这也是本项目 DDD 领域事件基础设施的底层支撑。 +- **生命周期管理**:容器负责 Bean 的初始化、回调(如 `@PostConstruct`)和销毁,简化资源管理逻辑。 + +典型复用场景包括:Service 层注入 Repository、Controller 注入 Service、通过 AOP 统一处理异常与日志、使用 `ApplicationListener` 监听启动完成事件等。 + +## 如何使用 + +### 引入依赖 + +在 Maven 项目的 `pom.xml` 中添加: + +```xml + + org.springframework.boot + spring-boot-starter + +``` + +该 starter 已包含 `spring-context`、`spring-aop`、`spring-beans` 等 IoC 核心模块。在本项目中,`springboot-starter` 及其他所有 starter 均已传递依赖此包,无需额外声明。 + +### Bean 定义与注入 + +| 方式 | 说明 | +|------|------| +| `@Component` / `@Service` / `@Repository` / `@Controller` | 类级别注解,标记为 Spring 管理的 Bean | +| `@Bean` | 在 `@Configuration` 类的方法上声明第三方或非注解类的 Bean | +| `@Autowired` | 按类型自动注入(推荐用于构造器注入) | +| `@Qualifier("beanName")` | 当同类型存在多个 Bean 时,指定具体名称 | +| `@Value("${key}")` | 注入配置文件中的属性值 | +| `@Scope("prototype")` | 改变 Bean 作用域,默认为 `singleton` | + +### AOP 使用 + +1. 添加 `spring-boot-starter-aop` 依赖(或使用 AspectJ)。 +2. 编写切面类,使用 `@Aspect` + `@Component` 标注。 +3. 通过 `@Around`、`@Before`、`@After` 等通知注解定义切入点与增强逻辑。 + +### 事件发布与监听 + +- 注入 `ApplicationEventPublisher`,调用 `publishEvent()` 发布自定义事件。 +- 使用 `@EventListener` 注解或实现 `ApplicationListener` 接口监听事件。 +- 配合 `@TransactionalEventListener` 可在事务提交后再触发处理逻辑。 + +### 配置方式 + +Spring Boot 优先使用基于注解的配置风格: + +- `@SpringBootApplication` 组合了 `@Configuration`、`@EnableAutoConfiguration`、`@ComponentScan`。 +- `@Import` 可导入额外的配置类。 +- `application.properties` / `application.yml` 提供外部化配置,通过 `@ConfigurationProperties` 绑定到 Bean。 + +## 使用实例 + +### 基本依赖注入 + +```java +@Service +public class UserService { + + private final UserRepository userRepository; + + // 构造器注入(推荐方式,Spring 4.3+ 单构造器可省略 @Autowired) + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User findById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + id)); + } +} + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + return Response.ok(userService.findById(id)); + } +} +``` + +### 通过 @Bean 注册第三方组件 + +```java +@Configuration +public class AppConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(10)) + .build(); + } +} +``` + +### AOP 切面示例 + +```java +@Aspect +@Component +@Slf4j +public class MethodExecutionLogAspect { + + @Around("@annotation(org.springframework.stereotype.Service)") + public Object logMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable { + String methodName = joinPoint.getSignature().toShortString(); + long start = System.currentTimeMillis(); + + try { + Object result = joinPoint.proceed(); + log.info("{} executed in {}ms", methodName, System.currentTimeMillis() - start); + return result; + } catch (Throwable ex) { + log.error("{} failed after {}ms", methodName, System.currentTimeMillis() - start, ex); + throw ex; + } + } +} +``` + +### 事件发布与监听 + +```java +// 定义事件 +public class UserCreatedEvent extends ApplicationEvent { + private final User user; + + public UserCreatedEvent(Object source, User user) { + super(source); + this.user = user; + } + + public User getUser() { + return user; + } +} + +// 发布事件 +@Service +public class UserService { + + private final ApplicationEventPublisher eventPublisher; + + public UserService(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @Transactional + public User createUser(CreateUserCommand command) { + User user = new User(command.getName(), command.getEmail()); + userRepository.save(user); + eventPublisher.publishEvent(new UserCreatedEvent(this, user)); + return user; + } +} + +// 监听事件 +@Component +@Slf4j +public class UserCreatedNotificationHandler { + + @EventListener + public void onUserCreated(UserCreatedEvent event) { + log.info("New user created: {}, sending welcome notification", event.getUser().getName()); + // 发送欢迎邮件或消息... + } +} +``` + +### 生命周期回调 + +```java +@Component +public class CacheWarmupService { + + @PostConstruct + public void warmupCache() { + // 容器初始化完成后预热缓存 + log.info("Warming up application cache..."); + } + + @PreDestroy + public void cleanup() { + // 容器关闭前释放资源 + log.info("Cleaning up resources before shutdown..."); + } +} +``` + +以上示例展示了 Spring IoC 容器在日常开发中最常用的能力。在本项目中,框架的事件系统(`IEvent`、`EventPusher`)、数据权限 SQL 拦截、工作流引擎等高级特性均构建于 IoC 容器之上,理解 IoC 是掌握整个框架的基础。 diff --git a/docs/capabilities/springboot/security.md b/docs/capabilities/springboot/security.md new file mode 100644 index 000000000..d30523bac --- /dev/null +++ b/docs/capabilities/springboot/security.md @@ -0,0 +1,231 @@ +--- +name: springboot/security +module: springboot +description: Spring Security,提供认证、授权和 CSRF 防护 +status: 已实现 +scope: 后端 +source: 框架:springboot +import: "org.springframework.boot:spring-boot-starter-security" +framework_version: 3.3.5 +--- + +## 解决什么问题 + +在企业级 Web 应用中,安全是最基础且最复杂的横切关注点。开发者通常需要面对以下痛点: + +- **认证机制繁琐**:手动实现 JWT / Redis Token 的签发、解析、续期和过期校验逻辑,代码重复且容易出错。 +- **授权规则分散**:URL 级别的访问控制散落在各个 Controller 或拦截器中,缺乏统一的安全策略配置入口。 +- **登录/登出流程耦合**:JSON API 场景下的登录成功/失败响应、Token 返回格式、登出清理等逻辑与业务代码高度耦合。 +- **安全防护遗漏**:CSRF、CORS、Frame Options 等浏览器安全头需要逐一配置,默认值往往不适合前后端分离架构。 +- **扩展困难**:当需要在认证前后插入自定义逻辑(如验证码校验、审计日志、多因子认证)时,缺少标准化的扩展点。 + +`springboot-starter-security` 模块在 Spring Security 6.x 基础上进行了二次封装,通过 `TokenGateway` 抽象令牌存储(JWT / Redis 双模式)、`SecurityLoginHandler` 开放登录生命周期钩子、`HttpSecurityCustomer` 允许自定义安全链配置,将上述问题收敛到统一的自动配置体系中,使业务项目只需声明少量配置即可获得生产级的安全基础设施。 + +## 如何使用 + +### 1. 引入依赖 + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +该 starter 会自动引入 `spring-boot-starter-security` 并注册 `AutoConfiguration`。 + +### 2. 核心配置项 + +在 `application.properties` 中通过 `codingapi.security.*` 前缀进行配置: + +```properties +# 需要认证的 URL 模式(逗号分隔),匹配后必须携带有效 Token +codingapi.security.authenticated-urls=/api/** + +# 免认证的 URL 模式(逗号分隔) +codingapi.security.ignore-urls=/open/**,/public/** + +# 登录接口地址(POST) +codingapi.security.login-processing-url=/user/login + +# 登出接口地址 +codingapi.security.logout-url=/user/logout + +# 禁用 CSRF(前后端分离场景通常为 true) +codingapi.security.disable-csrf=true + +# 禁用 CORS 内置处理(由框架自动配置 CORS 映射) +codingapi.security.disable-cors=true + +# 禁用 Basic Auth +codingapi.security.disable-basic-auth=true + +# 禁用 Frame Options +codingapi.security.disable-frame-options=true +``` + +### 3. 核心接口 + +| 接口 | 职责 | 扩展方式 | +|------|------|----------| +| `TokenGateway` | 令牌的创建(`create`)与解析(`parser`) | 实现该接口或使用内置的 `JWTTokenGatewayImpl` / `RedisTokenGatewayImpl` | +| `SecurityLoginHandler` | 登录前置校验(`preHandle`)与后置响应构建(`postHandle`) | 实现该接口并注册为 Bean,替换默认行为 | +| `AuthenticationTokenFilter` | Token 验证通过后的附加过滤逻辑 | 实现该接口并注册为 Bean | +| `HttpSecurityCustomer` | 自定义 `HttpSecurity` 配置链 | 实现 `customize(HttpSecurity)` 方法 | +| `UserDetailsService` | 用户加载 | 注册自定义 Bean 替换默认的内存用户 | +| `PasswordEncoder` | 密码编码 | 注册自定义 Bean 替换默认的 DelegatingPasswordEncoder | + +所有接口均标注 `@ConditionalOnMissingBean`,业务项目注册同名 Bean 即可无缝替换默认实现。 + +### 4. Token 模型 + +`Token` 对象封装了完整的令牌信息: + +- `username` — 用户名 +- `authorities` — 权限列表 +- `extra` — 业务扩展字段(JSON 字符串,可通过 `parseExtra(Class)` 反序列化) +- `expireTime` / `remindTime` — 过期时间与续期提醒时间 +- `canRestToken()` — 判断是否需要自动续期 +- `getAuthenticationToken()` — 转换为 Spring Security 的 `UsernamePasswordAuthenticationToken` + +### 5. 自动装配链路 + +`AutoConfiguration` 自动完成以下装配: + +1. 注册 `SecurityFilterChain`,配置 URL 授权规则、异常处理、登录/登出端点 +2. 通过 `HttpSecurityConfigurer` 注入 `MyLoginFilter`(登录)和 `MyAuthenticationFilter`(Token 鉴权) +3. 注册 `DaoAuthenticationProvider` 绑定 `UserDetailsService` 和 `PasswordEncoder` +4. 根据 `disableCors` 配置自动注册 CORS 映射 + +## 使用实例 + +### 示例 1:自定义 UserDetailsService 对接数据库 + +```java +@Service +public class DatabaseUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public DatabaseUserDetailsService(UserRepository userRepository, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserEntity entity = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); + + return User.withUsername(entity.getUsername()) + .password(entity.getPassword()) + .roles(entity.getRole()) + .build(); + } +} +``` + +注册此 Bean 后,框架自动替换默认的内存用户管理器。 + +### 示例 2:自定义登录处理器(增加验证码校验) + +```java +@Component +public class CaptchaSecurityLoginHandler implements SecurityLoginHandler { + + private final CaptchaService captchaService; + + public CaptchaSecurityLoginHandler(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + @Override + public void preHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest) throws Exception { + String captchaCode = loginRequest.getString("captcha"); + String captchaKey = loginRequest.getString("captchaKey"); + if (!captchaService.verify(captchaKey, captchaCode)) { + throw new AuthenticationServiceException("验证码错误"); + } + } + + @Override + public LoginResponse postHandle(HttpServletRequest request, HttpServletResponse response, + LoginRequest loginRequest, UserDetails user, Token token) { + LoginResponse loginResponse = new LoginResponse(); + loginResponse.setToken(token.getToken()); + loginResponse.setUsername(token.getUsername()); + loginResponse.setAuthorities(token.getAuthorities()); + // 可在此处追加额外的响应字段 + return loginResponse; + } +} +``` + +### 示例 3:Token 验证后注入上下文 + +```java +@Component +public class TenantAuthenticationTokenFilter implements AuthenticationTokenFilter { + + @Override + public void doFilter(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof Token token) { + // 从 Token extra 中提取租户 ID 并放入线程上下文 + TenantContext context = token.parseExtra(TenantContext.class); + if (context != null) { + TenantHolder.set(context.getTenantId()); + } + } + } +} +``` + +### 示例 4:自定义 HttpSecurity 配置 + +```java +@Component +public class CustomHttpSecurityCustomer implements HttpSecurityCustomer { + + @Override + public void customize(HttpSecurity security) throws Exception { + // 启用 OAuth2 Resource Server + security.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + // 添加自定义 Header + security.headers(headers -> headers + .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))); + } +} +``` + +### 示例 5:前端调用登录接口 + +```bash +curl -X POST http://localhost:8090/user/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' +``` + +成功响应: + +```json +{ + "success": true, + "data": { + "token": "eyJhbGciOiJIUzI1NiJ9...", + "username": "admin", + "authorities": ["ROLE_ADMIN"] + } +} +``` + +后续请求携带 Token: + +```bash +curl http://localhost:8090/api/users \ + -H "Authorization: eyJhbGciOiJIUzI1NiJ9..." +``` diff --git a/docs/conventions/index.md b/docs/conventions/index.md new file mode 100644 index 000000000..df2f11ffb --- /dev/null +++ b/docs/conventions/index.md @@ -0,0 +1,17 @@ +# 规范(Conventions) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +## ✅ 已实现 + +| 名称 | 模块 | 描述 | 范围 | 来源 | +|------|------|------|------|------| +| [springboot-starter/ddd-layered-architecture](./springboot-starter/ddd-layered-architecture.md) | springboot-starter | DDD 分层架构规范,定义 interface/app/domain/infra 四层的依赖规则和职责划分 | 后端 | 项目自有 | +| [springboot-starter/event-driven-convention](./springboot-starter/event-driven-convention.md) | springboot-starter | 事件驱动开发规范,定义 IEvent/IHandler 的使用约定和事件处理模式 | 后端 | 项目自有 | +| [springboot-starter/exception-handling-convention](./springboot-starter/exception-handling-convention.md) | springboot-starter | 全局异常处理规范,定义异常抛出和捕获的标准模式 | 后端 | 项目自有 | +| [springboot-starter/response-convention](./springboot-starter/response-convention.md) | springboot-starter | 统一响应格式规范,定义 Controller 层返回值的标准格式 | 后端 | 项目自有 | +| [springboot-starter-data-fast/dynamic-query-convention](./springboot-starter-data-fast/dynamic-query-convention.md) | springboot-starter-data-fast | 动态查询规范,定义 PageRequest + RequestFilter + FastRepository 的使用模式 | 后端 | 项目自有 | + +--- + +**统计**: 共 5 篇 — 已实现 5 / 计划中 0 / 已废弃 0 diff --git a/docs/conventions/springboot-starter-data-fast/dynamic-query-convention.md b/docs/conventions/springboot-starter-data-fast/dynamic-query-convention.md new file mode 100644 index 000000000..f4617ca49 --- /dev/null +++ b/docs/conventions/springboot-starter-data-fast/dynamic-query-convention.md @@ -0,0 +1,148 @@ +--- +name: springboot-starter-data-fast/dynamic-query-convention +module: springboot-starter-data-fast +description: 动态查询规范,定义 PageRequest + RequestFilter + FastRepository 的使用模式 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter-data-fast" +content_hash: 0369b4e3ba19d679dea69480cf4c5f6e4687c186c515e56883c004884711ab83 +--- + +## 解决什么问题 + +在企业级应用中,列表查询往往需要根据前端传入的动态条件进行过滤。如果不遵守本规范,会导致以下问题: + +1. **SQL 注入风险**:在 Service 层手动拼接 SQL 字符串实现动态查询,极易引入 SQL 注入漏洞。 +2. **代码重复与维护困难**:每个实体的动态查询都手写 `if-else` 判断和原生 SQL,导致大量重复代码,新增过滤字段需修改多处。 +3. **分页逻辑不一致**:各模块自行处理分页参数(偏移量计算、排序),缺乏统一标准,容易出现边界错误。 +4. **CQRS 职责混乱**:查询逻辑与命令逻辑耦合在同一 Service 中,违反读写分离原则,增加系统复杂度。 +5. **类型安全缺失**:使用 Map 或 JSON 传递查询条件时,缺乏编译期检查,运行时才发现字段名拼写错误或类型不匹配。 +6. **框架能力浪费**:不使用 `FastRepository` 提供的自动 Example/HQL 构建能力,绕过框架自建查询机制,失去统一的 SQL 拦截(如数据权限)支持。 + +## 如何使用 + +### 核心规则 + +1. **列表查询统一使用 `PageRequest` 构建分页参数** + - 通过 `PageRequest.of(page, size)` 或 `new PageRequest()` 创建请求对象。 + - 禁止直接使用 Spring Data 原生 `org.springframework.data.domain.PageRequest` 来承载业务过滤条件。 + +2. **动态过滤条件通过 `PageRequest.addFilter()` 方法添加** + - 简单等值过滤:`pageRequest.addFilter("name", "张三")`,默认使用 `Relation.EQ`。 + - 指定关系过滤:`pageRequest.addFilter("age", Relation.GT, 18)`。 + - 组合过滤:使用 `andFilter(Filter...)` 和 `orFilters(Filter...)` 构建复杂条件。 + +3. **过滤关系使用 `Relation` 枚举** + - 可用关系包括:`EQ`、`GT`、`LT`、`GTE`、`LTE`、`LIKE`、`IN` 等。 + - 所有过滤关系必须通过枚举表达,禁止硬编码字符串比较运算符。 + +4. **Repository 接口需继承 `FastRepository`** + - 实体 Repository 必须扩展 `FastRepository` 以获得动态查询能力。 + - `FastRepository` 同时继承了 `JpaRepository`、`JpaSpecificationExecutor`、`DynamicRepository` 和 `DynamicNativeRepository`。 + +5. **调用 `FastRepository.findAll(PageRequest)` 执行动态查询** + - 当 `PageRequest` 包含 Filter 时,自动通过 `ExampleBuilder` 构建 Example 查询。 + - 需要 HQL 级别查询时,使用 `pageRequest(PageRequest)` 方法,自动通过 `DynamicSQLBuilder` 生成 HQL。 + - 禁止绕过 `FastRepository` 提供的默认方法自行编写动态查询逻辑。 + +6. **禁止在 Service 层手写原生 SQL 实现动态查询** + - 不允许使用 `@Query(nativeQuery = true)` 或 `EntityManager.createNativeQuery()` 拼接动态条件。 + - 所有动态过滤必须委托给 `FastRepository` 的 Filter 机制完成。 + +7. **查询服务(CQRS Query 侧)应独立于命令服务** + - 查询服务放在 `*-app-query` 模块中,仅负责读取操作。 + - 命令服务放在 `*-app-cmd-*` 模块中,负责写入与领域编排。 + - 两者不得互相依赖或合并为同一个类。 + +### 命名约定 + +- 查询服务类命名:`{Entity}QueryService`,位于 `*.query` 包下。 +- 命令服务类命名:`{Entity}CmdService`,位于 `*.cmd` 包下。 +- Controller 中的列表查询方法参数类型统一为 `PageRequest`。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. Repository 继承 FastRepository +public interface UserRepository extends FastRepository { +} + +// 2. 查询服务独立于命令服务 +@Service +public class UserQueryService { + + @Autowired + private UserRepository userRepository; + + public Page listUsers(String name, Integer minAge, int page, int size) { + PageRequest request = PageRequest.of(page, size); + + // 动态添加过滤条件 + if (StringUtils.hasText(name)) { + request.addFilter("name", Relation.LIKE, name); + } + if (minAge != null) { + request.addFilter("age", Relation.GTE, minAge); + } + + // 委托 FastRepository 自动构建查询 + return userRepository.findAll(request); + } +} + +// 3. Controller 接收 PageRequest +@GetMapping("/users") +public MultiResponse list( + @RequestParam(required = false) String name, + @RequestParam(required = false) Integer minAge, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = userQueryService.listUsers(name, minAge, page, size); + return ResponseUtils.toMultiResponse(result); +} +``` + +### ❌ 错误示例 + +```java +// 错误 1: Repository 未继承 FastRepository,丧失动态查询能力 +public interface UserRepository extends JpaRepository { +} + +// 错误 2: Service 中手写原生 SQL 拼接动态条件 +@Service +public class UserService { + + @PersistenceContext + private EntityManager entityManager; + + public List listUsers(String name, Integer minAge) { + StringBuilder sql = new StringBuilder("SELECT * FROM users WHERE 1=1"); + if (name != null) { + sql.append(" AND name LIKE '%").append(name).append("%'"); // SQL 注入风险! + } + if (minAge != null) { + sql.append(" AND age >= ").append(minAge); + } + return entityManager.createNativeQuery(sql.toString(), User.class).getResultList(); + } +} + +// 错误 3: 查询与命令逻辑混合在同一个 Service 中 +@Service +public class UserService { + public Page list(...) { /* 查询逻辑 */ } + public void createUser(...) { /* 命令逻辑 */ } + public void deleteUser(...) { /* 命令逻辑 */ } +} + +// 错误 4: 使用 Spring Data 原生 PageRequest,无法承载 Filter +@GetMapping("/users") +public Page list(org.springframework.data.domain.PageRequest pageable) { + // 丢失了框架的动态过滤能力 + return userRepository.findAll(pageable); +} +``` diff --git a/docs/conventions/springboot-starter/ddd-layered-architecture.md b/docs/conventions/springboot-starter/ddd-layered-architecture.md new file mode 100644 index 000000000..c1a97a8cd --- /dev/null +++ b/docs/conventions/springboot-starter/ddd-layered-architecture.md @@ -0,0 +1,224 @@ +--- +name: springboot-starter/ddd-layered-architecture +module: springboot-starter +description: DDD 分层架构规范,定义 interface/app/domain/infra 四层的依赖规则和职责划分 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +--- + +## 解决什么问题 + +不遵守 DDD 分层架构规范会导致以下问题: + +1. **领域逻辑污染**:domain 层直接引用 infra 层实现类(如 JPA Repository、Redis Client),导致业务规则与基础设施耦合,无法独立演进和测试。 +2. **依赖方向混乱**:若 domain 反向依赖 app 或 interface,修改一个 API 接口可能牵连整个领域模型重构,变更成本指数级增长。 +3. **CQRS 读写混淆**:查询服务与命令服务混杂在同一模块中,复杂查询被迫走聚合根加载全量数据,性能瓶颈难以定位和优化。 +4. **接口层职责膨胀**:Controller 中直接编排业务流程、拼装 DTO、处理事件,导致接口层成为"上帝类",复用性和可维护性急剧下降。 +5. **Repository 契约缺失**:domain 层直接使用具体持久化实现而非接口,切换存储方案(如从 MySQL 迁移到 MongoDB)需要改动所有调用方代码。 + +## 如何使用 + +### 四层架构与依赖方向 + +``` +interface → app → domain ← infra +``` + +| 层级 | 模块命名约定 | 核心职责 | 允许依赖 | +|------|-------------|---------|---------| +| **interface**(接口层) | `*-interface` | Controller(REST API)、Handler(事件处理器)、Runner(启动任务) | app | +| **app**(应用层) | `*-app-query` / `*-app-cmd-*` | query:CQRS 查询服务;cmd:命令服务与领域编排 | domain | +| **domain**(领域层) | `*-domain-*` | Entity、ValueObject、DomainService、Repository 接口、DomainEvent | 无外部框架 | +| **infra**(基础设施层) | `*-infra-*` | Repository 实现、Gateway 实现、外部服务适配、持久化配置 | domain | + +### 核心规则 + +1. **依赖方向严格单向**:`interface → app → domain ← infra`。禁止反向依赖和跨层依赖。 +2. **domain 层纯业务**:domain 层只包含领域模型和业务逻辑,不依赖 Spring、JPA、Redis 等任何外部框架。 +3. **Repository 接口在 domain,实现在 infra**:domain 层定义 Repository 接口,infra 层提供具体实现并通过 Spring Bean 注入。 +4. **app 层按 CQRS 拆分**:查询服务(query)与命令服务(cmd)分模块,query 侧可直接读取视图/DTO,cmd 侧通过聚合根执行业务操作。 +5. **interface 层仅做适配**:Controller 只做参数校验、权限检查和响应封装,业务编排委托给 app 层;Handler 只负责事件监听与转发。 +6. **禁止 domain 引用 infra 实现类**:domain 层不得 import infra 包下的任何类,包括具体的 Repository 实现、DAO、Client 等。 + +### 包结构约定 + +``` +{bounded-context}/ + {context}-interface/ # 接口层 + controller/ + handler/ + runner/ + {context}-app/ # 应用层 + {context}-app-query/ # CQRS Query 侧 + {context}-app-cmd-domain/ # CQRS Command 侧(领域编排) + {context}-domain/ # 领域层 + model/ # Entity, ValueObject + repository/ # Repository 接口 + service/ # DomainService + event/ # DomainEvent + {context}-infra/ # 基础设施层 + jpa/ # Repository 实现 + gateway/ # 外部服务适配 +``` + +## 使用实例 + +### ✅ 正确示例 + +**domain 层定义 Repository 接口:** + +```java +// example-domain-user/src/.../repository/UserRepository.java +package com.example.domain.user.repository; + +public interface UserRepository { + User findById(Long id); + void save(User user); +} +``` + +**infra 层提供 Repository 实现:** + +```java +// example-infra-jpa/src/.../jpa/UserRepositoryImpl.java +package com.example.infra.jpa; + +import com.example.domain.user.repository.UserRepository; +import org.springframework.stereotype.Repository; + +@Repository +public class UserRepositoryImpl implements UserRepository { + private final UserJpaDao userJpaDao; + + @Override + public User findById(Long id) { + return userJpaDao.findById(id).map(UserConverter::toDomain).orElse(null); + } + + @Override + public void save(User user) { + userJpaDao.save(UserConverter.toEntity(user)); + } +} +``` + +**app 层按 CQRS 拆分:** + +```java +// example-app-query: 查询服务,直接返回 DTO +@Service +public class UserQueryService { + private final UserReadDao userReadDao; // 可直接读视图 + + public SingleResponse getUser(Long id) { + return SingleResponse.of(userReadDao.findUserDTO(id)); + } +} + +// example-app-cmd-domain: 命令服务,通过聚合根操作 +@Service +public class UserCommandService { + private final UserRepository userRepository; // domain 层接口 + + @Transactional + public Response createUser(CreateUserCmd cmd) { + User user = User.create(cmd.getName(), cmd.getEmail()); + userRepository.save(user); + return Response.success(); + } +} +``` + +**interface 层仅做适配:** + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + private final UserQueryService queryService; + private final UserCommandService commandService; + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + return queryService.getUser(id); // 委托 app 层 + } + + @PostMapping + public Response createUser(@Valid @RequestBody CreateUserCmd cmd) { + return commandService.createUser(cmd); // 委托 app 层 + } +} +``` + +### ❌ 错误示例 + +**domain 层直接引用 infra 实现类:** + +```java +// ❌ domain 层 import 了 infra 包的类 +package com.example.domain.user.service; + +import com.example.infra.jpa.UserJpaDao; // 违规!domain 不应依赖 infra + +@Service +public class UserService { + private final UserJpaDao userJpaDao; // 应使用 UserRepository 接口 + + public User findUser(Long id) { + return userJpaDao.findById(id).orElse(null); + } +} +``` + +**依赖方向反转:** + +```java +// ❌ domain 层反向依赖 app 层 +package com.example.domain.user.model; + +import com.example.app.cmd.domain.UserService; // 违规!domain 不应依赖 app + +public class User { + public void activate() { + UserService userService = ...; // 领域对象不应调用应用服务 + userService.sendActivationEmail(this); + } +} +``` + +**app 层未拆分 CQRS,查询走聚合根:** + +```java +// ❌ 查询和命令混在一起,列表查询加载完整聚合根 +@Service +public class UserService { + private final UserRepository userRepository; + + // 列表查询不应加载完整 User 聚合根 + public List listUsers(String keyword) { + return userRepository.findAll().stream() + .filter(u -> u.getName().contains(keyword)) + .collect(Collectors.toList()); // 内存过滤,性能灾难 + } +} +``` + +**Controller 中直接编排业务逻辑:** + +```java +// ❌ Controller 承担了本应属于 app 层的职责 +@PostMapping("/orders") +public Response createOrder(@RequestBody OrderCmd cmd) { + User user = userRepository.findById(cmd.getUserId()); // 应在 app 层 + Product product = productRepository.findById(cmd.getProductId()); // 应在 app 层 + if (product.getStock() < cmd.getQuantity()) { // 业务校验应在 domain + return Response.error("库存不足"); + } + Order order = new Order(user, product, cmd.getQuantity()); // 领域创建应在 app/cmd + orderRepository.save(order); // 应在 app 层 + eventPusher.push(new OrderCreatedEvent(order)); // 事件推送应在 app 层 + return Response.success(order.getId()); +} +``` diff --git a/docs/conventions/springboot-starter/event-driven-convention.md b/docs/conventions/springboot-starter/event-driven-convention.md new file mode 100644 index 000000000..3cfb076bc --- /dev/null +++ b/docs/conventions/springboot-starter/event-driven-convention.md @@ -0,0 +1,217 @@ +--- +name: springboot-starter/event-driven-convention +module: springboot-starter +description: 事件驱动开发规范,定义 IEvent/IHandler 的使用约定和事件处理模式 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +content_hash: 02975087ebd599ed5978c834297c16c37ae4e31ec37d964d753353b1de9fb07d +--- + +## 解决什么问题 + +不遵守事件驱动开发规范会导致以下问题: + +1. **事务耦合风险**:将事件处理与主业务事务强绑定,导致分支业务的失败回滚整个主事务,破坏领域事件的独立性原则。当分支逻辑(如通知、日志、缓存更新)出错时,不应影响核心业务流程的提交。 + +2. **事件类型混乱**:不区分同步事件与异步事件,导致关键业务事件被异步执行产生时序问题,或非关键事件被同步阻塞影响主流程性能。 + +3. **Handler 注册失效**:未通过 `@Service` 注解将 Handler 注册为 Spring Bean,导致框架无法自动发现处理器,事件推送后无响应且难以排查。 + +4. **执行顺序不可控**:多个 Handler 订阅同一事件时未指定 `order()`,导致处理顺序依赖 Bean 加载顺序,在不同环境或重启后行为不一致。 + +5. **异常处理缺失**:未实现 `error()` 回调或直接在 `handler()` 中抛出未捕获异常,导致后续 Handler 被跳过且错误信息丢失,事件链中断而无法感知。 + +6. **绕过 EventPusher 直接调用**:手动实例化 Handler 或直接调用处理方法,绕过框架的循环检测、线程池调度和上下文传递机制,引发循环事件死锁或丢失链路追踪信息。 + +## 如何使用 + +### 规则 1:事件类必须实现 IEvent 接口 + +所有事件类必须实现 `IEvent` 接口。根据执行方式选择子接口: + +- **同步事件**:实现 `ISyncEvent`,在当前线程内按顺序执行所有 Handler +- **异步事件**:实现 `IAsyncEvent`,由框架线程池异步调度执行 + +事件类应为纯数据载体,不包含业务逻辑,并实现 `Serializable` 以支持序列化传输。 + +### 规则 2:事件处理器必须实现 IHandler 接口 + +Handler 通过泛型参数声明订阅的事件类型。框架在启动时通过 `HandlerBeanDefinitionRegistrar` 自动扫描所有 `IHandler` 实现并注册到 `ApplicationHandlerUtils`。 + +```java +public interface IHandler { + default int order() { return 0; } + void handler(T event); + default void error(Exception exception) throws Exception { throw exception; } +} +``` + +### 规则 3:事件处理器通过 @Service 注解注册为 Spring Bean + +Handler 必须标注 `@Service`(或 `@Component`)注解,确保被 Spring 容器管理。未注册为 Bean 的 Handler 不会被框架发现和调用。 + +### 规则 4:事件推送统一使用 EventPusher.push() 静态方法 + +所有事件推送必须通过 `EventPusher.push(event)` 发起,禁止手动调用 Handler。框架内置循环事件检测机制,当检测到事件循环推送时自动抛出异常。若确认需要允许循环事件,可使用 `EventPusher.push(event, true)` 关闭检测。 + +### 规则 5:事件不应与主业务强耦合事务绑定 + +事件处理的核心理念是**解耦**。事件对于主业务来说可成功可失败,成功与失败都不应强关联主体业务。若分支逻辑必须与主事务保持一致,应直接使用服务调用而非事件机制。 + +### 规则 6:多个 Handler 通过 order() 方法控制执行顺序 + +当多个 Handler 订阅同一事件时,通过重写 `order()` 方法指定执行优先级。数值越小越先执行,默认值为 0。相同 order 值的 Handler 执行顺序不确定。 + +### 规则 7:Handler 的 error() 回调处理异常 + +`error()` 方法接收 Handler 执行过程中抛出的异常。默认实现会重新抛出异常,这将**阻止后续 Handler 的执行**。若希望某个 Handler 的失败不影响其他 Handler,应在 `error()` 中记录日志而不重新抛出。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. 定义同步事件 +public class UserCreatedEvent implements ISyncEvent { + private final String userId; + private final String username; + + public UserCreatedEvent(String userId, String username) { + this.userId = userId; + this.username = username; + } + + public String getUserId() { return userId; } + public String getUsername() { return username; } +} + +// 2. 定义异步事件 +public class UserNotificationEvent implements IAsyncEvent { + private final String userId; + private final String message; + + public UserNotificationEvent(String userId, String message) { + this.userId = userId; + this.message = message; + } + + public String getUserId() { return userId; } + public String getMessage() { return message; } +} + +// 3. 注册 Handler 并指定执行顺序 +@Service +public class UserCacheRefreshHandler implements IHandler { + + @Override + public int order() { + return 1; // 优先刷新缓存 + } + + @Override + public void handler(UserCreatedEvent event) { + cacheService.refreshUserCache(event.getUserId()); + } + + @Override + public void error(Exception exception) { + // 缓存刷新失败不影响后续 Handler,仅记录日志 + log.warn("缓存刷新失败: {}", exception.getMessage()); + } +} + +@Service +public class UserAuditLogHandler implements IHandler { + + @Override + public int order() { + return 2; // 缓存之后写审计日志 + } + + @Override + public void handler(UserCreatedEvent event) { + auditService.logUserCreation(event.getUserId(), event.getUsername()); + } +} + +// 4. 在业务中推送事件 +@Service +public class UserService { + + public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + // 通过 EventPusher 推送,不直接调用 Handler + EventPusher.push(new UserCreatedEvent(user.getId(), user.getUsername())); + return user; + } +} +``` + +### ❌ 错误示例 + +```java +// 错误 1:事件未实现 IEvent 接口 +public class UserCreatedEvent { // ❌ 缺少 ISyncEvent / IAsyncEvent + private String userId; +} + +// 错误 2:Handler 未注册为 Spring Bean +public class UserCacheHandler implements IHandler { // ❌ 缺少 @Service + @Override + public void handler(UserCreatedEvent event) { + cacheService.refreshUserCache(event.getUserId()); + } +} + +// 错误 3:在事务中强绑定事件结果 +@Transactional +public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + try { + EventPusher.push(new UserCreatedEvent(user.getId(), user.getUsername())); + } catch (Exception e) { + throw new RuntimeException("事件处理失败,回滚事务", e); // ❌ 事件失败不应回滚主事务 + } + return user; +} + +// 错误 4:手动调用 Handler 绕过框架 +@Service +public class UserService { + @Autowired + private UserCacheRefreshHandler cacheHandler; + + public User createUser(CreateUserCommand command) { + User user = userRepository.save(command.toEntity()); + cacheHandler.handler(new UserCreatedEvent(user.getId(), user.getUsername())); // ❌ 绕过 EventPusher + return user; + } +} + +// 错误 5:未处理异常导致事件链中断 +@Service +public class FragileHandler implements IHandler { + @Override + public void handler(UserCreatedEvent event) { + externalService.notify(event.getUserId()); // 可能抛出异常 + } + // ❌ 未重写 error(),默认重新抛出异常,阻止后续 Handler 执行 +} + +// 错误 6:多个 Handler 未指定 order,执行顺序不可预测 +@Service +public class HandlerA implements IHandler { + @Override + public void handler(UserCreatedEvent event) { /* ... */ } + // ❌ 未重写 order(),默认 0,与 HandlerB 顺序不确定 +} + +@Service +public class HandlerB implements IHandler { + @Override + public void handler(UserCreatedEvent event) { /* ... */ } + // ❌ 未重写 order(),默认 0,与 HandlerA 顺序不确定 +} +``` diff --git a/docs/conventions/springboot-starter/exception-handling-convention.md b/docs/conventions/springboot-starter/exception-handling-convention.md new file mode 100644 index 000000000..6f6146175 --- /dev/null +++ b/docs/conventions/springboot-starter/exception-handling-convention.md @@ -0,0 +1,152 @@ +--- +name: springboot-starter/exception-handling-convention +module: springboot-starter +description: 全局异常处理规范,定义异常抛出和捕获的标准模式 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +content_hash: 669e54861ccb844ae926652bdd6b30a9a1b8bbc9f9a55ce145d19c283d499596 +--- + +## 解决什么问题 + +不遵守此规范会导致以下问题: + +1. **错误响应格式不一致**:不同开发者在 Controller 中自行 try-catch 并返回自定义格式的错误信息,导致前端无法统一解析 `errCode` / `errMessage` 字段,增加联调和维护成本。 +2. **缺失国际化支持**:直接抛出 `RuntimeException` 或硬编码中文消息,无法根据请求 Locale 切换语言,多语言场景下用户体验差且需要大量改造。 +3. **异常被吞没**:Controller 层使用 try-catch 捕获异常后仅打印日志或返回 null,调用方无法感知业务失败原因,排查线上问题时缺少关键上下文。 +4. **错误码体系混乱**:各模块自行定义错误码常量或直接拼接字符串,缺乏统一的 key → message 映射机制,同一个业务含义可能出现多个不同的 errCode。 +5. **全局拦截失效**:绕过框架的 `HandlerExceptionResolver` 机制手动处理异常,导致日志记录、监控埋点等横切关注点被跳过。 + +## 如何使用 + +### 规则 1:业务异常统一使用 LocaleMessageException + +所有业务校验失败、参数非法、权限不足等场景,必须抛出 `LocaleMessageException`,禁止直接使用 `RuntimeException`、`IllegalArgumentException` 等原生异常。 + +```java +// 构造方式一:errCode + 默认消息(推荐用于简单场景) +throw new LocaleMessageException("user.not.found", "用户不存在"); + +// 构造方式二:仅 errCode,消息从 message.properties 自动解析 +throw new LocaleMessageException("user.not.found"); + +// 构造方式三:带占位符参数,对应 properties 中 user.duplicate=用户名 {0} 已存在 +throw LocaleMessageException.of("user.duplicate", username); +``` + +### 规则 2:异常消息采用 message key + 默认消息格式 + +- 第一个参数为 **errCode**(即 i18n message key),用于前端匹配和国际化查找。 +- 第二个参数为 **默认消息**(defaultMessage),当 message.properties 中未配置该 key 时作为兜底展示。 +- errCode 命名采用 **点分隔小写** 格式,如 `order.status.invalid`、`auth.token.expired`。 + +### 规则 3:依赖 ExceptionConfiguration 全局拦截 + +框架通过 `ExceptionConfiguration` 注册 `LocaleMessage` Bean,并由 `BasicHandlerExceptionResolverConfiguration` 中的 `ServletExceptionHandler` 统一拦截所有 Controller 层异常: + +- `LocaleMessageException` → 提取 `errCode` 和 `errMessage`,返回标准 Response 格式。 +- 其他未知异常 → 使用默认 errCode `system.err`,返回异常原始消息。 + +**无需在任何 Controller 中添加额外的 @ExceptionHandler。** + +### 规则 4:异常响应统一返回 Response 格式 + +全局拦截器返回的 JSON 结构如下: + +```json +{ + "success": false, + "errCode": "user.not.found", + "errMessage": "用户不存在" +} +``` + +前端只需判断 `success === false` 即可进入统一错误处理流程。 + +### 规则 5:禁止在 Controller 中使用 try-catch 吞掉异常 + +Controller 方法应保持简洁,让异常自然向上抛出由全局处理器接管。如需对特定异常做差异化处理(如参数校验),应在 Service 层完成转换后再抛出 `LocaleMessageException`。 + +### 规则 6:禁止直接抛出 RuntimeException + +`RuntimeException` 没有 `errCode` 字段,全局拦截器只能将其归入 `system.err`,丢失了业务语义。所有可预见的业务异常都必须使用 `LocaleMessageException`。 + +## 使用实例 + +### ✅ 正确示例 + +```java +@Service +public class UserService { + + public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new LocaleMessageException("user.not.found", "用户不存在")); + } + + public void createUser(String username) { + if (userRepository.existsByUsername(username)) { + // 使用静态工厂方法 + 占位符参数 + throw LocaleMessageException.of("user.duplicate", username); + } + // ... + } +} + +@RestController +@RequestMapping("/users") +public class UserController { + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + // 不需要 try-catch,异常由全局处理器接管 + return SingleResponse.of(userService.getUser(id)); + } +} +``` + +对应的 `messages_zh_CN.properties`: + +```properties +user.not.found=用户不存在 +user.duplicate=用户名 {0} 已存在 +``` + +### ❌ 错误示例 + +```java +// 错误 1:直接抛出 RuntimeException,缺少 errCode +public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new RuntimeException("用户不存在")); +} + +// 错误 2:Controller 中 try-catch 吞掉异常 +@GetMapping("/{id}") +public SingleResponse getUser(@PathVariable Long id) { + try { + return SingleResponse.of(userService.getUser(id)); + } catch (Exception e) { + log.error("查询失败", e); + return null; // 前端收到 null,无法区分成功与失败 + } +} + +// 错误 3:Controller 中自行构建错误响应,绕过全局拦截 +@GetMapping("/{id}") +public ResponseEntity getUser(@PathVariable Long id) { + try { + return ResponseEntity.ok(SingleResponse.of(userService.getUser(id))); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("code", 500); + error.put("msg", e.getMessage()); + return ResponseEntity.status(500).body(error); + } +} + +// 错误 4:硬编码中文消息,不支持国际化 +throw new LocaleMessageException("err001", "这个用户找不到啊"); +``` diff --git a/docs/conventions/springboot-starter/response-convention.md b/docs/conventions/springboot-starter/response-convention.md new file mode 100644 index 000000000..1c5c30712 --- /dev/null +++ b/docs/conventions/springboot-starter/response-convention.md @@ -0,0 +1,156 @@ +--- +name: springboot-starter/response-convention +module: springboot-starter +description: 统一响应格式规范,定义 Controller 层返回值的标准格式 +status: 已实现 +scope: 后端 +source: 项目自有 +import: "com.codingapi.springboot:springboot-starter" +content_hash: f34034d99b0e32ca0ef9f72cf98793135fd15e335facfaca548ab3caa9e14cc1 +--- + +## 解决什么问题 + +在 REST API 开发中,如果不对响应格式做统一约束,会导致以下问题: + +- **前端解析困难**:不同接口返回结构不一致(有的直接返回对象、有的返回 Map、有的包裹在自定义结构中),前端需要为每个接口单独适配,增加维护成本。 +- **错误处理碎片化**:没有统一的 `success` / `errCode` / `errMessage` 字段,前端无法用一套逻辑判断请求是否成功、展示错误提示。 +- **分页结构不统一**:列表接口各自定义分页字段名(`total` / `count` / `records` / `items`),前端分页组件难以复用。 +- **API 契约不稳定**:随意返回裸 Map 或临时 DTO,字段增减无感知,容易引发线上联调故障。 +- **国际化与监控缺失基础**:缺少标准化的错误码字段,后续接入国际化、链路追踪、告警分类时改造成本极高。 + +本规范通过强制使用框架提供的 Response 体系,确保所有 API 输出结构一致、可预测、可机器解析。 + +## 如何使用 + +### 核心规则 + +1. **Controller 方法返回值必须使用 Response 体系**:只能返回 `Response`、`SingleResponse`、`MultiResponse` 或 `MapResponse` 之一,禁止返回其他类型。 +2. **单个对象返回**:使用 `SingleResponse.of(data)`。 +3. **列表返回**:使用 `MultiResponse.of(list)` 或 `MultiResponse.of(page)`(接受 Spring Data `Page` 对象)。 +4. **无数据操作成功返回**:使用 `Response.buildSuccess()`。 +5. **失败返回**:使用 `Response.buildFailure(errCode, errMessage)`。 +6. **禁止直接返回 Map 或自定义 DTO 作为 API 响应**。 +7. **响应 JSON 结构固定包含**:`success`(boolean)、`errCode`(string)、`errMessage`(string)、`data`(业务数据,仅 SingleResponse/MultiResponse 携带)。 + +### 补充说明 + +- `SingleResponse.empty()` 用于查询可能为空但语义上成功的场景,返回 `{ success: true, data: null }`。 +- `MultiResponse.of(collection, total)` 用于手动分页场景;`MultiResponse.of(page)` 自动从 Spring Data Page 提取 total。 +- `MultiResponse.empty()` 返回空列表 `{ success: true, data: { total: 0, list: [] } }`。 +- 异常处理应通过全局异常处理器统一转换为 `Response.buildFailure(...)`,Controller 内不要 try-catch 后自行拼装错误响应。 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. 单对象查询 +@GetMapping("/users/{id}") +public SingleResponse getUser(@PathVariable Long id) { + UserDTO user = userService.findById(id); + return SingleResponse.of(user); +} + +// 2. 分页列表查询 +@GetMapping("/users") +public MultiResponse listUsers(PageRequest pageRequest) { + Page page = userService.findAll(pageRequest); + return MultiResponse.of(page); +} + +// 3. 非分页列表查询 +@GetMapping("/roles") +public MultiResponse listRoles() { + List roles = roleService.findAll(); + return MultiResponse.of(roles); +} + +// 4. 写操作成功(无返回数据) +@PostMapping("/users") +public Response createUser(@RequestBody CreateUserCmd cmd) { + userService.create(cmd); + return Response.buildSuccess(); +} + +// 5. 业务校验失败 +@PostMapping("/orders") +public Response createOrder(@RequestBody CreateOrderCmd cmd) { + if (cmd.getQuantity() <= 0) { + return Response.buildFailure("INVALID_QUANTITY", "订单数量必须大于0"); + } + orderService.create(cmd); + return Response.buildSuccess(); +} +``` + +正确返回的 JSON 结构: + +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { "id": 1, "name": "张三" } +} +``` + +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": { + "total": 100, + "list": [{ "id": 1, "name": "张三" }] + } +} +``` + +### ❌ 错误示例 + +```java +// 错误1:直接返回实体对象 +@GetMapping("/users/{id}") +public UserDTO getUser(@PathVariable Long id) { + return userService.findById(id); +} + +// 错误2:返回裸 Map +@GetMapping("/users/{id}") +public Map getUser(@PathVariable Long id) { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("data", userService.findById(id)); + return result; +} + +// 错误3:自定义响应结构 +@GetMapping("/users/{id}") +public Result getUser(@PathVariable Long id) { + return new Result<>(userService.findById(id)); +} + +// 错误4:Controller 内 try-catch 自行拼装错误 +@PostMapping("/users") +public Response createUser(@RequestBody CreateUserCmd cmd) { + try { + userService.create(cmd); + return Response.buildSuccess(); + } catch (Exception e) { + // 不应在 Controller 中捕获并手动构建错误响应 + Response resp = new Response(); + resp.setSuccess(false); + resp.setErrMessage(e.getMessage()); + return resp; + } +} + +// 错误5:列表接口不使用 MultiResponse +@GetMapping("/users") +public List listUsers() { + return userService.findAll(); +} +``` + +以上错误写法会导致前端无法统一解析响应、错误处理逻辑分散、API 契约不可靠等问题。 diff --git a/example/example-app/example-app-cmd-domain/pom.xml b/example/example-app/example-app-cmd-domain/pom.xml index fb2339898..6fd7afd26 100644 --- a/example/example-app/example-app-cmd-domain/pom.xml +++ b/example/example-app/example-app-cmd-domain/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/example-app-cmd-meta/pom.xml b/example/example-app/example-app-cmd-meta/pom.xml index 9ab44d9a3..dc6b15fc1 100644 --- a/example/example-app/example-app-cmd-meta/pom.xml +++ b/example/example-app/example-app-cmd-meta/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/example-app-query/pom.xml b/example/example-app/example-app-query/pom.xml index f8f02e4db..982facb8b 100644 --- a/example/example-app/example-app-query/pom.xml +++ b/example/example-app/example-app-query/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/pom.xml b/example/example-app/pom.xml index 95f11dcee..111b8a503 100644 --- a/example/example-app/pom.xml +++ b/example/example-app/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml pom diff --git a/example/example-domain/example-domain-leave/pom.xml b/example/example-domain/example-domain-leave/pom.xml index f5c1734e9..6717974d2 100644 --- a/example/example-domain/example-domain-leave/pom.xml +++ b/example/example-domain/example-domain-leave/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-domain - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-domain/example-domain-user/pom.xml b/example/example-domain/example-domain-user/pom.xml index 2477be54c..cd68cdab2 100644 --- a/example/example-domain/example-domain-user/pom.xml +++ b/example/example-domain/example-domain-user/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-domain - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-domain/pom.xml b/example/example-domain/pom.xml index c47712d36..085db923f 100644 --- a/example/example-domain/pom.xml +++ b/example/example-domain/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml 4.0.0 diff --git a/example/example-infra/example-infra-flow/pom.xml b/example/example-infra/example-infra-flow/pom.xml index da43affa7..6ca6db430 100644 --- a/example/example-infra/example-infra-flow/pom.xml +++ b/example/example-infra/example-infra-flow/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/example-infra-jpa/pom.xml b/example/example-infra/example-infra-jpa/pom.xml index f5f1c4e2e..3b0fe0c48 100644 --- a/example/example-infra/example-infra-jpa/pom.xml +++ b/example/example-infra/example-infra-jpa/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/example-infra-security/pom.xml b/example/example-infra/example-infra-security/pom.xml index d0fdab139..6d13113e1 100644 --- a/example/example-infra/example-infra-security/pom.xml +++ b/example/example-infra/example-infra-security/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/pom.xml b/example/example-infra/pom.xml index 637e53caf..138f14a8f 100644 --- a/example/example-infra/pom.xml +++ b/example/example-infra/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml pom diff --git a/example/example-interface/pom.xml b/example/example-interface/pom.xml index 5eafea994..9c99fbb0e 100644 --- a/example/example-interface/pom.xml +++ b/example/example-interface/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 example-interface diff --git a/example/example-server/pom.xml b/example/example-server/pom.xml index b5c8954cd..73e58ece6 100644 --- a/example/example-server/pom.xml +++ b/example/example-server/pom.xml @@ -5,7 +5,7 @@ springboot-example com.codingapi.springboot - 3.4.54 + 3.4.55 4.0.0 diff --git a/example/pom.xml b/example/pom.xml index f12815507..ea1257c2f 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -14,12 +14,12 @@ com.codingapi.springboot springboot-parent - 3.3.5 + 3.4.55 ../pom.xml springboot-example - 3.4.54 + 3.4.55 springboot-example springboot-example project for Spring Boot diff --git a/pom.xml b/pom.xml index 82a3b0153..a2d03bc92 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.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 d673b1063..97370691f 100644 --- a/springboot-starter-data-authorization/pom.xml +++ b/springboot-starter-data-authorization/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 springboot-starter-data-authorization diff --git a/springboot-starter-data-fast/pom.xml b/springboot-starter-data-fast/pom.xml index e1e9666d6..e94c4e69d 100644 --- a/springboot-starter-data-fast/pom.xml +++ b/springboot-starter-data-fast/pom.xml @@ -5,7 +5,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 4.0.0 diff --git a/springboot-starter-flow/pom.xml b/springboot-starter-flow/pom.xml index ba4a583f9..0b58df480 100644 --- a/springboot-starter-flow/pom.xml +++ b/springboot-starter-flow/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 springboot-starter-flow diff --git a/springboot-starter-script/pom.xml b/springboot-starter-script/pom.xml index d9c4853ba..07954d4e6 100644 --- a/springboot-starter-script/pom.xml +++ b/springboot-starter-script/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.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 4cad69aa5..e12ca04ca 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; /** * 临时脚本数据上下文管理对象 @@ -15,10 +21,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 { @@ -26,30 +42,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); + } } /** @@ -60,7 +83,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(); } } @@ -73,7 +106,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)); } } } @@ -93,10 +126,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); + } + } /** * 脚本总数量 @@ -118,7 +164,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; @@ -131,6 +181,9 @@ public GroovyScript getGroovyScript(String key) { * 清空脚本数据 */ public void clear() { + for (ClearJob job : this.cache.values()) { + job.cancel(); + } this.cache.clear(); } } 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 ba60d6d0c..596f4df7d 100644 --- a/springboot-starter-security/pom.xml +++ b/springboot-starter-security/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 springboot-starter-security diff --git a/springboot-starter/pom.xml b/springboot-starter/pom.xml index 8e3937d68..4aed573ed 100644 --- a/springboot-starter/pom.xml +++ b/springboot-starter/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 springboot-starter diff --git a/springboot-starter/src/main/java/com/codingapi/springboot/framework/event/EventTraceContext.java b/springboot-starter/src/main/java/com/codingapi/springboot/framework/event/EventTraceContext.java index 6088234ce..4ed131712 100644 --- a/springboot-starter/src/main/java/com/codingapi/springboot/framework/event/EventTraceContext.java +++ b/springboot-starter/src/main/java/com/codingapi/springboot/framework/event/EventTraceContext.java @@ -1,5 +1,4 @@ package com.codingapi.springboot.framework.event; - import com.codingapi.springboot.framework.exception.EventLoopException; import com.codingapi.springboot.framework.utils.RandomGenerator; import lombok.Getter;