diff --git a/.claude/settings.json b/.claude/settings.json index 19959dfbb..ca30ed8e6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,5 +3,8 @@ "allow": [ "Bash(echo \"exit: $?\")" ] + }, + "enabledPlugins": { + "pkr@pkr-marketplace": true } } diff --git a/.gitignore b/.gitignore index adf1f08ad..9ece17937 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,10 @@ build/ ### qwen ### -.qwen/ \ No newline at end of file +.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..fd27cbc44 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,184 @@ +# 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.54`。 + +**回复语言**:请使用中文进行回答。 + +## 常用命令 + +```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/capabilities/index.md](./docs/capabilities/index.md) — 已有可复用能力 +2. [docs/conventions/index.md](./docs/conventions/index.md) — 开发规范 + +已有能力必须复用,禁止重新实现。编码必须遵循已注册的规范。 + +### 计划模式约束 + +计划方案中必须包含: +1. **复用了哪些已有能力** — 列出从 PKR 中找到并复用的 Capability +2. **遵循了哪些规范** — 列出遵守的 Convention +3. **是否有新增能力** — 如果本次开发产生了可复用的新能力,完成后通过 `/pkr-add` 注册 + +### 知识管理命令 + +| 命令 | 用途 | +|------|------| +| `/pkr-init` | 首次扫描项目,发现候选能力和规范 | +| `/pkr-sync` | 全量同步,对比代码变更 | +| `/pkr-update [desc]` | 单项更新,可带描述指导更新 | +| `/pkr-add [name] ` | 从代码/框架扫描注册(名称可省略) | +| `/pkr-add plan [name] ` | 注册计划中的能力(名称可省略) | + diff --git a/docs/capabilities/crypto-tools.md b/docs/capabilities/crypto-tools.md new file mode 100644 index 000000000..b8791dcb3 --- /dev/null +++ b/docs/capabilities/crypto-tools.md @@ -0,0 +1,247 @@ +--- +name: crypto-tools +description: 密码学工具套件,AES(CBC 模式)/RSA(BouncyCastle)/DES 加解密 + SHA256/HmacSHA256 哈希签名,提供实例化工具类和静态方法两种 API +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 36eae41d +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/AES.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/AESUtils.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/DES.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/DESUtils.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/RSA.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/RSAUtils.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/SHA256.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/crypto/HmacSHA256.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/crypto/AESTools.java +--- + +## 解决什么问题 + +企业应用中大量场景需要密码学支持:敏感数据存储加密、API 通信签名验证、Token 加解密、文件完整性校验等。直接使用 JDK 原生 `javax.crypto` / `java.security` API 存在以下问题: + +- **API 繁琐**:每次加解密都需要手动创建 Cipher、KeySpec、AlgorithmParameters,代码冗长且容易出错 +- **Provider 管理**:BouncyCastle 等第三方 Provider 需要手动注册,遗漏会导致运行时异常 +- **密钥格式混乱**:byte[]、String、Base64 之间的转换散落在各处 +- **缺少统一入口**:不同算法的使用方式不一致,增加学习成本 + +本能力封装了五种常用密码学算法,提供两种使用风格: + +- **实例化 API**(`AES`/`DES`/`RSA`):适合自定义密钥、动态生成密钥对的场景 +- **单例工具类**(`AESUtils`/`DESUtils`/`RSAUtils`/`AESTools`):内置预置密钥,开箱即用,适合应用内统一加解密 +- **静态方法**(`SHA256`/`HmacSHA256`):无状态哈希/签名,直接调用 + +## 如何使用 + +### AES 加解密(CBC/PKCS5Padding) + +基于 BouncyCastle Provider,默认使用 `AES/CBC/PKCS5Padding` 算法。 + +```java +// 自动生成 256 位密钥 + 随机 IV +AES aes = new AES(); + +// 指定密钥长度 +AES aes = new AES(128); + +// 使用指定密钥和 IV(byte[]) +AES aes = new AES(keyBytes, ivBytes); + +// 使用字符串密钥和 IV +AES aes = new AES("mySecretKey12345", "myIvParam1234567"); + +// 加解密 +byte[] encrypted = aes.encrypt("Hello".getBytes(StandardCharsets.UTF_8)); +byte[] decrypted = aes.decrypt(encrypted); + +// 获取密钥和 IV(用于传输或存储) +byte[] key = aes.getKey(); +byte[] iv = aes.getIv(); +``` + +#### AESUtils — 预置密钥单例 + +内置 Base64 编码的预置密钥和 IV,适用于应用内部数据加密: + +```java +AESUtils utils = AESUtils.getInstance(); + +// 字符串加解密(自动 Base64 编解码) +String cipher = utils.encode("敏感数据"); +String plain = utils.decode(cipher); + +// byte[] 加解密(原始字节,不做 Base64) +byte[] encrypted = utils.encode(rawBytes); +byte[] decrypted = utils.decode(encrypted); +``` + +#### AESTools — Security 模块的 AES 封装 + +位于 `springboot-starter-security` 模块,通过 `init(AES)` 方法注入自定义 AES 实例,适合安全模块统一管理密钥: + +```java +// 由安全模块配置类初始化 +AESTools.getInstance().init(new AES(customKey, customIv)); + +// 使用方式与 AESUtils 一致 +String cipher = AESTools.getInstance().encode("data"); +String plain = AESTools.getInstance().decode(cipher); +``` + +### DES 加解密 + +标准 DES 算法,ECB 模式。 + +```java +// 自动生成密钥 +DES des = new DES(); + +// 使用指定密钥 +DES des = new DES("myKey123"); +DES des = new DES(keyBytes); + +// 加解密 +byte[] encrypted = des.encrypt(data); +byte[] decrypted = des.decrypt(encrypted); +``` + +#### DESUtils — 预置密钥单例 + +```java +DESUtils utils = DESUtils.getInstance(); +String cipher = utils.encode("明文"); +String plain = utils.decode(cipher); +``` + +### RSA 加解密(2048 位) + +基于 BouncyCastle Provider,使用 `RSA/ECB/PKCS1Padding`,密钥长度 2048 位。 + +```java +// 自动生成密钥对 +RSA rsa = new RSA(); + +// 从已有密钥构造 +RSA rsa = new RSA(privateKeyBytes, publicKeyBytes); +RSA rsa = new RSA(publicKeyBytes); // 仅公钥(只能加密) +RSA rsa = new RSA(keyPair); + +// 公钥加密 / 私钥解密 +byte[] encrypted = rsa.encrypt("Hello".getBytes(StandardCharsets.UTF_8)); +byte[] decrypted = rsa.decrypt(encrypted); + +// 导出密钥 +byte[] pubKey = rsa.getPublicKey(); +byte[] priKey = rsa.getPrivateKey(); +``` + +#### RSAUtils — 预置密钥单例 + +内置 Base64 编码的 2048 位 RSA 密钥对: + +```java +RSAUtils utils = RSAUtils.getInstance(); +String cipher = utils.encode("敏感数据"); +String plain = utils.decode(cipher); +``` + +### SHA256 哈希 + +无状态静态方法,返回十六进制小写字符串: + +```java +String hash = SHA256.sha256("Hello World"); +// 输出: "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e" +``` + +### HmacSHA256 签名 + +无状态静态方法,返回 HMAC-SHA256 签名字节数组: + +```java +byte[] signature = HmacSHA256.sha256( + "message".getBytes(StandardCharsets.UTF_8), + "secret-key".getBytes(StandardCharsets.UTF_8) +); +String hexSign = HexFormat.of().formatHex(signature); +``` + +## 使用实例 + +### 1. API 请求签名验证 + +```java +// 客户端签名 +String timestamp = String.valueOf(System.currentTimeMillis()); +String payload = userId + timestamp; +byte[] sign = HmacSHA256.sha256( + payload.getBytes(StandardCharsets.UTF_8), + appSecret.getBytes(StandardCharsets.UTF_8) +); +String signature = HexFormat.of().formatHex(sign); + +// 服务端验签 +byte[] expected = HmacSHA256.sha256( + payload.getBytes(StandardCharsets.UTF_8), + appSecret.getBytes(StandardCharsets.UTF_8) +); +boolean valid = Arrays.equals(sign, expected); +``` + +### 2. 用户敏感字段存储加密 + +```java +// 入库时加密 +String encryptedPhone = AESUtils.getInstance().encode(user.getPhone()); +user.setPhone(encryptedPhone); +userRepository.save(user); + +// 查询时解密 +String phone = AESUtils.getInstance().decode(user.getPhone()); +``` + +### 3. RSA 密钥交换 + AES 数据加密 + +```java +// 发送方:用接收方公钥加密 AES 密钥 +RSA rsa = new RSA(receiverPublicKeyBytes); +AES aes = new AES(); +byte[] encryptedAesKey = rsa.encrypt(aes.getKey()); + +// 用 AES 加密实际数据 +byte[] encryptedData = aes.encrypt(sensitiveData); + +// 接收方:用私钥解密 AES 密钥,再解密数据 +RSA rsaReceiver = new RSA(receiverPrivateKeyBytes, receiverPublicKeyBytes); +byte[] aesKey = rsaReceiver.decrypt(encryptedAesKey); +AES aesDecrypt = new AES(aesKey, aes.getIv()); +byte[] plainData = aesDecrypt.decrypt(encryptedData); +``` + +### 4. 密码哈希存储 + +```java +// 注册时 +String passwordHash = SHA256.sha256(rawPassword + salt); +user.setPassword(passwordHash); + +// 登录验证 +String inputHash = SHA256.sha256(inputPassword + salt); +boolean match = passwordHash.equals(inputHash); +``` + +### 5. 安全模块中自定义 AES 密钥 + +```java +@Configuration +public class SecurityCryptoConfig { + + @PostConstruct + public void initAESTools() throws Exception { + String key = environment.getProperty("security.aes.key"); + String iv = environment.getProperty("security.aes.iv"); + AESTools.getInstance().init(new AES(key, iv)); + } +} +``` diff --git a/docs/capabilities/data-authorization.md b/docs/capabilities/data-authorization.md new file mode 100644 index 000000000..e037b63c7 --- /dev/null +++ b/docs/capabilities/data-authorization.md @@ -0,0 +1,228 @@ +--- +name: data-authorization +description: 完整的数据权限框架,包含行级权限(WHERE/JOIN 条件注入)和列级权限(数据脱敏),基于 JSqlParser 的 SQL 增强引擎,提供 ColumnMask SPI、RowHandler/ColumnHandler SPI、SkipAuthorizationFilter 跳过授权机制 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 67895fda +code_files: + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationConfiguration.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/DataPermissionSQLEnhancer.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/filter/DataAuthorizationFilter.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/filter/DefaultDataAuthorizationFilter.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/filter/SkipAuthorizationFilter.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/handler/RowHandler.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/handler/ColumnHandler.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/handler/ColumnHandlerContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/mask/ColumnMask.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/mask/ColumnMaskContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/mask/impl/PhoneMask.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/mask/impl/BankCardMask.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/mask/impl/IDCardMask.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/condition/IConditionSQL.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/condition/WhereConditionSQL.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/condition/JoinConditionSQL.java +--- + +## 解决什么问题 + +在企业级应用中,不同角色/部门的用户只能看到其权限范围内的数据行,且敏感字段(手机号、银行卡号、身份证号等)需要脱敏展示。如果由每个业务模块自行实现权限过滤和数据脱敏,会导致大量重复代码、规则分散难以维护、容易遗漏造成数据泄露。 + +本能力提供一套完整的数据权限框架,从两个维度保障数据安全: + +- **行级权限**:通过 `RowHandler` SPI 定义过滤规则,`DataPermissionSQLEnhancer` 基于 JSqlParser 解析 SQL AST,自动向 SELECT 语句注入 WHERE 条件或 JOIN 关联表,支持子查询、UNION、嵌套 SELECT 等复杂场景 +- **列级权限**:通过 `ColumnHandler` SPI 拦截 ResultSet 读取,结合 `ColumnMask` SPI 对敏感数据自动脱敏。内置手机号、银行卡号、身份证号三种脱敏策略,可按需扩展 +- **跳过授权机制**:通过 `SkipAuthorizationFilter` 接口,允许特定 SQL 或场景(如管理后台全量导出)绕过数据权限拦截 +- **Spring Boot 自动装配**:通过 `DataAuthorizationConfiguration` 自动注册所有组件,业务方只需实现 SPI 接口并声明为 Bean 即可生效 + +## 如何使用 + +### 核心架构 + +``` +DataAuthorizationContext (单例入口) +├── DataAuthorizationFilter[] — 权限过滤器链(行+列) +├── SkipAuthorizationFilter — 跳过授权判断 +├── RowHandler — 行权限条件提供者 +├── ColumnHandler — 列权限结果处理器 +└── ColumnMask[] — 数据脱敏策略链 +``` + +### 行级权限 — RowHandler + +实现 `RowHandler` 接口,根据表名返回需要注入的 SQL 条件: + +```java +public interface RowHandler { + Condition handler(String subSql, String tableName, String tableAlias); +} +``` + +`Condition` 包含 `List` 条件列表,支持两种条件类型: + +- **WhereConditionSQL**:追加 WHERE 条件,如 `"t.dept_id = %d"` +- **JoinConditionSQL**:追加 JOIN 关联表,支持 INNER/LEFT/RIGHT 三种类型,指定表名、别名和 ON 条件 + +将 `RowHandler` 实现声明为 Spring Bean,`ConditionHandlerRegister` 会自动将其注册到框架中。 + +### 列级权限 — ColumnHandler + +实现 `ColumnHandler` 接口,拦截 ResultSet 中各类型字段的读取: + +```java +public interface ColumnHandler { + String getString(SQLExecuteState state, int columnIndex, + String tableName, String columnName, String value); + int getInt(SQLExecuteState state, int columnIndex, + String tableName, String columnName, int value); + // ... 覆盖所有 JDBC ResultSet.getXxx() 方法 +} +``` + +`ColumnHandlerContext` 是单例委托器,框架内部通过它调用 `ColumnHandler`。将 `ColumnHandler` 实现声明为 Spring Bean,`ResultSetHandlerRegister` 会自动注册。 + +### 数据脱敏 — ColumnMask SPI + +```java +public interface ColumnMask { + boolean support(Object value); // 判断是否匹配该脱敏规则 + Object mask(Object value); // 执行脱敏 +} +``` + +内置三种脱敏实现,通过正则匹配自动识别: + +| 实现类 | 匹配规则 | 脱敏效果 | +|--------|----------|----------| +| `PhoneMask` | `^1[3-9]\d{9}$` | `138****1234` | +| `BankCardMask` | `^\d{13,19}$` | `622202******1234` | +| `IDCardMask` | 15/18位身份证 | `110101********1234` | + +通过 `ColumnMaskContext.getInstance().addColumnMask(mask)` 注册自定义脱敏策略,按注册顺序依次匹配,首个命中即返回。 + +### 跳过授权 — SkipAuthorizationFilter + +```java +public interface SkipAuthorizationFilter { + String filter(String sql); // 返回处理后的 SQL,或 null 表示不跳过 +} +``` + +通过 `DataAuthorizationContext.getInstance().setSkipAuthorizationFilter(filter)` 设置。默认使用 `DefaultSkipAuthorizationFilter`(不跳过任何 SQL)。 + +### 自动配置 + +`DataAuthorizationConfiguration` 通过 `@Configuration` 自动注册以下 Bean: + +- `DataAuthorizationProperties`(配置前缀 `codingapi.data-authorization`) +- `ConditionHandlerRegister`(注入 `RowHandler`) +- `ResultSetHandlerRegister`(注入 `ColumnHandler`) +- `SQLInterceptorRegister`(注入 `SQLInterceptor`) +- `DataAuthorizationContextRegister`(注入所有 `DataAuthorizationFilter` Bean) + +所有 Handler/Filter 均为 `@Autowired(required = false)`,未提供时使用默认空实现。 + +## 使用实例 + +### 1. 实现行级数据权限 + +```java +@Component +public class DeptRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + if ("employee".equalsIgnoreCase(tableName)) { + Long deptId = SecurityContext.getCurrentDeptId(); + WhereConditionSQL where = new WhereConditionSQL( + "%s.dept_id = %d", tableAlias, deptId + ); + return Condition.of(where); + } + return null; + } +} +``` + +### 2. 实现列级数据脱敏 + +```java +@Component +public class SensitiveColumnHandler extends DefaultColumnHandler { + + @Override + public String getString(SQLExecuteState state, int columnIndex, + String tableName, String columnName, String value) { + // 委托给 ColumnMaskContext 进行自动脱敏 + return ColumnMaskContext.getInstance().mask(value); + } +} +``` + +### 3. 注册自定义脱敏策略 + +```java +@Component +public class EmailMask implements ColumnMask { + + private static final Pattern EMAIL_PATTERN = + Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w+$"); + + @Override + public boolean support(Object value) { + return value instanceof String + && EMAIL_PATTERN.matcher((String) value).matches(); + } + + @Override + public Object mask(Object value) { + String email = (String) value; + int atIndex = email.indexOf('@'); + return email.charAt(0) + "***" + email.substring(atIndex); + } +} + +// 在初始化时注册 +@PostConstruct +public void init() { + ColumnMaskContext.getInstance().addColumnMask(new EmailMask()); +} +``` + +### 4. 使用 JOIN 条件实现跨表行权限 + +```java +@Component +public class ProjectRowHandler implements RowHandler { + + @Override + public Condition handler(String subSql, String tableName, String tableAlias) { + if ("task".equalsIgnoreCase(tableName)) { + JoinConditionSQL join = new JoinConditionSQL( + JoinConditionSQL.Type.INNER, + "project_member", + "pm", + String.format("%s.project_id = pm.project_id AND pm.user_id = %d", + tableAlias, SecurityContext.getCurrentUserId()) + ); + return Condition.of(join); + } + return null; + } +} +``` + +### 5. 临时跳过数据权限 + +```java +// 管理后台导出全量数据 +DataAuthorizationContext.getInstance() + .setSkipAuthorizationFilter(sql -> sql); // 放行所有 SQL + +List all = employeeRepository.findAll(); + +// 恢复正常权限 +DataAuthorizationContext.getInstance() + .setSkipAuthorizationFilter(new DefaultSkipAuthorizationFilter()); +``` diff --git a/docs/capabilities/domain-change-interceptor.md b/docs/capabilities/domain-change-interceptor.md new file mode 100644 index 000000000..f2b482e0d --- /dev/null +++ b/docs/capabilities/domain-change-interceptor.md @@ -0,0 +1,165 @@ +--- +name: domain-change-interceptor +description: 通过 CGLIB 代理拦截领域实体字段的 setter 方法,自动检测变更并发布 DomainChangeEvent +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 87c449a7 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/proxy/DomainChangeInterceptor.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/proxy/DomainProxyFactory.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainChangeEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainCreateEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainDeleteEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/IDomain.java +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)实践中,领域实体的状态变更需要被精确追踪并作为领域事件发布,以便触发审计日志、通知下游系统、维护事件溯源等。传统做法是在每个 setter 方法中手动编写变更检测和事件发布代码,导致领域模型充斥大量样板代码,且容易遗漏。 + +本能力通过 CGLIB 动态代理技术,在运行时拦截领域实体的所有带参数的方法调用(主要是 setter),自动对比字段变更前后的值,当检测到差异时自动发布 `DomainChangeEvent`。核心优势: + +- **零侵入领域模型**:实体类无需继承特定基类或添加注解,保持纯 POJO +- **自动变更检测**:支持基本类型和嵌套对象的深层字段比较 +- **完整生命周期事件**:覆盖创建(`DomainCreateEvent`)、变更(`DomainChangeEvent`)、删除(`DomainDeleteEvent`)、持久化(`DomainPersistEvent`)四种领域事件 +- **与框架事件系统集成**:变更事件通过 `EventPusher` 发布,可被同步/异步 Handler 消费 + +## 如何使用 + +### 核心组件 + +#### IDomain — 领域对象接口 + +```java +public interface IDomain { + // 发布持久化事件 + default void persist() { EventPusher.push(new DomainPersistEvent(this)); } + + // 发布删除事件 + default void delete() { EventPusher.push(new DomainDeleteEvent(this)); } +} +``` + +领域实体可选择性实现此接口以获得便捷的持久化和删除事件发布能力。 + +#### DomainProxyFactory — 代理工厂 + +```java +// 创建带有变更拦截的领域实体代理 +public static T create(Class entityClass, Object... args) +``` + +调用 `create()` 会: +1. 通过反射调用目标类的构造函数创建实例 +2. 使用 CGLIB 生成代理对象 +3. 自动发布 `DomainCreateEvent` +4. 返回代理对象 + +#### DomainChangeInterceptor — CGLIB 方法拦截器 + +内部工作机制: +1. **首次拦截时**:通过 `BeanUtils.getPropertyDescriptors()` 读取实体所有属性,递归快照当前字段值到内存 Map +2. **每次 setter 调用后**:重新读取字段值并与快照对比 +3. **发现差异时**:通过 `EventPusher.push()` 发布 `DomainChangeEvent`,包含字段名、旧值、新值 +4. **更新快照**:将新值写入快照 Map,为下次比较做准备 + +#### 领域事件体系 + +``` +DomainEvent (抽象基类,实现 IEvent) +├── entity — 关联的实体对象 +├── entityClass — 实体类型 +└── timestamp — 事件时间戳 + +DomainCreateEvent extends DomainEvent — 实体创建 +DomainChangeEvent extends DomainEvent — 字段变更(含 fieldName, oldValue, newValue) +DomainDeleteEvent extends DomainEvent — 实体删除 +DomainPersistEvent extends DomainEvent — 实体持久化 +``` + +### 注意事项 + +- 代理对象通过 CGLIB 子类化实现,要求目标类不能是 final 类,且需要有可访问的构造函数 +- 变更检测基于 `equals()` 比较,自定义对象需正确实现 `equals()` 方法 +- 仅对带参数的方法调用进行变更检测(即 setter),无参方法(getter)直接透传 +- 支持嵌套对象的深层比较,但仅限基本类型属性的递归展开 + +## 使用实例 + +### 1. 定义领域实体 + +```java +public class UserEntity implements IDomain { + private String name; + private Integer age; + private Address address; + + public UserEntity() {} + + public UserEntity(String name, Integer age) { + this.name = name; + this.age = age; + } + + // getter/setter ... +} +``` + +### 2. 通过代理工厂创建实体 + +```java +// 创建代理对象,自动发布 DomainCreateEvent +UserEntity user = DomainProxyFactory.create(UserEntity.class, "张三", 25); + +// 修改字段时自动检测变更并发布 DomainChangeEvent +user.setName("李四"); // → DomainChangeEvent(fieldName="name", oldValue="张三", newValue="李四") +user.setAge(30); // → DomainChangeEvent(fieldName="age", oldValue=25, newValue=30) + +// 持久化时发布 DomainPersistEvent +user.persist(); + +// 删除时发布 DomainDeleteEvent +user.delete(); +``` + +### 3. 监听领域变更事件 + +```java +@Component +public class UserChangeHandler implements IHandler { + + @Override + public void handler(DomainChangeEvent event) { + if (event.getEntity() instanceof UserEntity) { + log.info("用户字段变更: {} = {} → {}", + event.getFieldName(), + event.getOldValue(), + event.getNewValue()); + } + } +} +``` + +### 4. 监听实体创建事件 + +```java +@Component +public class UserCreateHandler implements IHandler { + + @Override + public void handler(DomainCreateEvent event) { + log.info("新用户创建: {}", event.getEntity().getClass().getSimpleName()); + } +} +``` + +### 5. 嵌套对象变更检测 + +```java +// 假设 UserEntity 中包含 Address 嵌套对象 +user.getAddress().setCity("北京"); +// → DomainChangeEvent(fieldName="address.city", oldValue="上海", newValue="北京") +``` + +嵌套对象的字段变更会以 `父字段.子字段` 的格式记录字段路径,便于精确定位变更位置。 diff --git a/docs/capabilities/dynamic-application.md b/docs/capabilities/dynamic-application.md new file mode 100644 index 000000000..2b59e906c --- /dev/null +++ b/docs/capabilities/dynamic-application.md @@ -0,0 +1,132 @@ +--- +name: dynamic-application +description: 支持外部 JAR 加载和 Spring 上下文热重启的应用启动器 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: e8583c68 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/boot/DynamicApplication.java +--- + +## 解决什么问题 + +标准 Spring Boot 应用的类路径在启动时确定,运行期间无法动态加载新的 JAR 包或模块。当业务需要以下场景时,传统启动方式无法满足: + +- **插件化架构**:核心应用运行时加载外部插件 JAR,无需重新打包主应用 +- **热部署/热更新**:开发或运维过程中,替换 JAR 后自动重启 Spring 上下文,减少停机时间 +- **模块化分发**:不同客户/环境使用不同的 JAR 组合,通过外部目录配置而非 Maven 依赖 + +本能力提供了 `DynamicApplication` 启动器,替代标准的 `SpringApplication.run()`,在启动前扫描指定目录下的 JAR 文件并注入到类加载器中,同时支持运行时关闭并重建 Spring 上下文(热重启)。 + +## 如何使用 + +### 核心类 + +| 类 | 说明 | +|----|------| +| `DynamicApplication` | 单例应用启动器,管理外部 JAR 加载和 Spring 上下文生命周期 | + +### API + +```java +// 启动应用(替代 SpringApplication.run) +DynamicApplication.run(Class applicationClass, String[] args) + +// 热重启(关闭当前上下文 → 重新加载 JAR → 重新启动) +DynamicApplication.restart() + +// 获取单例实例 +DynamicApplication app = DynamicApplication.getInstance() + +// 设置外部 JAR 目录(默认 ./jars) +app.setJarsFolder("/path/to/plugins") +``` + +### 启动流程 + +1. 调用 `DynamicApplication.run(App.class, args)` +2. 扫描 `jarsFolder` 目录下所有 `.jar` 文件 +3. 创建 `URLClassLoader` 并将这些 JAR 加入类路径 +4. 将当前线程的 ContextClassLoader 设置为该 URLClassLoader +5. 调用 `SpringApplication.run()` 启动 Spring Boot 应用 + +### 热重启流程 + +1. 调用 `DynamicApplication.restart()` +2. 在新线程中执行(非守护线程,确保 JVM 不会提前退出) +3. 关闭当前 `ConfigurableApplicationContext` +4. 重新扫描 JAR 目录并更新 ClassLoader +5. 使用原始启动类和参数重新启动 Spring Boot + +### 配置 + +```java +// 修改外部 JAR 目录(默认为工作目录下的 ./jars) +DynamicApplication.getInstance().setJarsFolder("/opt/app/plugins"); +``` + +### 注意事项 + +- 外部 JAR 目录不存在或为空时,不加载任何额外类路径,应用正常启动 +- 热重启会完全销毁并重建 Spring 上下文,所有 Bean 会被重新初始化 +- 热重启在新线程中执行,原调用线程立即返回 +- `URLClassLoader` 以系统 ClassLoader 为父加载器,外部 JAR 中的类优先级高于系统类路径中的同名类 + +## 使用实例 + +### 1. 基本启动(替代 SpringApplication.run) + +```java +@SpringBootApplication +public class MyApplication { + + public static void main(String[] args) { + // 替代 SpringApplication.run(MyApplication.class, args); + DynamicApplication.run(MyApplication.class, args); + } +} +``` + +### 2. 自定义 JAR 目录启动 + +```java +@SpringBootApplication +public class MyApplication { + + public static void main(String[] args) { + DynamicApplication.getInstance().setJarsFolder("/opt/myapp/extensions"); + DynamicApplication.run(MyApplication.class, args); + } +} +``` + +### 3. 通过 REST API 触发重启 + +```java +@RestController +@RequestMapping("/admin") +public class AdminController { + + @PostMapping("/restart") + public Response restart() { + // 异步重启,接口立即返回 + DynamicApplication.restart(); + return Response.success(); + } +} +``` + +### 4. 目录结构示例 + +``` +project-root/ +├── jars/ ← 默认外部 JAR 目录 +│ ├── plugin-a.jar ← 自动加载 +│ ├── plugin-b.jar ← 自动加载 +│ └── readme.txt ← 非 .jar 文件,忽略 +├── src/ +└── pom.xml +``` + +将新的插件 JAR 放入 `jars/` 目录后,调用 `DynamicApplication.restart()` 即可加载新插件并重启应用。 diff --git a/docs/capabilities/dynamic-mvc-mapping.md b/docs/capabilities/dynamic-mvc-mapping.md new file mode 100644 index 000000000..0511cf1cc --- /dev/null +++ b/docs/capabilities/dynamic-mvc-mapping.md @@ -0,0 +1,168 @@ +--- +name: dynamic-mvc-mapping +description: 运行时动态注册/注销 Spring MVC REST 端点,支持将 Groovy 脚本绑定到动态 API 路径 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: d5569b1d +code_files: + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/mapping/FastMvcMappingRegister.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/script/FastScriptMappingRegister.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/script/ScriptMapping.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/script/ScriptRuntime.java +--- + +## 解决什么问题 + +传统 Spring MVC 的 REST 端点在编译期通过注解(`@RequestMapping`)静态声明,无法在运行时按需增减。当业务需要以下场景时,静态映射无法满足: + +- **低代码/无代码平台**:用户通过界面配置 API,后端需动态生成对应的 REST 端点 +- **Groovy 脚本绑定 API**:将动态编写的 Groovy 脚本挂载到指定 URL 路径,作为即时可用的 REST 接口 +- **插件化扩展**:第三方模块在运行时注册自己的 API,卸载时自动注销 +- **灰度/临时接口**:在不重启应用的前提下快速上线或下线调试端点 + +本能力封装了 Spring `RequestMappingHandlerMapping` 的底层 API,提供安全的运行时端点注册/注销操作,并与 Groovy 脚本引擎集成,实现"脚本即 API"的动态绑定。 + +## 如何使用 + +### 核心组件 + +| 类 | 说明 | +|----|------| +| `FastMvcMappingRegister` | 底层 MVC 映射注册器,直接操作 `RequestMappingHandlerMapping` | +| `FastScriptMappingRegister` | 上层脚本映射注册器,将 `ScriptMapping` 绑定为 REST 端点 | +| `ScriptMapping` | 脚本与 URL 的绑定关系,持有映射路径、HTTP 方法和 Groovy 脚本内容 | +| `ScriptRuntime` | 脚本执行运行时(单例),向脚本注入 `$request`、`$jpa`、`$jdbc` 上下文变量 | +| `ScriptMethod` | HTTP 方法枚举(GET / POST),转换为 Spring `RequestMethod` | +| `ScriptRequest` | 对 `HttpServletRequest` 的类型安全封装,提供带默认值的参数获取方法 | + +### 自动配置 + +`DataFastConfiguration` 在检测到 `WebMvcConfigurer` 类存在时自动注册以下 Bean: + +- `FastMvcMappingRegister` — 注入 Spring 容器的 `requestMappingHandlerMapping` +- `FastScriptMappingRegister` — 依赖 `FastMvcMappingRegister` + +无需手动配置,引入 `springboot-starter-data-fast` 模块即可使用。 + +### FastMvcMappingRegister API + +```java +// 注册端点 +void addMapping(String url, RequestMethod requestMethod, Object handler, Method method) + +// 注销端点 +void removeMapping(String url, RequestMethod requestMethod) +``` + +所有端点默认产出 `application/json` 媒体类型,使用 `PathPatternParser` 解析路径模式。 + +### FastScriptMappingRegister API + +```java +// 将脚本映射注册为 REST 端点 +void addMapping(ScriptMapping scriptMapping) + +// 测试脚本映射(不注册端点,直接执行并返回结果) +Response test(ScriptMapping scriptMapping) + +// 注销脚本映射对应的端点 +void removeMapping(String url, ScriptMethod scriptMethod) +``` + +### ScriptRuntime 注入变量 + +脚本执行时自动绑定以下变量: + +| 变量名 | 类型 | 说明 | +|--------|------|------| +| `$request` | `ScriptRequest` | 当前 HTTP 请求的类型安全封装 | +| `$jpa` | `JpaQuery` | JPA 查询工具 | +| `$jdbc` | `JdbcQuery` | JDBC 查询工具 | + +### ScriptRequest 参数获取 + +```java +String getParameter(String key, String defaultValue) +int getParameter(String key, int defaultValue) +float getParameter(String key, float defaultValue) +double getParameter(String key, double defaultValue) +long getParameter(String key, long defaultValue) +boolean getParameter(String key, boolean defaultValue) +PageRequest pageRequest(int pageNumber, int pageSize) +``` + +## 使用实例 + +### 1. 注册一个动态 GET 端点 + +```java +@Autowired +private FastScriptMappingRegister scriptMappingRegister; + +// 创建脚本映射:GET /api/dynamic/hello → 返回字符串 +ScriptMapping mapping = new ScriptMapping( + "/api/dynamic/hello", + ScriptMethod.GET, + "return 'Hello, Dynamic API!'" +); + +// 注册为 REST 端点 +scriptMappingRegister.addMapping(mapping); +// 此时 GET /api/dynamic/hello 已可用,返回 SingleResponse{"Hello, Dynamic API!"} +``` + +### 2. 注册一个带参数的 POST 端点 + +```java +String script = """ + def name = $request.getParameter('name', 'World') + def age = $request.getParameter('age', 0) + return [name: name, age: age] +"""; + +ScriptMapping mapping = new ScriptMapping( + "/api/dynamic/greet", + ScriptMethod.POST, + script +); +scriptMappingRegister.addMapping(mapping); +``` + +### 3. 测试脚本(不注册端点) + +```java +ScriptMapping mapping = new ScriptMapping( + "/test", + ScriptMethod.GET, + "return [1, 2, 3]" +); + +// 直接执行脚本并获取结果,不会注册到 MVC +Response result = scriptMappingRegister.test(mapping); +``` + +### 4. 注销动态端点 + +```java +// 移除之前注册的端点 +scriptMappingRegister.removeMapping("/api/dynamic/hello", ScriptMethod.GET); +// GET /api/dynamic/hello 不再可用 +``` + +### 5. 脚本中使用 JPA/JDBC 查询 + +```java +String script = """ + def page = $request.pageRequest(0, 20) + def users = $jpa.query('select u from User u', page) + return users +"""; + +ScriptMapping mapping = new ScriptMapping( + "/api/dynamic/users", + ScriptMethod.GET, + script +); +scriptMappingRegister.addMapping(mapping); +``` diff --git a/docs/capabilities/event-system.md b/docs/capabilities/event-system.md new file mode 100644 index 000000000..42df04b12 --- /dev/null +++ b/docs/capabilities/event-system.md @@ -0,0 +1,137 @@ +--- +name: event-system +description: 自建的领域事件发布-订阅系统,支持同步/异步事件、事务后提交、Handler自动注册排序 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 145aada7 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/EventPusher.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/IHandler.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/IEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/ISyncEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/IAsyncEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/DomainEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/DomainEventContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/SpringDefaultEventHandler.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/SpringTransactionEventHandler.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/HandlerBeanDefinitionRegistrar.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/ApplicationHandlerUtils.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainCreateEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainChangeEvent.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/domain/event/DomainDeleteEvent.java +--- + +## 解决什么问题 + +在 DDD(领域驱动设计)架构中,领域实体状态变更时需要通知其他模块执行联动逻辑(如发送通知、更新缓存、触发下游流程等)。如果直接调用会导致模块间强耦合,且主业务与分支逻辑的事务边界不清晰。 + +本能力提供了一套自建的发布-订阅事件系统,解决以下痛点: + +- **解耦**:事件发布者无需知道有哪些订阅者,通过 `IEvent` + `IHandler` 接口完全解耦 +- **同步/异步分离**:通过 `ISyncEvent` / `IAsyncEvent` 标记接口区分执行模式,异步事件由独立线程池处理 +- **事务后提交**:`SpringTransactionEventHandler` 使用 `@TransactionalEventListener(AFTER_COMMIT)` 确保事件在主事务提交后才触发,避免脏数据 +- **Handler 自动注册与排序**:所有 `IHandler` 实现类通过 Spring Bean 自动扫描注册,支持 `order()` 控制执行顺序 +- **循环检测**:内置 `EventTraceContext` 追踪事件链,防止事件循环触发 +- **异常隔离**:多 Handler 场景下,单个 Handler 异常通过 `error()` 回调处理,不影响其他 Handler 执行 + +## 如何使用 + +### 核心接口 + +| 接口/类 | 说明 | +|---------|------| +| `IEvent` | 事件标记接口,继承 `Serializable` | +| `ISyncEvent extends IEvent` | 同步事件标记 | +| `IAsyncEvent extends IEvent` | 异步事件标记 | +| `IHandler` | 事件处理器,泛型绑定事件类型 | +| `EventPusher` | 静态工具类,事件推送入口 | +| `DomainEvent` (domain包) | 领域实体事件基类,携带 entity、entityClass、timestamp | +| `DomainCreateEvent` | 实体创建事件 | +| `DomainChangeEvent` | 实体字段变更事件(含 fieldName、oldValue、newValue) | +| `DomainDeleteEvent` | 实体删除事件 | + +### 注入方式 + +无需手动注入。`HandlerBeanDefinitionRegistrar` 在 Spring 启动时自动扫描所有标注 `@Handler` 的 Bean 并注册到 `ApplicationHandlerUtils`。 + +### 配置项 + +```properties +# 异步事件线程池大小(默认20) +codingapi.framework.handler-thread-pool-size=20 +``` + +### 两种事件分发器 + +- **`SpringDefaultEventHandler`**:使用 `@EventListener`,事件立即触发 +- **`SpringTransactionEventHandler`**:使用 `@TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true)`,事务提交后触发 + +## 使用实例 + +### 1. 定义自定义事件 + +```java +// 同步事件 +public class OrderCreatedEvent implements ISyncEvent { + private final String orderId; + + public OrderCreatedEvent(String orderId) { + this.orderId = orderId; + } + + public String getOrderId() { return orderId; } +} +``` + +### 2. 编写 Handler + +```java +@Handler +@Component +public class OrderNotificationHandler implements IHandler { + + @Override + public int order() { + return 10; // 排序值,越小越先执行 + } + + @Override + public void handler(OrderCreatedEvent event) { + // 发送订单创建通知 + System.out.println("Order created: " + event.getOrderId()); + } + + @Override + public void error(Exception exception) throws Exception { + // 异常回调,记录日志但不阻止后续 Handler + log.error("Notification failed", exception); + } +} +``` + +### 3. 推送事件 + +```java +// 推送同步事件 +EventPusher.push(new OrderCreatedEvent("ORD-001")); + +// 推送异步事件(需事件类实现 IAsyncEvent) +EventPusher.push(new AsyncLogEvent("log message")); + +// 允许循环事件(跳过循环检测) +EventPusher.push(event, true); +``` + +### 4. 使用领域实体事件 + +```java +// 实体变更事件携带字段级别信息 +DomainChangeEvent changeEvent = new DomainChangeEvent( + userEntity, // 实体对象 + "status", // 字段名 + "ACTIVE", // 旧值 + "INACTIVE" // 新值 +); +EventPusher.push(changeEvent); +``` diff --git a/docs/capabilities/fast-repository.md b/docs/capabilities/fast-repository.md new file mode 100644 index 000000000..7b7805502 --- /dev/null +++ b/docs/capabilities/fast-repository.md @@ -0,0 +1,140 @@ +--- +name: fast-repository +description: JPA Repository 增强,支持动态过滤查询(RequestFilter + Relation 操作符)、HQL 自动构建、排序 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 44ba20df +code_files: + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/jpa/repository/FastRepository.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/jpa/repository/BaseRepository.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/jpa/repository/DynamicRepository.java + - springboot-starter-data-fast/src/main/java/com/codingapi/springboot/fast/jpa/repository/SortRepository.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/request/PageRequest.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/request/RequestFilter.java +--- + +## 解决什么问题 + +Spring Data JPA 原生的 `JpaRepository` 在处理复杂查询时存在以下痛点: + +- **动态条件拼接繁琐**:需要手动编写 `Specification` 或 `@Query`,无法根据前端传入的过滤参数自动构建查询 +- **分页与过滤分离**:Spring 原生 `PageRequest` 不支持携带过滤条件,业务代码需要在 Controller/Service 层反复组装查询逻辑 +- **缺少丰富的比较操作符**:LIKE、BETWEEN、IN、IS_NULL 等常用操作符没有统一的抽象 + +本能力通过扩展 `PageRequest` 和提供 `FastRepository` 接口,实现了: + +- `RequestFilter` + `Relation` 枚举统一表达 14 种查询操作符 +- `FastRepository.findAll(PageRequest)` 自动根据 Filter 构建 Example 查询 +- `FastRepository.pageRequest(PageRequest)` 自动构建 HQL 动态查询 +- `DynamicRepository` 支持原生 SQL / SQLBuilder 的动态列表和分页查询 +- `SortRepository` 提供拖拽排序的 `reSort()` 方法 + +## 如何使用 + +### 核心接口 + +| 接口/类 | 说明 | +|---------|------| +| `FastRepository` | 增强型 Repository,继承 JpaRepository + JpaSpecificationExecutor + DynamicRepository | +| `BaseRepository` | 基础 Repository,提供 `getEntityClass()` 反射获取泛型类型 | +| `DynamicRepository` | 动态查询 Repository,支持 SQLBuilder 和原生 SQL 查询 | +| `SortRepository` | 排序 Repository,提供 `reSort(SortRequest)` 拖拽排序 | +| `PageRequest` | 扩展 Spring Data PageRequest,内置 `RequestFilter` | +| `RequestFilter` | 过滤条件容器,管理多个 `Filter` | +| `Filter` | 单个过滤条件,包含 key、relation、value | +| `Relation` | 查询操作符枚举 | + +### Relation 操作符 + +``` +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 +``` + +### 注入方式 + +让实体 Repository 继承 `FastRepository` 即可: + +```java +public interface UserRepository extends FastRepository { +} +``` + +## 使用实例 + +### 1. 基础动态过滤查询(Example 模式) + +```java +// 创建分页请求并添加过滤条件 +PageRequest request = PageRequest.of(0, 20); +request.addFilter("name", "张三"); // 默认 EQUAL +request.addFilter("age", Relation.GREATER_THAN, 18); // 大于 +request.addFilter("email", Relation.LIKE, "gmail"); // 模糊匹配 + +Page page = userRepository.findAll(request); +``` + +### 2. HQL 动态查询 + +```java +PageRequest request = PageRequest.of(0, 20); +request.addFilter("status", Relation.IN, "ACTIVE", "PENDING"); +request.addFilter("createTime", Relation.BETWEEN, startDate, endDate); + +// pageRequest() 使用 HQL 构建查询,适合复杂条件 +Page page = userRepository.pageRequest(request); +``` + +### 3. AND/OR 组合条件 + +```java +PageRequest request = PageRequest.of(0, 20); + +// OR 组合:name='张三' OR name='李四' +request.orFilters( + Filter.as("name", "张三"), + Filter.as("name", "李四") +); + +// AND 组合 +request.andFilters( + Filter.as("age", Relation.GREATER_THAN_EQUAL, 18), + Filter.as("status", "ACTIVE") +); +``` + +### 4. 从请求中读取过滤值 + +```java +// 在 Service 层获取前端传入的过滤参数 +String name = request.getStringFilter("name", "default"); +int age = request.getIntFilter("age", 0); +boolean hasFilter = request.hasFilter(); +``` + +### 5. 原生 SQL 动态查询 + +```java +// 使用 DynamicRepository 的原生 SQL 查询 +List users = userRepository.dynamicListQuery( + "SELECT * FROM user WHERE status = ?", "ACTIVE" +); + +// 分页查询 +Page page = userRepository.dynamicPageQuery( + "SELECT * FROM user WHERE dept_id = ?", + "SELECT COUNT(*) FROM user WHERE dept_id = ?", + PageRequest.of(0, 20), + deptId +); +``` + +### 6. 拖拽排序 + +```java +// 实体需实现 ISort 接口 +SortRequest sortRequest = new SortRequest(List.of(3L, 1L, 2L)); +userRepository.reSort(sortRequest); +``` diff --git a/docs/capabilities/flow-postpone-event.md b/docs/capabilities/flow-postpone-event.md new file mode 100644 index 000000000..84d7d84dc --- /dev/null +++ b/docs/capabilities/flow-postpone-event.md @@ -0,0 +1,77 @@ +--- +name: flow-postpone-event +description: 延期流程操作的事件通知 — 当前 FlowPostponedService 未发送 FlowApprovalEvent +status: 计划中 +scope: 后端 +source: 计划 +--- + +## 解决什么问题 + +当前工作流引擎的所有操作(发起、提交、驳回、撤回、删除、作废、退回、完成、转办、催办、终止)都通过 `EventPusher` 发送 `FlowApprovalEvent`,业务侧可通过 `IHandler` 监听并联动处理。 + +**但延期(Postpone)操作是唯一的例外**:`FlowPostponedService.postponed()` 仅更新了 `FlowRecord` 的截止时间,没有发送任何事件。这导致: + +- 业务系统无法感知延期操作,无法触发提醒、日志、通知等联动 +- 与其他流程操作的契约不一致 — Handler 中 `isXxx()` 判断覆盖了所有操作,唯独缺少 `isPostponed()` +- 审计日志中延期操作无事件记录 + +## 如何使用 + +### 计划实现 + +1. 在 `FlowApprovalEvent` 中新增状态常量: + ```java + public static final int STATE_POSTPONED = 15; + ``` + +2. 新增便捷判断方法: + ```java + public boolean isPostponed() { + return state == STATE_POSTPONED; + } + ``` + +3. 在 `FlowPostponedService.postponed()` 末尾发送事件: + ```java + EventPusher.push(new FlowApprovalEvent( + FlowApprovalEvent.STATE_POSTPONED, + flowRecord, currentOperator, flowWork, bindData + )); + ``` + +4. 在 `event.md` 规范文档中将"延期流程"事件更新为 `POSTPONED` + +### 业务监听 + +```java +@Component +public class LeavePostponedHandler implements IHandler { + @Override + public void handler(FlowApprovalEvent event) { + if (event.isPostponed()) { + FlowRecord record = event.getFlowRecord(); + // 发送延期提醒通知给申请人 + notifyService.sendDelayNotice( + record.getCreateOperatorId(), + record.getPostponedTime() + ); + } + } +} +``` + +## 使用实例 + +```java +// 当前状态(无事件): +flowPostponedService.postponed(recordId, operator, delayMillis); +// → FlowRecord 截止时间已更新 +// → ❌ 无 FlowApprovalEvent 发出 + +// 实现后(有事件): +flowPostponedService.postponed(recordId, operator, delayMillis); +// → FlowRecord 截止时间已更新 +// → ✅ FlowApprovalEvent(STATE_POSTPONED) 通过 EventPusher 发出 +// → ✅ LeavePostponedHandler.handler() 被调用 +``` diff --git a/docs/capabilities/groovy-script-engine.md b/docs/capabilities/groovy-script-engine.md new file mode 100644 index 000000000..451c399e0 --- /dev/null +++ b/docs/capabilities/groovy-script-engine.md @@ -0,0 +1,133 @@ +--- +name: groovy-script-engine +description: Groovy 动态脚本引擎,支持运行时编译执行、LRU 缓存、热更新和 REST API +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: fbfb901b +code_files: + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScriptRuntime.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScriptRuntimeContext.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCacheContext.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/repository/GroovyScriptRepository.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/repository/TempGroovyScriptRepository.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/strategy/GroovyTypeFixStrategy.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/strategy/GroovyMetadataGenerateStrategy.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/controller/GroovyScriptController.java + - springboot-starter-script/src/main/java/com/codingapi/springboot/script/runner/GroovyScriptEngineRunner.java +--- + +## 解决什么问题 + +业务规则频繁变更时,硬编码需要重新编译部署。Groovy 脚本引擎提供运行时动态编译执行能力,让业务规则可以通过脚本形式热加载,无需重启应用。典型场景: + +- 动态表单校验规则 +- 工作流节点审批脚本 +- 数据转换/映射规则 +- 临时数据处理脚本 + +## 如何使用 + +### 核心运行时(GroovyScriptRuntime) + +```java +// 初始化运行时(指定 LRU 缓存最大容量) +GroovyScriptRuntime runtime = new GroovyScriptRuntime(100); + +// 编译脚本(带缓存 — 以 SHA256 哈希为 Key) +runtime.compile("def add(a, b) { return a + b }", true); + +// 编译脚本(不带缓存) +runtime.compile("println 'hello'", false); + +// 执行脚本 +Object result = runtime.execute("return 1 + 2"); + +// 执行脚本并绑定变量 +Map bindings = new HashMap<>(); +bindings.put("name", "张三"); +Object result = runtime.execute("return 'Hello, ' + name", bindings); +``` + +### LRU 缓存机制 + +- 缓存 Key 为脚本内容的 SHA256 哈希值 +- 采用 `LinkedHashMap` 实现 LRU 淘汰策略 +- 超过 `maxCacheSize` 时自动移除最久未使用的条目 +- 通过 `clearCache()` 手动清空缓存 +- 通过 `cacheSize()` 查看当前缓存数量 + +### 脚本仓库(GroovyScriptRepository) + +持久化存储脚本,支持 CRUD 和按名称查找: +```java +// 保存脚本 +repository.save("calc_discount", scriptContent); + +// 按名称执行 +Object result = runtime.executeByName("calc_discount", bindings); +``` + +### 临时脚本(TempGroovyScriptRepository) + +一次性执行的临时脚本,执行后自动清理: +```java +// 提交临时脚本 +tempRepo.submit("return data.collect { it.name }", dataBindings); +``` + +### 元数据扫描 + +通过注解扫描提取脚本元数据,用于前端展示: +```java +@GroovyScript +class DiscountCalc { + @ScriptFunction(description = "计算折扣") + static BigDecimal calc(@ScriptParameter("金额") BigDecimal amount) { + return amount.multiply(new BigDecimal("0.9")) + } +} +``` + +### 策略扩展点 + +- `GroovyMetadataGenerateStrategy` — 自定义元数据生成逻辑 +- `GroovyTypeFixStrategy` — 自定义类型元数据修正(如补充泛型信息) + +### REST API(GroovyScriptController) + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/open/script/save` | POST | 保存脚本 | +| `/open/script/compile` | POST | 编译检查(不执行) | +| `/open/script/run` | POST | 执行脚本 | +| `/open/script/list` | GET | 列出所有脚本 | + +## 使用实例 + +```java +// 1. 基本执行 +GroovyScriptRuntime runtime = new GroovyScriptRuntime(50); +Object result = runtime.execute("return [1,2,3].sum()"); +assert result.equals(6); + +// 2. 缓存模式 — 相同内容只编译一次 +String script = "def discount(amount) { return amount * 0.85 }"; +runtime.compile(script, true); // 首次编译并缓存 +Object r1 = runtime.execute(script); // 命中缓存 +Object r2 = runtime.execute(script); // 命中缓存 + +// 3. 绑定变量执行 +Map vars = new HashMap<>(); +vars.put("price", new BigDecimal("100")); +vars.put("quantity", 3); +Object total = runtime.execute( + "return price.multiply(new BigDecimal(quantity))", + vars +); +// total = 300 + +// 4. 清空缓存 +runtime.clearCache(); +assert runtime.cacheSize() == 0; +``` diff --git a/docs/capabilities/index.md b/docs/capabilities/index.md new file mode 100644 index 000000000..0a3e6bc19 --- /dev/null +++ b/docs/capabilities/index.md @@ -0,0 +1,40 @@ +# 能力(Capabilities) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +## ✅ 已实现 + +| 名称 | 描述 | 范围 | 来源 | +|------|------|------|------| +| [crypto-tools](./crypto-tools.md) | 密码学工具套件,AES(CBC 模式)/RSA(BouncyCastle)/DES 加解密 + SHA256/HmacSHA256 哈希签名,提供实例化工... | 后端 | 项目自有 | +| [data-authorization](./data-authorization.md) | 完整的数据权限框架,包含行级权限(WHERE/JOIN 条件注入)和列级权限(数据脱敏),基于 JSqlParser 的 SQL 增强引擎,提供 Colu... | 后端 | 项目自有 | +| [domain-change-interceptor](./domain-change-interceptor.md) | 通过 CGLIB 代理拦截领域实体字段的 setter 方法,自动检测变更并发布 DomainChangeEvent | 后端 | 项目自有 | +| [dynamic-application](./dynamic-application.md) | 支持外部 JAR 加载和 Spring 上下文热重启的应用启动器 | 后端 | 项目自有 | +| [dynamic-mvc-mapping](./dynamic-mvc-mapping.md) | 运行时动态注册/注销 Spring MVC REST 端点,支持将 Groovy 脚本绑定到动态 API 路径 | 后端 | 项目自有 | +| [event-system](./event-system.md) | 自建的领域事件发布-订阅系统,支持同步/异步事件、事务后提交、Handler自动注册排序 | 后端 | 项目自有 | +| [fast-repository](./fast-repository.md) | JPA Repository 增强,支持动态过滤查询(RequestFilter + Relation 操作符)、HQL 自动构建、排序 | 后端 | 项目自有 | +| [groovy-script-engine](./groovy-script-engine.md) | Groovy 动态脚本引擎,支持运行时编译执行、LRU 缓存、热更新和 REST API | 后端 | 项目自有 | +| [jackson](./jackson.md) | Jackson JSON 序列化/反序列化能力,由 Spring Boot Web 自动配置,框架中用于 REST API 响应序列化、请求参数绑定等场景 | 后端 | 框架:jackson | +| [jdbc-proxy-chain](./jdbc-proxy-chain.md) | 完整的 JDBC 装饰器代理链,透明拦截 SQL 执行以实现数据权限控制 | 后端 | 项目自有 | +| [locale-exception](./locale-exception.md) | 支持 i18n 的业务异常体系,errCode + errMessage,通过 MessageSource 实现国际化 | 后端 | 项目自有 | +| [rest-client](./rest-client.md) | 出站 HTTP 客户端框架,提供 HttpClient(底层 HTTP 封装)、RestClient(REST 高级客户端)、SessionClient(... | 后端 | 项目自有 | +| [spring-data-jpa](./spring-data-jpa.md) | Spring Data JPA 数据访问能力,提供 ORM 映射、Repository 抽象、分页查询等基础设施,框架在此基础上扩展了 FastRepos... | 后端 | 框架:spring-data-jpa | +| [spring-data-redis](./spring-data-redis.md) | Spring Data Redis 数据存储能力,框架中主要用于 Token/Session 的有状态存储,支持服务端主动失效和多端登录管理 | 后端 | 框架:spring-data-redis | +| [spring-framework](./spring-framework.md) | Spring Framework / Spring Boot 核心基础能力,提供 IoC 容器、事件发布、AOP、Web MVC、事务管理等基础设施 | 后端 | 框架:spring-framework | +| [spring-security](./spring-security.md) | Spring Security 安全认证与授权能力,框架在此基础上封装了 JWT 无状态认证和 Redis 有状态认证两种模式 | 后端 | 框架:spring-security | +| [sql-interceptor](./sql-interceptor.md) | 基于 JSqlParser 的 SQL 解析改写拦截器,在 SQL 执行前透明注入数据权限条件 | 后端 | 项目自有 | +| [token-gateway](./token-gateway.md) | JWT/Redis 双模式 Token 认证网关,统一创建、解析和管理认证令牌 | 后端 | 项目自有 | +| [transaction-manager-context](./transaction-manager-context.md) | 单例编程式事务上下文,支持 REQUIRES_NEW 语义,在非 Spring 管理上下文中也能使用事务 | 后端 | 项目自有 | +| [trigger-system](./trigger-system.md) | 触发器框架,支持定时/条件触发,提供统一的触发上下文管理 | 后端 | 项目自有 | +| [user-context](./user-context.md) | 线程级用户身份上下文,通过 ThreadLocal 持有当前登录用户,支持跨层传递 | 后端 | 项目自有 | +| [workflow-engine](./workflow-engine.md) | 工作流引擎,支持流程定义、节点流转、审批、委托、会签、数据快照和事件通知 | 后端 | 项目自有 | + +## 🗓️ 计划中 + +| 名称 | 描述 | 范围 | 来源 | +|------|------|------|------| +| [flow-postpone-event](./flow-postpone-event.md) | 延期流程操作的事件通知 — 当前 FlowPostponedService 未发送 FlowApprovalEvent | 后端 | 计划 | + +--- + +**统计**: 共 23 篇 — 已实现 22 / 计划中 1 / 已废弃 0 diff --git a/docs/capabilities/jackson.md b/docs/capabilities/jackson.md new file mode 100644 index 000000000..5170e6ccc --- /dev/null +++ b/docs/capabilities/jackson.md @@ -0,0 +1,122 @@ +--- +name: jackson +description: Jackson JSON 序列化/反序列化能力,由 Spring Boot Web 自动配置,框架中用于 REST API 响应序列化、请求参数绑定等场景 +status: 已实现 +scope: 后端 +source: 框架:jackson +framework_version: Managed by Spring Boot BOM (3.3.5) +--- + +## 解决什么问题 + +Jackson 是 Spring Boot Web 默认的 JSON 处理库,在框架中承担以下职责: + +- **REST API 响应序列化**:所有 Controller 返回的 `Response` / `SingleResponse` / `MultiResponse` / `MapResponse` 对象均通过 Jackson 自动序列化为 JSON 输出 +- **请求参数反序列化**:`@RequestBody` 注解的请求体自动由 Jackson 反序列化为 Java 对象,包括 `PageRequest` 中的过滤条件 +- **枚举序列化**:框架提供了自定义 `EnumSerializer`,统一枚举值的 JSON 输出格式 +- **日期/时间格式化**:通过 Spring Boot 的 `spring.jackson.*` 配置项统一日期格式 +- **字段命名策略**:支持驼峰/下划线等命名转换,前后端字段名对齐 + +> **注意**:框架内部部分场景(如 Token 序列化、事件数据传输)使用了 Fastjson(`com.alibaba.fastjson.JSONObject`)作为补充,通过 `JsonSerializable` 接口封装。Jackson 主要负责 HTTP 层的 JSON 处理。 + +## 如何使用 + +### 依赖引入 + +Jackson 由 `spring-boot-starter-web` 自动引入,无需单独声明: + +```xml + + + com.codingapi.springboot + springboot-starter + +``` + +### 全局配置 + +在 `application.properties` 中统一配置 Jackson 行为: + +```properties +# 日期格式 +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 + +# 序列化选项 +spring.jackson.default-property-inclusion=non_null +spring.jackson.serialization.write-dates-as-timestamps=false + +# 反序列化选项 +spring.jackson.deserialization.fail-on-unknown-properties=false +``` + +### 自定义序列化器 + +框架已提供 `EnumSerializer`,可按需注册更多自定义序列化器: + +```java +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addSerializer(MyEnum.class, new EnumSerializer()); + mapper.registerModule(module); + return mapper; + } +} +``` + +## 使用实例 + +### REST API 响应(自动序列化) + +```java +@RestController +@RequestMapping("/api/users") +@AllArgsConstructor +public class UserController { + + private final UserQueryService userQueryService; + + // Jackson 自动将 SingleResponse 序列化为 JSON + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + return SingleResponse.of(userQueryService.findById(id)); + } + + // 分页响应包含 total、content 等字段 + @GetMapping + public MultiResponse list(PageRequest request) { + Page page = userQueryService.findAll(request); + return MultiResponse.of(page.getContent(), page.getTotalElements()); + } +} +``` + +输出示例: + +```json +{ + "success": true, + "errCode": null, + "errMessage": null, + "data": [ + { "id": 1, "name": "张三", "age": 28 } + ], + "total": 1 +} +``` + +### 请求体反序列化 + +```java +@PostMapping +public Response createUser(@RequestBody CreateUserCmd cmd) { + // Jackson 自动将 JSON 请求体反序列化为 CreateUserCmd + userService.create(cmd); + return Response.buildSuccess(); +} +``` diff --git a/docs/capabilities/jdbc-proxy-chain.md b/docs/capabilities/jdbc-proxy-chain.md new file mode 100644 index 000000000..9c21d2f7a --- /dev/null +++ b/docs/capabilities/jdbc-proxy-chain.md @@ -0,0 +1,95 @@ +--- +name: jdbc-proxy-chain +description: 完整的 JDBC 装饰器代理链,透明拦截 SQL 执行以实现数据权限控制 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 67895fda +code_files: + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/jdbc/proxy/ConnectionProxy.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/jdbc/proxy/StatementProxy.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/jdbc/proxy/PreparedStatementProxy.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/jdbc/proxy/CallableStatementProxy.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/jdbc/proxy/ResultSetProxy.java +--- + +## 解决什么问题 + +数据权限要求在不修改业务 SQL 的前提下,透明地注入行级/列级过滤条件。手动在每个 Repository 中拼接权限条件会导致: +- 业务代码与权限逻辑强耦合 +- 遗漏某个查询导致数据泄露 +- 难以统一管理和审计权限规则 + +JDBC 代理链通过装饰器模式包装整个 JDBC 调用链,在 SQL 执行前自动拦截和改写,对业务代码完全透明。 + +## 如何使用 + +### 代理链架构 + +``` +ConnectionProxy(包装 DataSource 返回的 Connection) + │ + ├── createStatement() → StatementProxy + │ └── executeQuery(sql) → SQLRunningContext.intercept(sql) → ResultSetProxy + │ + ├── prepareStatement(sql) → SQLRunningContext.intercept(sql) → PreparedStatementProxy + │ └── executeQuery() → ResultSetProxy + │ + └── prepareCall(sql) → SQLRunningContext.intercept(sql) → CallableStatementProxy + └── executeQuery() → ResultSetProxy +``` + +### 各代理职责 + +| 代理类 | 职责 | +|--------|------| +| `ConnectionProxy` | 拦截 `prepareStatement`/`prepareCall`,在 SQL 创建时调用 `SQLRunningContext.intercept()` | +| `StatementProxy` | 拦截 `executeQuery`/`executeUpdate`,动态改写 SQL 并包装返回值 | +| `PreparedStatementProxy` | 包装已拦截的 PreparedStatement,对返回的 ResultSet 应用列级权限 | +| `CallableStatementProxy` | 委托底层 CallableStatement,应用授权上下文 | +| `ResultSetProxy` | 拦截列级数据读取,通过 `ColumnHandlerContext` 对字段值进行脱敏或过滤 | + +### 拦截流程 + +1. `ConnectionProxy.prepareStatement(sql)` 调用 `SQLRunningContext.getInstance().intercept(sql)` +2. `SQLRunningContext` 返回 `SQLExecuteState`(包含改写后的 SQL 和拦截状态) +3. 改写后的 SQL 传给底层 `Connection.prepareStatement()` +4. 返回的 `ResultSet` 被 `ResultSetProxy` 包装 +5. 读取每列时,`ColumnHandlerContext` 根据表名和列名决定是否脱敏 + +### 自动装配 + +代理链通过 `DataAuthorizationConfiguration` 自动注入到 DataSource 中,业务代码无需感知: + +```java +// 框架自动将 DataSource.getConnection() 替换为 ConnectionProxy +// 所有 JPA/JDBC 查询自动走代理链 +``` + +## 使用实例 + +```java +// 自定义 SQL 拦截器 — 为特定表注入租户条件 +public class TenantSQLInterceptor implements SQLInterceptor { + @Override + public SQLExecuteState intercept(String sql) { + if (sql.contains("FROM orders")) { + String tenantId = UserContext.getCurrentUser().getTenantId(); + String modifiedSql = sql + " AND tenant_id = '" + tenantId + "'"; + return new SQLExecuteState(modifiedSql, true); + } + return new SQLExecuteState(sql, false); + } +} + +// 自定义列级处理器 — 对手机号脱敏 +public class PhoneColumnHandler implements ColumnHandler { + @Override + public String handle(String tableName, String columnName, String value) { + if ("user".equals(tableName) && "phone".equals(columnName)) { + return value.substring(0, 3) + "****" + value.substring(7); + } + return value; + } +} +``` diff --git a/docs/capabilities/locale-exception.md b/docs/capabilities/locale-exception.md new file mode 100644 index 000000000..ee843ff5d --- /dev/null +++ b/docs/capabilities/locale-exception.md @@ -0,0 +1,156 @@ +--- +name: locale-exception +description: 支持 i18n 的业务异常体系,errCode + errMessage,通过 MessageSource 实现国际化 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 0c4299a1 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/LocaleMessageException.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/LocaleMessage.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/MessageContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/ExceptionConfiguration.java +--- + +## 解决什么问题 + +在企业级应用中,业务异常需要同时满足以下需求: + +1. **结构化错误信息**:异常需携带机器可读的错误码(errCode)和人类可读的错误消息(errMessage),便于前端根据 errCode 做差异化处理 +2. **国际化支持**:同一错误码在不同语言环境下返回对应语言的错误消息,而非硬编码中文字符串 +3. **参数化消息**:错误消息支持占位符(如 `{0}`、`{1}`),运行时动态填充具体值 +4. **与统一响应对接**:异常的 errCode/errMessage 可直接映射到框架的 `Response` DTO,由全局异常处理器统一返回 + +本能力基于 Spring `MessageSource` 构建了国际化异常体系,开发者只需在 `messages.properties` 中定义错误码与消息的映射关系,代码中通过错误码抛出异常即可自动解析当前 Locale 对应的消息。 + +## 如何使用 + +### 核心组件 + +#### LocaleMessageException — 国际化业务异常 + +```java +public class LocaleMessageException extends RuntimeException { + String getErrCode() // 错误码 + String getErrMessage() // 解析后的错误消息(当前 Locale) +} +``` + +提供多种构造方式: + +| 构造函数 | 说明 | +|---------|------| +| `LocaleMessageException(errCode)` | 仅错误码,从 MessageSource 解析消息 | +| `LocaleMessageException(errCode, args)` | 错误码 + 占位符参数 | +| `LocaleMessageException(errCode, errMessage)` | 错误码 + 自定义消息(不走 i18n) | +| `LocaleMessageException(errCode, args, cause)` | 错误码 + 参数 + 原始异常 | +| `LocaleMessageException.of(errCode, args...)` | 静态工厂方法(推荐) | + +#### MessageContext — 消息上下文(单例) + +内部持有 `LocaleMessage` 实例,提供 `getErrorMsg(errCode)` 和 `getErrorMsg(errCode, args)` 方法。`LocaleMessageException` 的无消息构造函数通过此上下文获取国际化消息。 + +#### LocaleMessage — 消息解析器 + +封装 Spring `MessageSource`,根据 `LocaleContextHolder.getLocale()` 自动获取当前请求的语言环境并解析消息。 + +### 自动配置 + +`ExceptionConfiguration` 自动注册 `LocaleMessage` Bean: + +```java +@Configuration +public class ExceptionConfiguration { + @Bean(initMethod = "init") + public LocaleMessage exceptionLocaleMessage(MessageSource messageSource) { + return new LocaleMessage(messageSource); + } +} +``` + +`init()` 方法将 `LocaleMessage` 注入到 `MessageContext` 单例中。只要 Spring 容器中存在 `MessageSource`(通常由 `messages.properties` 自动配置),即可直接使用。 + +### 消息资源文件 + +在 `src/main/resources/` 下创建消息资源文件: + +```properties +# messages.properties (默认/中文) +user.not.found=用户不存在 +order.amount.exceeded=订单金额超出限制,最大金额为 {0} 元 +account.locked=账户 {0} 已被锁定,请联系管理员 + +# messages_en.properties (英文) +user.not.found=User not found +order.amount.exceeded=Order amount exceeded, maximum is {0} yuan +account.locked=Account {0} has been locked, please contact administrator +``` + +## 使用实例 + +### 1. 基本用法 — 仅错误码 + +```java +// 从 messages.properties 中解析 "user.not.found" 对应的消息 +throw new LocaleMessageException("user.not.found"); +// 中文环境 → errMessage: "用户不存在" +// 英文环境 → errMessage: "User not found" +``` + +### 2. 带参数的消息 + +```java +// 使用静态工厂方法(推荐) +throw LocaleMessageException.of("order.amount.exceeded", 10000); +// → errMessage: "订单金额超出限制,最大金额为 10000 元" + +// 或使用构造函数 +throw new LocaleMessageException("account.locked", new Object[]{"admin@example.com"}); +// → errMessage: "账户 admin@example.com 已被锁定,请联系管理员" +``` + +### 3. 携带原始异常 + +```java +try { + userRepository.findById(userId); +} catch (DataAccessException e) { + throw new LocaleMessageException("user.not.found", e); +} +``` + +### 4. 自定义消息(跳过 i18n) + +```java +// 直接指定消息文本,不从 MessageSource 解析 +throw new LocaleMessageException("custom.error", "这是一条自定义错误消息"); +``` + +### 5. 与全局异常处理器配合 + +在全局异常处理器中捕获 `LocaleMessageException`,提取 errCode 和 errMessage 构建统一响应: + +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(LocaleMessageException.class) + public Response handleLocaleException(LocaleMessageException e) { + return Response.buildFailure(e.getErrCode(), e.getErrMessage()); + } +} +``` + +返回的 JSON 响应: + +```json +{ + "success": false, + "errCode": "order.amount.exceeded", + "errMessage": "订单金额超出限制,最大金额为 10000 元" +} +``` + +### 6. Locale 切换 + +框架通过 `LocaleContextHolder` 获取当前 Locale,与 Spring MVC 的 `Accept-Language` 请求头或自定义 LocaleResolver 集成。无需额外配置,客户端发送不同的 `Accept-Language` 头即可获得对应语言的错误消息。 diff --git a/docs/capabilities/rest-client.md b/docs/capabilities/rest-client.md new file mode 100644 index 000000000..986f5412a --- /dev/null +++ b/docs/capabilities/rest-client.md @@ -0,0 +1,226 @@ +--- +name: rest-client +description: 出站 HTTP 客户端框架,提供 HttpClient(底层 HTTP 封装)、RestClient(REST 高级客户端)、SessionClient(Cookie/Session 感知)、HttpRequest(请求构建器 + 代理支持) +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: dc5e04ef +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/HttpClient.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/RestClient.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/SessionClient.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/HttpRequest.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/Request.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/RestTemplateContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/param/IRestParam.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/param/RestParam.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/properties/HttpProxyProperties.java +--- + +## 解决什么问题 + +在微服务架构中,服务间调用、第三方 API 对接、爬虫数据采集等场景需要频繁发起出站 HTTP 请求。直接使用 Spring `RestTemplate` 存在以下痛点: + +- **缺少重试机制**:网络抖动时直接抛异常,需要手动包装重试逻辑 +- **Session/Cookie 管理繁琐**:模拟登录等有状态交互需手动维护 Cookie +- **代理配置分散**:HTTP 代理设置需要在每处创建 RestTemplate 时重复配置 +- **参数构建不便**:GET 查询参数和 POST 表单/JSON 的参数组装代码冗长 + +本能力提供三层 HTTP 客户端抽象: + +- **HttpClient**:轻量级底层封装,适合单次无状态调用 +- **RestClient**:REST 高级客户端,内置 baseUrl 拼接、自动重试、默认 JSON Content-Type +- **SessionClient**:Cookie/Session 感知客户端,自动跟踪 Set-Cookie 响应头并回传,支持 302 重定向跟随 + +所有客户端共享 `RestTemplateContext` 单例(基于 Apache HttpComponents,信任所有证书),统一支持 HTTP 代理配置。 + +## 如何使用 + +### 核心类层次 + +``` +RestTemplateContext (单例, Apache HttpComponents) +└── HttpRequest (请求构建器, 代理支持, 拦截器钩子) + ├── HttpClient (基础 GET/POST) + ├── RestClient (baseUrl + 重试 + REST 风格) + └── SessionClient (Cookie 跟踪 + 重定向) +``` + +### HttpRequest — 请求构建器 + +底层请求引擎,封装 `RestTemplate.exchange()` 调用,提供两个扩展点: + +```java +// 请求拦截器:可修改 URL、Header、Body +interface IHttpRequestHandler { + String handler(HttpRequest client, String uri, HttpMethod method, + HttpHeaders headers, HttpEntity httpEntity); +} + +// 响应拦截器:可处理状态码、重定向、错误 +interface IHttpResponseHandler { + String handler(HttpRequest client, String uri, ResponseEntity response); +} +``` + +默认行为:请求拦截器透传 URL;响应拦截器处理 200/404 返回 body,302 自动跟随重定向。连接超时固定为 3000ms。 + +### HttpClient — 基础客户端 + +```java +// 无代理 +HttpClient client = new HttpClient(); + +// 带代理 +HttpProxyProperties proxy = new HttpProxyProperties(); +proxy.setEnableProxy(true); +proxy.setProxyHost("proxy.example.com"); +proxy.setProxyPort(8080); +proxy.setProxyType(Proxy.Type.HTTP); +HttpClient client = new HttpClient(proxy); + +// 自定义请求/响应处理器 +HttpClient client = new HttpClient(proxy, requestHandler, responseHandler); +``` + +提供三个便捷方法: +- `post(url, headers, JSON)` — POST JSON 请求 +- `post(url, headers, MultiValueMap)` — POST 表单请求 +- `get(url, headers, uriVariables)` — GET 请求 + +### RestClient — REST 高级客户端 + +```java +// 最简构造:指定 baseUrl,默认重试 5 次,空响应 "{}" +RestClient rest = new RestClient("https://api.example.com"); + +// 完整构造 +RestClient rest = new RestClient(proxyProperties, baseUrl, retryCount, + emptyResponse, requestHandler, responseHandler); +``` + +特性: +- **baseUrl 自动拼接**:`rest.get("/users")` → `https://api.example.com/users` +- **自动重试**:请求失败时按 `retryCount` 重试,每次间隔 1 秒 +- **默认 JSON**:初始化时自动设置 `Content-Type: application/json` +- **优雅降级**:重试耗尽后返回 `emptyResponse` 而非抛异常 + +### SessionClient — Session 感知客户端 + +```java +SessionClient session = new SessionClient(); // 无代理 +SessionClient session = new SessionClient(proxyProperties); // 带代理 + +// 链式添加 Header +session.addHeader("Authorization", "Bearer xxx"); + +// 表单提交(自动设置 APPLICATION_FORM_URLENCODED) +session.postForm("https://example.com/login", restParam); + +// JSON 提交 +session.postJson("https://example.com/api/data", restParam); + +// GET 请求 +session.getJson("https://example.com/api/users"); +session.getHtml("https://example.com/page"); +``` + +内部通过自定义 `IHttpResponseHandler` 自动将响应中的 `Set-Cookie` 转为后续请求的 `Cookie` 头,并在遇到 302 时自动跟随重定向。 + +### RestParam — 统一参数构建器 + +```java +// 手动构建 +RestParam param = RestParam.create() + .add("username", "admin") + .add("password", "secret"); + +// 从对象解析 +RestParam param = RestParam.parser(myObject); + +// 转换为不同格式 +JSONObject json = param.toJsonRequest(); // POST JSON +MultiValueMap form = param.toFormRequest(); // POST 表单 +MultiValueMap query = param.toGetRequest(); // GET 参数 +``` + +实现 `IRestParam` 接口的 POJO 可直接调用 `toParameters()` 转为 `RestParam`。 + +### HttpProxyProperties — 代理配置 + +```java +@Setter @Getter +public class HttpProxyProperties { + private boolean enableProxy; + private String proxyHost; + private int proxyPort; + private Proxy.Type proxyType; +} +``` + +## 使用实例 + +### 1. 调用第三方 REST API(带重试) + +```java +RestClient api = new RestClient("https://api.third-party.com"); + +// GET 请求 +String users = api.get("/v1/users"); + +// POST JSON 请求 +RestParam param = RestParam.create() + .add("name", "张三") + .add("age", 25); +String result = api.post("/v1/users", param); + +// 带自定义 Header +HttpHeaders headers = new HttpHeaders(); +headers.set("X-API-Key", "my-key"); +String data = api.get("/v1/data", headers); +``` + +### 2. 模拟登录并保持会话 + +```java +SessionClient session = new SessionClient(); + +// 登录(表单提交,自动保存 Cookie) +RestParam loginParam = RestParam.create() + .add("username", "admin") + .add("password", "password123"); +session.postForm("https://app.example.com/login", loginParam); + +// 后续请求自动携带 Cookie +String dashboard = session.getHtml("https://app.example.com/dashboard"); +String userInfo = session.getJson("https://app.example.com/api/user/info"); +``` + +### 3. 通过代理访问外部服务 + +```java +HttpProxyProperties proxy = new HttpProxyProperties(); +proxy.setEnableProxy(true); +proxy.setProxyHost("proxy.corp.com"); +proxy.setProxyPort(3128); +proxy.setProxyType(Proxy.Type.HTTP); + +RestClient client = new RestClient(proxy, "https://external-api.com", + 3, "{}", null, null); +String response = client.get("/data"); +``` + +### 4. 自定义请求/响应拦截 + +```java +// 请求签名拦截器 +HttpRequest.IHttpRequestHandler signer = (client, uri, method, headers, entity) -> { + String timestamp = String.valueOf(System.currentTimeMillis()); + headers.set("X-Timestamp", timestamp); + headers.set("X-Signature", HmacSHA256.sign(uri + timestamp, secretKey)); + return uri; +}; + +HttpClient httpClient = new HttpClient(signer, null); +String result = httpClient.get("https://secure-api.com/data", headers, null); +``` diff --git a/docs/capabilities/spring-data-jpa.md b/docs/capabilities/spring-data-jpa.md new file mode 100644 index 000000000..9463ee2c3 --- /dev/null +++ b/docs/capabilities/spring-data-jpa.md @@ -0,0 +1,106 @@ +--- +name: spring-data-jpa +description: Spring Data JPA 数据访问能力,提供 ORM 映射、Repository 抽象、分页查询等基础设施,框架在此基础上扩展了 FastRepository 动态查询 +status: 已实现 +scope: 后端 +source: 框架:spring-data-jpa +framework_version: Managed by Spring Boot BOM (3.3.5) +--- + +## 解决什么问题 + +Spring Data JPA 是框架数据持久层的基石,解决了以下问题: + +- **ORM 映射**:通过 JPA 注解将领域实体映射到关系数据库表,框架中所有领域对象(如 `FlowWork`、`FlowRecord`、`FlowNode` 等)均基于 JPA Entity 定义 +- **Repository 抽象**:提供 `JpaRepository` 接口,自动生成 CRUD 实现,消除样板代码 +- **分页查询**:原生支持 `Pageable` / `Page` 分页抽象,框架在此基础上扩展了 `PageRequest` + `RequestFilter` 动态过滤能力 + +框架的 `springboot-starter-data-fast` 模块在 Spring Data JPA 之上构建了 `FastRepository`,支持基于 Filter 的动态 Example 查询和 HQL 构建,大幅简化复杂条件查询的开发。 + +## 如何使用 + +### 依赖引入 + +```xml + + com.codingapi.springboot + springboot-starter-data-fast + +``` + +该模块已传递依赖 `spring-boot-starter-data-jpa`,无需单独引入。 + +### Repository 定义 + +继承 `FastRepository` 而非原生的 `JpaRepository`,即可获得动态过滤查询能力: + +```java +public interface UserRepository extends FastRepository { + // 自动拥有 findAll(PageRequest)、pageRequest(PageRequest)、searchRequest(SearchRequest) 等方法 +} +``` + +### 动态过滤查询 + +`PageRequest` 扩展了 Spring Data 的 `org.springframework.data.domain.PageRequest`,增加了 `RequestFilter`: + +```java +PageRequest request = PageRequest.of(0, 20); +request.addFilter("name", "张三"); +request.addFilter("age", Relation.GT, 18); +Page page = userRepository.findAll(request); +``` + +当 Filter 存在时,`findAll` 自动构建 Example 查询;`pageRequest` 方法则构建 HQL 动态 SQL,支持更复杂的关联查询。 + +## 使用实例 + +### 基础 CRUD + 动态过滤 + +```java +@Service +@AllArgsConstructor +public class UserQueryService { + + private final UserRepository userRepository; + + // 简单分页(无过滤) + public Page listUsers(int page, int size) { + return userRepository.findAll(PageRequest.of(page, size)); + } + + // 带动态过滤的分页查询 + public Page searchUsers(String name, Integer minAge) { + PageRequest request = PageRequest.of(0, 20); + if (name != null) { + request.addFilter("name", name); + } + if (minAge != null) { + request.addFilter("age", Relation.GTE, minAge); + } + return userRepository.findAll(request); + } + + // 使用 HQL 动态查询(支持关联字段、OR 条件等) + public Page advancedSearch(SearchRequest searchRequest) { + return userRepository.searchRequest(searchRequest); + } +} +``` + +### JPA Entity 定义 + +```java +@Entity +@Table(name = "t_user") +@Getter @Setter +public class UserEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private Integer age; + private String email; +} +``` diff --git a/docs/capabilities/spring-data-redis.md b/docs/capabilities/spring-data-redis.md new file mode 100644 index 000000000..bc77f3d0c --- /dev/null +++ b/docs/capabilities/spring-data-redis.md @@ -0,0 +1,105 @@ +--- +name: spring-data-redis +description: Spring Data Redis 数据存储能力,框架中主要用于 Token/Session 的有状态存储,支持服务端主动失效和多端登录管理 +status: 已实现 +scope: 后端 +source: 框架:spring-data-redis +framework_version: Managed by Spring Boot BOM (3.3.5) +--- + +## 解决什么问题 + +Spring Data Redis 在框架中承担了有状态认证场景下的核心存储职责,解决了以下问题: + +- **Token 服务端存储**:`RedisTokenGateway` 使用 `RedisTemplate` 将 Token 以 JSON 格式存入 Redis,Key 格式为 `{encodedUsername}:{uuid}`,支持按用户名批量查询和删除 +- **Token 生命周期管理**:通过 Redis TTL 机制自动过期,同时支持服务端主动调用 `removeToken()` / `resetToken()` 实现即时失效和续期 +- **多端登录管理**:`getTokensByUsername()` 获取用户所有在线 Token,`removeUsername()` 一键踢出所有设备,还支持按条件(`Predicate`)选择性踢出 +- **缓存抽象**:可作为通用缓存层,用于脚本编译结果缓存、会话数据暂存等场景 + +## 如何使用 + +### 依赖引入 + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +`spring-boot-starter-data-redis` 作为 `provided` 依赖包含在安全模块中,启用 Redis 认证模式时需确保运行时存在 Redis 连接配置。 + +### 配置 Redis 连接 + +```properties +# Redis 连接配置 +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.data.redis.password=your-password + +# 启用 Redis 有状态认证 +codingapi.security.redis.enable=true +codingapi.security.redis.valid-time=86400000 # Token 有效期(毫秒) +codingapi.security.redis.rest-time=3600000 # 刷新提醒时间(毫秒) +``` + +### RedisTokenGateway 核心 API + +框架封装了 `RedisTokenGateway`,提供面向业务的 Token 操作方法: + +| 方法 | 说明 | +|------|------| +| `create(username, iv, authorities, extra)` | 创建 Token 并存入 Redis | +| `getToken(token)` | 根据 Token 字符串获取用户信息 | +| `removeToken(token)` | 删除指定 Token | +| `resetToken(token)` | 重置 Token 有效期 | +| `removeUsername(username)` | 删除用户所有 Token(踢出所有设备) | +| `getTokensByUsername(username)` | 获取用户所有在线 Token Key | +| `removeUsername(username, predicate)` | 按条件选择性删除 Token | + +## 使用实例 + +### 管理员强制下线用户 + +```java +@Service +@AllArgsConstructor +public class UserAdminService { + + private final RedisTokenGateway redisTokenGateway; + + /** + * 强制下线指定用户的所有设备 + */ + public void forceLogout(String username) { + redisTokenGateway.removeUsername(username); + } + + /** + * 选择性下线:仅移除特定设备的 Token + */ + public void logoutDevice(String username, String targetToken) { + redisTokenGateway.removeUsername(username, token -> + targetToken.equals(token.getToken()) + ); + } + + /** + * 查看用户在线设备数 + */ + public int getOnlineDeviceCount(String username) { + return redisTokenGateway.getTokensByUsername(username).size(); + } +} +``` + +### Token 续期 + +```java +// 在认证过滤器中检测 Token 是否需要续期 +Token token = tokenGateway.parser(tokenString); +if (token != null && token.canRestToken()) { + // canRestToken() = !isExpire() && remindTime <= currentTimeMillis + redisTokenGateway.resetToken(token); +} +``` diff --git a/docs/capabilities/spring-framework.md b/docs/capabilities/spring-framework.md new file mode 100644 index 000000000..566c8e834 --- /dev/null +++ b/docs/capabilities/spring-framework.md @@ -0,0 +1,110 @@ +--- +name: spring-framework +description: Spring Framework / Spring Boot 核心基础能力,提供 IoC 容器、事件发布、AOP、Web MVC、事务管理等基础设施 +status: 已实现 +scope: 后端 +source: 框架:spring-framework +framework_version: 3.3.5 +--- + +## 解决什么问题 + +Spring Framework / Spring Boot 是整个框架的运行时基座,解决了以下核心问题: + +- **IoC 容器**:管理所有 Bean 的生命周期与依赖注入,框架中所有 Starter 模块的自动配置均基于 Spring Boot AutoConfiguration 机制(`spring.factories` + `AutoConfiguration.imports`) +- **事件发布(ApplicationEventPublisher)**:框架自建的事件系统(`DomainEvent`、`IHandler`)底层通过 Spring 的 `@EventListener` 桥接,由 `SpringDefaultEventHandler` / `SpringTransactionEventHandler` 订阅 `DomainEvent` 并分发到业务 Handler +- **AOP**:支持声明式事务(`@Transactional`)、数据权限 SQL 拦截等切面能力 +- **Web MVC**:统一 REST API 响应封装(`Response` / `SingleResponse` / `MultiResponse`)、全局异常处理、参数绑定 +- **事务管理**:工作流引擎、领域事件持久化等场景均依赖 Spring 声明式事务 + +## 如何使用 + +### 依赖引入 + +框架根 POM 继承自 `spring-boot-starter-parent:3.3.5`,所有子模块自动获得 Spring Boot 依赖管理。 + +```xml + + + com.codingapi.springboot + springboot-starter + +``` + +### 事件系统桥接 + +框架通过 `SpringEventConfiguration` 注册 `SpringEventInitializer`,在容器启动时将 `ApplicationContext` 注入 `DomainEventContext`,使 `EventPusher.push()` 能够发布 Spring `DomainEvent`: + +```java +// 框架内部自动配置,无需手动操作 +@Bean +public SpringEventInitializer springEventInitializer(ApplicationContext context) { + return new SpringEventInitializer(context); +} +``` + +业务侧只需实现 `IHandler` 接口即可订阅事件,Handler 由 `HandlerBeanDefinitionRegistrar` 自动扫描注册。 + +### 事务管理 + +使用标准 Spring `@Transactional` 注解: + +```java +@Transactional +public void startFlow(...) { ... } +``` + +可通过 `codingapi.framework.event.transaction.enable=true` 启用事务内事件处理模式(`SpringTransactionEventHandler`),确保事件在事务提交后触发。 + +## 使用实例 + +### 定义并订阅领域事件 + +```java +// 1. 定义事件 +public class OrderCreatedEvent implements ISyncEvent { + private final String orderId; + // constructor, getters... +} + +// 2. 实现处理器(自动注册为 Spring Bean) +@Component +public class OrderCreatedHandler implements IHandler { + @Override + public int order() { return 0; } + + @Override + public void handler(OrderCreatedEvent event) { + // 处理订单创建逻辑 + } + + @Override + public void error(OrderCreatedEvent event, Exception exception) { + // 异常回调 + } +} + +// 3. 发布事件 +EventPusher.push(new OrderCreatedEvent("ORD-001"), true); +``` + +### 统一响应封装 + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + @GetMapping("/{id}") + public SingleResponse getUser(@PathVariable Long id) { + UserDTO user = userService.findById(id); + return SingleResponse.of(user); + } + + @GetMapping + public MultiResponse listUsers(PageRequest request) { + Page page = userService.findAll(request); + return MultiResponse.of(page.getContent(), page.getTotalElements()); + } +} +``` diff --git a/docs/capabilities/spring-security.md b/docs/capabilities/spring-security.md new file mode 100644 index 000000000..407c72f47 --- /dev/null +++ b/docs/capabilities/spring-security.md @@ -0,0 +1,109 @@ +--- +name: spring-security +description: Spring Security 安全认证与授权能力,框架在此基础上封装了 JWT 无状态认证和 Redis 有状态认证两种模式 +status: 已实现 +scope: 后端 +source: 框架:spring-security +framework_version: Managed by Spring Boot BOM (3.3.5) +--- + +## 解决什么问题 + +Spring Security 为框架提供了完整的安全基础设施,解决了以下问题: + +- **认证(Authentication)**:框架的 `springboot-starter-security` 模块基于 Spring Security 过滤器链,实现了两种 Token 认证模式: + - **JWT 无状态模式**:通过 `JwtTokenGateway` + JJWT 库签发/验证 JWT Token,适用于分布式部署 + - **Redis 有状态模式**:通过 `RedisTokenGateway` + `RedisTemplate` 将 Token 存储在 Redis 中,支持服务端主动失效、单点登录等场景 +- **授权(Authorization)**:基于 Spring Security 的权限模型,`Token.authorities` 携带用户角色/权限列表,通过 `UsernamePasswordAuthenticationToken` 注入 SecurityContext +- **CSRF 防护**:REST API 场景下默认禁用 CSRF(无状态 Token 认证不需要),表单场景可按需启用 +- **统一安全过滤链**:`MyAuthenticationFilter` 拦截请求解析 Token,`MyLoginFilter` 处理登录请求生成 Token,`SecurityLoginHandler` 统一登录成功/失败响应 + +## 如何使用 + +### 依赖引入 + +```xml + + com.codingapi.springboot + springboot-starter-security + +``` + +### 配置认证模式 + +在 `application.properties` 中选择认证方式: + +```properties +# JWT 无状态认证 +codingapi.security.jwt.enable=true +codingapi.security.jwt.secret-key=your-secret-key-at-least-256-bits +codingapi.security.jwt.valid-time=86400000 # Token 有效期(毫秒) +codingapi.security.jwt.rest-time=3600000 # Token 刷新提醒时间(毫秒) + +# Redis 有状态认证(二选一) +codingapi.security.redis.enable=true +codingapi.security.redis.valid-time=86400000 +codingapi.security.redis.rest-time=3600000 + +# 免认证 URL 列表 +codingapi.security.ignore-urls=/open/**,/#/**,/api/login +``` + +### TokenGateway 接口 + +两种模式均实现统一的 `TokenGateway` 接口,业务代码无需关心底层存储: + +```java +public interface TokenGateway { + Token create(String username, String iv, List authorities, String extra); + Token parser(String sign); +} +``` + +## 使用实例 + +### 自定义登录逻辑 + +```java +@Service +@AllArgsConstructor +public class LoginService { + + private final TokenGateway tokenGateway; + + public Token login(String username, String password) { + // 1. 验证用户名密码(自行实现) + UserDetails user = authenticate(username, password); + + // 2. 生成 Token(自动根据配置选择 JWT 或 Redis 模式) + return tokenGateway.create( + user.getUsername(), + null, // iv(AES 加密向量,可选) + user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()), + "{\"userId\":1}" // extra 扩展信息 + ); + } +} +``` + +### 获取当前用户信息 + +```java +@RestController +public class ProfileController { + + @GetMapping("/api/profile") + public SingleResponse> profile() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + Token token = (Token) auth.getPrincipal(); + + Map info = new HashMap<>(); + info.put("username", token.getUsername()); + info.put("authorities", token.getAuthorities()); + info.put("extra", token.parseExtra(Map.class)); + return SingleResponse.of(info); + } +} +``` diff --git a/docs/capabilities/sql-interceptor.md b/docs/capabilities/sql-interceptor.md new file mode 100644 index 000000000..70d9a7c57 --- /dev/null +++ b/docs/capabilities/sql-interceptor.md @@ -0,0 +1,186 @@ +--- +name: sql-interceptor +description: 基于 JSqlParser 的 SQL 解析改写拦截器,在 SQL 执行前透明注入数据权限条件 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 67895fda +code_files: + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLInterceptor.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLRunningContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLInterceptorContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/DefaultSQLInterceptor.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/register/SQLInterceptorRegister.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/filter/DataAuthorizationFilter.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/filter/DefaultDataAuthorizationFilter.java +--- + +## 解决什么问题 + +在企业级多租户或行级数据权限场景中,业务查询需要根据当前用户的角色、部门、数据范围等条件自动追加 WHERE 过滤条件。如果由每个开发者手动拼接权限 SQL,容易出现遗漏、不一致和安全漏洞。 + +本能力通过 JDBC Connection/Statement 代理层拦截所有 SQL 执行,利用 JSqlParser 解析 SQL AST,在不修改任何业务代码的前提下,自动向 SELECT 语句注入行级和列级数据权限条件。核心优势: + +- **零侵入**:业务代码无需感知权限逻辑,SQL 改写对上层完全透明 +- **可扩展**:通过 `SQLInterceptor` 接口自定义拦截策略,通过 `DataAuthorizationFilter` 定义具体的权限规则 +- **防递归**:拦截器内部执行的查询自动跳过拦截,避免无限循环 +- **支持复杂 SQL**:处理子查询、JOIN、UNION、IN 子句中的嵌套 SELECT 等场景 + +## 如何使用 + +### 核心接口 + +#### SQLInterceptor — SQL 拦截器接口 + +```java +public interface SQLInterceptor { + // 前置判断:是否需要拦截该 SQL(默认仅拦截查询语句) + boolean beforeHandler(String sql); + + // 核心处理:解析并改写 SQL,返回 DataPermissionSQL(含新 SQL 和表别名上下文) + DataPermissionSQL postHandler(String sql) throws SQLException; + + // 后置回调:用于日志记录或异常处理 + void afterHandler(String sql, String newSql, SQLException exception); +} +``` + +#### DataAuthorizationFilter — 数据权限过滤器接口 + +```java +public interface DataAuthorizationFilter { + // 行级权限:返回需要追加到 WHERE/JOIN 的条件 + Condition rowAuthorization(String tableName, String tableAlias); + + // 列级权限:对查询结果中的特定列值进行脱敏/过滤 + T columnAuthorization(String tableName, String columnName, T value); + + // 是否支持对该表/列进行权限过滤 + boolean supportRowAuthorization(String tableName, String tableAlias); + boolean supportColumnAuthorization(String tableName, String columnName, Object value); +} +``` + +#### SQLRunningContext — SQL 执行上下文(单例) + +```java +// 获取单例 +SQLRunningContext.getInstance() + +// 拦截 SQL 并返回执行状态(包含改写后的 SQL) +SQLExecuteState intercept(String sql) + +// 临时跳过数据权限拦截执行代码块 + T skipDataAuthorization(Supplier supplier) +void skipDataAuthorization(Runnable runnable) +``` + +### 自定义拦截器注册 + +实现 `SQLInterceptor` 接口后,通过 Spring Bean 方式注册: + +```java +@Bean +public SQLInterceptorRegister sqlInterceptorRegister(MyCustomSQLInterceptor interceptor) { + return new SQLInterceptorRegister(interceptor); +} +``` + +`SQLInterceptorRegister` 会在构造时将自定义拦截器设置到 `SQLInterceptorContext` 中,替换默认的 `DefaultSQLInterceptor`。 + +### 自定义数据权限过滤器 + +实现 `DataAuthorizationFilter` 接口,并通过 `DataAuthorizationContext.getInstance().addDataAuthorizationFilter(filter)` 注册。可注册多个过滤器,按注册顺序依次匹配。 + +### 配置项 + +在 `application.properties` 中开启 SQL 日志输出: + +```properties +# 开启后会在 afterHandler 中打印改写后的 SQL +codingapi.authorization.show-sql=true +``` + +## 使用实例 + +### 1. 实现行级数据权限过滤器 + +```java +@Component +public class DepartmentDataFilter implements DataAuthorizationFilter { + + @Override + public boolean supportRowAuthorization(String tableName, String tableAlias) { + // 仅对 employee 表生效 + return "employee".equalsIgnoreCase(tableName); + } + + @Override + public Condition rowAuthorization(String tableName, String tableAlias) { + // 根据当前用户部门追加过滤条件 + Long deptId = SecurityContext.getCurrentDeptId(); + return Condition.formatCondition("%s.dept_id = %d", tableAlias, deptId); + } + + @Override + public boolean supportColumnAuthorization(String tableName, String columnName, Object value) { + return false; + } + + @Override + public T columnAuthorization(String tableName, String columnName, T value) { + return value; + } +} +``` + +### 2. 临时跳过数据权限拦截 + +```java +// 在管理后台导出全量数据时,跳过行级权限 +List allEmployees = SQLRunningContext.getInstance() + .skipDataAuthorization(() -> employeeRepository.findAll()); +``` + +### 3. 自定义 SQL 拦截器 + +```java +@Component +public class AuditSQLInterceptor implements SQLInterceptor { + + @Override + public boolean beforeHandler(String sql) { + // 仅拦截包含敏感表的查询 + return SQLUtils.isQuerySql(sql) && sql.contains("salary"); + } + + @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) { + if (exception != null) { + log.error("SQL 改写失败: {}", sql, exception); + } else { + auditLog.record(sql, newSql); + } + } +} +``` + +### 4. 拦截流程说明 + +``` +原始 SQL → SQLRunningContext.intercept() + → SQLInterceptor.beforeHandler() // 判断是否需要拦截 + → SQLInterceptor.postHandler() // JSqlParser 解析 + 注入权限条件 + → SQLInterceptor.afterHandler() // 日志/审计回调 + → 返回 SQLExecuteState(含改写后的 SQL) + → JDBC 代理使用改写后的 SQL 执行查询 +``` + +拦截器内部的查询操作会自动设置 `skipInterceptor=true`,防止递归拦截。执行完毕后自动重置状态。 diff --git a/docs/capabilities/token-gateway.md b/docs/capabilities/token-gateway.md new file mode 100644 index 000000000..aa2ae7bd5 --- /dev/null +++ b/docs/capabilities/token-gateway.md @@ -0,0 +1,101 @@ +--- +name: token-gateway +description: JWT/Redis 双模式 Token 认证网关,统一创建、解析和管理认证令牌 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 327e5420 +code_files: + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/gateway/TokenGateway.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/gateway/Token.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/jwt/JwtTokenGateway.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/redis/RedisTokenGateway.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/gateway/TokenContext.java +--- + +## 解决什么问题 + +在多端登录、无状态/有状态认证场景中,业务层需要一个统一的 Token 抽象来屏蔽底层存储差异。`TokenGateway` 提供了统一的令牌创建、解析和生命周期管理接口,支持: + +- **JWT 无状态模式**:Token 信息自包含在签名载荷中,适合微服务和分布式场景 +- **Redis 有状态模式**:Token 存储在 Redis 中,支持多端登录管理、强制下线、按需踢出 + +## 如何使用 + +### 核心接口 + +```java +public interface TokenGateway { + // 创建令牌(用户名 + IV加密密钥 + 权限列表 + 扩展信息) + Token create(String username, String iv, List authorities, String extra); + + // 解析令牌,返回 Token 对象 + Token parser(String sign); +} +``` + +### Token 对象 + +`Token` 包含以下字段: +- `username` — 用户名 +- `token` — 令牌字符串(JWT 签名或 Redis Key) +- `authorities` — 权限列表 +- `extra` — 扩展信息(JSON 字符串,可通过 `parseExtra(Class)` 反序列化) +- `iv` — AES 加密密钥(经 AESTools 编码) +- `expireTime` — 过期时间戳 +- `remindTime` — 续期提醒时间戳 + +关键方法: +- `token.verify()` — 校验是否过期,过期抛出 `TokenExpiredException` +- `token.canRestToken()` — 判断是否需要续期(未过期且已过提醒时间) +- `token.getAuthenticationToken()` — 转换为 Spring Security 的 `UsernamePasswordAuthenticationToken` + +### JWT 模式(JwtTokenGateway) + +通过 HMAC-SHA 签名生成自包含 JWT,配置项: +```properties +codingapi.security.jwt.secret-key=your-secret-key +codingapi.security.jwt.valid-time=3600000 # 有效期(毫秒) +codingapi.security.jwt.rest-time=1800000 # 续期提醒时间(毫秒) +``` + +### Redis 模式(RedisTokenGateway) + +Token 以 `AES(用户名):UUID` 为 Key 存储在 Redis 中,支持: +- `getToken(token)` — 根据令牌获取用户信息 +- `removeToken(token)` — 删除单个令牌 +- `removeUsername(username)` — 删除用户所有令牌(强制下线) +- `removeUsername(username, Predicate)` — 按条件选择性踢出 +- `getTokensByUsername(username)` — 获取用户所有令牌(多端管理) +- `resetToken(token)` — 续期令牌 + +```properties +codingapi.security.redis.valid-time=3600000 +codingapi.security.redis.rest-time=1800000 +``` + +## 使用实例 + +```java +// JWT 模式 — 创建并解析令牌 +JwtTokenGateway jwtGateway = new JwtTokenGateway(jwtProperties); +Token token = jwtGateway.create("admin", Arrays.asList("ROLE_USER", "ROLE_ADMIN")); +System.out.println(token.getToken()); // eyJhbGciOiJIUz... + +Token parsed = jwtGateway.parser(token.getToken()); +parsed.verify(); // 校验过期时间 +System.out.println(parsed.getUsername()); // "admin" + +// Redis 模式 — 多端登录管理 +RedisTokenGateway redisGateway = new RedisTokenGateway(redisTemplate, redisProperties); +Token token1 = redisGateway.create("user1", "iv123", Arrays.asList("ROLE_USER"), null); + +// 获取用户所有在线会话 +List tokens = redisGateway.getTokensByUsername("user1"); + +// 按条件踢出(如踢出非当前设备的会话) +redisGateway.removeUsername("user1", t -> !t.getToken().equals(currentToken)); + +// 强制下线 +redisGateway.removeUsername("user1"); +``` diff --git a/docs/capabilities/transaction-manager-context.md b/docs/capabilities/transaction-manager-context.md new file mode 100644 index 000000000..f1e5be8c9 --- /dev/null +++ b/docs/capabilities/transaction-manager-context.md @@ -0,0 +1,141 @@ +--- +name: transaction-manager-context +description: 单例编程式事务上下文,支持 REQUIRES_NEW 语义,在非 Spring 管理上下文中也能使用事务 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 398bf849 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContextConfiguration.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContextRegister.java +--- + +## 解决什么问题 + +在以下场景中,标准的 Spring `@Transactional` 声明式事务无法满足需求: + +1. **非 Spring 管理的代码**:工具类、静态方法、回调函数中无法使用 `@Transactional` 注解 +2. **需要独立新事务**:当前已处于一个事务中,但某段逻辑需要在独立的新事务中执行(`REQUIRES_NEW`),且不受外层事务回滚影响 +3. **动态事务控制**:需要根据运行时条件决定是否开启事务、使用只读事务还是读写事务 +4. **框架内部基础设施**:事件处理器、脚本引擎等框架组件需要在自身逻辑中管理事务边界 + +本能力提供了一个全局单例的编程式事务上下文,封装了 Spring `PlatformTransactionManager`,使得在任何位置都能以简洁的 Lambda 方式执行事务操作。核心特点: + +- **全局单例访问**:通过 `TransactionManagerContext.getInstance()` 在任何位置获取 +- **REQUIRES_NEW 语义**:每次调用都开启独立新事务,不与调用方共享事务 +- **自动初始化**:Spring 容器启动时自动注入 `PlatformTransactionManager` +- **优雅降级**:未配置事务管理器时直接执行业务逻辑,不抛异常 + +## 如何使用 + +### 核心 API + +#### TransactionManagerContext — 事务上下文(单例) + +```java +// 获取单例实例 +TransactionManagerContext ctx = TransactionManagerContext.getInstance(); + +// 在读写事务中执行(REQUIRES_NEW) + T commit(Supplier supplier) + +// 在只读事务中执行(REQUIRES_NEW + readOnly=true) + T readOnly(Supplier supplier) +``` + +### 自动配置 + +框架通过 `TransactionManagerContextConfiguration` 自动完成初始化: + +1. `TransactionManagerContextConfiguration` 注册 `TransactionManagerContextRegister` Bean +2. `TransactionManagerContextRegister` 实现 `InitializingBean`,在 Bean 属性设置完成后将 Spring 容器中的 `PlatformTransactionManager` 注入到 `TransactionManagerContext` 单例中 +3. 如果容器中不存在 `PlatformTransactionManager`(`@Autowired(required = false)`),则不注入,事务操作会降级为直接执行 + +无需任何额外配置,只要项目中存在数据源和事务管理器即可自动生效。 + +### 事务行为说明 + +| 方法 | 传播行为 | 只读 | 正常结束 | 异常时 | +|------|---------|------|---------|--------| +| `commit()` | REQUIRES_NEW | 否 | 提交事务 | 回滚事务并抛出异常 | +| `readOnly()` | REQUIRES_NEW | 是 | 回滚事务(只读无需提交) | 回滚事务并抛出异常 | + +> **注意**:`readOnly()` 方法在正常结束时也会调用 `rollback()` 而非 `commit()`,因为只读事务没有数据变更,回滚比提交更高效。 + +## 使用实例 + +### 1. 在工具类中使用事务 + +```java +public class DataMigrationUtil { + + public static void migrateData(List records) { + TransactionManagerContext.getInstance().commit(() -> { + // 这些操作在独立的新事务中执行 + for (Record record : records) { + recordRepository.save(record); + } + return null; + }); + } +} +``` + +### 2. 在事件处理器中使用独立事务 + +```java +@Component +public class OrderCreatedHandler implements IHandler { + + @Override + public void handler(OrderCreatedEvent event) { + // 即使外层事务回滚,库存扣减也在独立事务中完成 + TransactionManagerContext.getInstance().commit(() -> { + inventoryService.deduct(event.getProductId(), event.getQuantity()); + return null; + }); + } +} +``` + +### 3. 使用只读事务查询 + +```java +public List generateReport() { + return TransactionManagerContext.getInstance().readOnly(() -> { + // 只读事务,数据库可优化查询性能 + List orders = orderRepository.findByMonth(currentMonth); + List products = productRepository.findAll(); + return reportBuilder.build(orders, products); + }); +} +``` + +### 4. 无事务管理器时的降级行为 + +```java +// 当 PlatformTransactionManager 未注入时,supplier 直接执行,不包裹事务 +// 适用于测试环境或无数据库的场景 +String result = TransactionManagerContext.getInstance().commit(() -> { + return "direct execution without transaction"; +}); +``` + +### 5. 异常自动回滚 + +```java +try { + TransactionManagerContext.getInstance().commit(() -> { + accountService.debit(fromAccount, amount); + accountService.credit(toAccount, amount); + if (balanceCheckFailed) { + throw new IllegalStateException("余额校验失败"); + } + return null; + }); +} catch (IllegalStateException e) { + // 事务已自动回滚,debit 和 credit 操作均已撤销 + log.error("转账失败,事务已回滚", e); +} +``` diff --git a/docs/capabilities/trigger-system.md b/docs/capabilities/trigger-system.md new file mode 100644 index 000000000..79e8ca7ca --- /dev/null +++ b/docs/capabilities/trigger-system.md @@ -0,0 +1,230 @@ +--- +name: trigger-system +description: 触发器框架,支持定时/条件触发,提供统一的触发上下文管理 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 70be0d38 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/trigger/Trigger.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/trigger/TriggerHandler.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/trigger/TriggerContext.java +--- + +## 解决什么问题 + +在业务流程中,存在一类"等待特定条件满足后执行"的场景: + +- 订单超时未支付自动取消 +- 审批流程中等待上级审批后再触发下一步 +- 数据采集达到阈值后触发告警 +- 临时性的条件监听,触发一次后即失效 + +这类需求与事件系统(Event System)的区别在于:**Event 是消息驱动**(确定了消息内容,不确定谁消费),而 **Trigger 是订阅驱动**(先注册订阅规则,再等待匹配的消息到来触发执行)。Trigger 模式允许精确控制: + +- **是否进入触发逻辑**:通过 `preTrigger()` 前置判断 +- **触发后是否移除**:通过 `remove()` 控制一次性触发还是持续监听 +- **按类型隔离**:不同 Trigger 类型的 Handler 互不干扰 + +本能力提供了一个轻量级的触发器框架,支持运行时动态注册/移除触发器,适用于需要条件触发、一次性触发或临时监听的业务场景。 + +## 如何使用 + +### 核心接口 + +#### Trigger — 触发器数据对象标记接口 + +```java +public interface Trigger { + // 标记接口,具体触发器需定义自己的数据结构 +} +``` + +所有触发器数据对象必须实现此接口,作为泛型约束的基础类型。 + +#### TriggerHandler\ — 触发处理器 + +```java +public interface TriggerHandler { + // 前置判断:是否满足触发条件 + boolean preTrigger(T trigger); + + // 触发执行逻辑 + void trigger(T trigger); + + // 执行完成后是否移除此 Handler(默认不移除) + default boolean remove(T trigger, boolean canTrigger) { + return false; + } +} +``` + +- `preTrigger()` 返回 `true` 时才会执行 `trigger()` 方法 +- `remove()` 的 `canTrigger` 参数表示本次是否实际执行了 `trigger()` 逻辑 +- `remove()` 返回 `true` 时,该 Handler 会从触发器列表中移除(一次性触发器) + +#### TriggerContext — 触发器上下文(单例) + +```java +// 获取单例 +TriggerContext ctx = TriggerContext.getInstance(); + +// 注册触发处理器 +void addTrigger(TriggerHandler handler) + +// 触发指定类型的触发器 +void trigger(Trigger trigger) + +// 清空某类型的所有触发器 +void clear(Class clazz) + +// 判断某类型的触发器是否为空 +boolean isEmpty(Class clazz) +``` + +### 工作机制 + +1. **注册阶段**:调用 `addTrigger(handler)` 时,框架通过反射读取 Handler 实现的 `TriggerHandler` 泛型参数,自动推断其关联的 Trigger 类型,并按类型分组存储 +2. **触发阶段**:调用 `trigger(triggerObj)` 时,根据 triggerObj 的实际类型查找对应的 Handler 列表,依次执行: + - 调用 `preTrigger()` 判断是否满足条件 + - 满足条件则调用 `trigger()` 执行业务逻辑 + - 调用 `remove()` 判断是否需要移除该 Handler +3. **异常隔离**:单个 Handler 执行异常不会影响其他 Handler 的执行,异常会被捕获并记录 warn 日志 + +### 线程安全 + +- 触发器 Map 使用 `ConcurrentHashMap` 保证并发注册安全 +- 每个类型的 Handler 列表使用 `CopyOnWriteArrayList` 保证遍历时的并发安全 + +## 使用实例 + +### 1. 定义触发器数据对象 + +```java +// 订单超时触发器 +public class OrderTimeoutTrigger implements Trigger { + private final String orderId; + private final LocalDateTime deadline; + + public OrderTimeoutTrigger(String orderId, LocalDateTime deadline) { + this.orderId = orderId; + this.deadline = deadline; + } + + public String getOrderId() { return orderId; } + public LocalDateTime getDeadline() { return deadline; } +} +``` + +### 2. 实现一次性触发处理器 + +```java +// 订单超时自动取消(触发一次后自动移除) +public class OrderTimeoutCancelHandler implements TriggerHandler { + + @Override + public boolean preTrigger(OrderTimeoutTrigger trigger) { + // 仅在超过截止时间时才触发 + return LocalDateTime.now().isAfter(trigger.getDeadline()); + } + + @Override + public void trigger(OrderTimeoutTrigger trigger) { + log.info("订单 {} 超时未支付,自动取消", trigger.getOrderId()); + orderService.cancelOrder(trigger.getOrderId(), "超时未支付"); + } + + @Override + public boolean remove(OrderTimeoutTrigger trigger, boolean canTrigger) { + // 无论是否触发成功,都移除该 Handler(一次性) + return true; + } +} +``` + +### 3. 实现持续性触发处理器 + +```java +// 库存告警(持续监听,每次低于阈值都触发) +public class StockAlertTrigger implements Trigger { + private final String productId; + private final int currentStock; + + public StockAlertTrigger(String productId, int currentStock) { + this.productId = productId; + this.currentStock = currentStock; + } + + // getter ... +} + +public class StockAlertHandler implements TriggerHandler { + + private static final int THRESHOLD = 10; + + @Override + public boolean preTrigger(StockAlertTrigger trigger) { + return trigger.getCurrentStock() < THRESHOLD; + } + + @Override + public void trigger(StockAlertTrigger trigger) { + notificationService.sendAlert( + "商品 " + trigger.getProductId() + " 库存不足,当前: " + trigger.getCurrentStock() + ); + } + + // 使用默认 remove(),返回 false,持续监听 +} +``` + +### 4. 注册和触发 + +```java +@Service +public class OrderService { + + // 创建订单时注册超时触发器 + public void createOrder(String orderId) { + // ... 创建订单逻辑 ... + + // 注册 30 分钟超时触发器 + OrderTimeoutTrigger trigger = new OrderTimeoutTrigger( + orderId, LocalDateTime.now().plusMinutes(30)); + TriggerContext.getInstance().addTrigger(new OrderTimeoutCancelHandler()); + + // 在实际场景中,通常会结合定时任务定期调用 trigger() + } + + // 定时检查(由 ScheduledTask 调用) + @Scheduled(fixedRate = 60000) + public void checkOrderTimeout() { + OrderTimeoutTrigger trigger = new OrderTimeoutTrigger(null, null); + // 遍历待检查订单,逐个触发 + for (String orderId : pendingOrders) { + OrderTimeoutTrigger t = new OrderTimeoutTrigger(orderId, getOrderDeadline(orderId)); + TriggerContext.getInstance().trigger(t); + } + } +} +``` + +### 5. 清空触发器 + +```java +// 当订单被手动取消或支付成功时,清空该类型的所有触发器 +TriggerContext.getInstance().clear(OrderTimeoutTrigger.class); + +// 检查是否还有活跃的触发器 +boolean hasTriggers = !TriggerContext.getInstance().isEmpty(OrderTimeoutTrigger.class); +``` + +### 6. Event vs Trigger 选择指南 + +| 场景 | 推荐 | 原因 | +|------|------|------| +| 实体状态变更后通知多个下游 | Event | 消息确定,消费者不确定 | +| 等待特定条件后执行一次性操作 | Trigger | 先订阅再等待,触发后可自移除 | +| 跨模块解耦通信 | Event | 发布-订阅天然解耦 | +| 临时性条件监听 | Trigger | 可动态注册/移除,生命周期可控 | +| 定时轮询检查条件 | Trigger | 配合 preTrigger 做条件判断 | diff --git a/docs/capabilities/user-context.md b/docs/capabilities/user-context.md new file mode 100644 index 000000000..3e36d7657 --- /dev/null +++ b/docs/capabilities/user-context.md @@ -0,0 +1,142 @@ +--- +name: user-context +description: 线程级用户身份上下文,通过 ThreadLocal 持有当前登录用户,支持跨层传递 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 719167a0 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/user/UserContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/user/IUser.java +--- + +## 解决什么问题 + +在企业应用中,几乎所有业务操作都需要知道"当前操作用户是谁"。传统做法是在方法签名中逐层传递用户对象(Controller → Service → Repository),导致: + +- **参数污染**:每个业务方法都需要额外接收用户参数,签名臃肿 +- **跨层耦合**:中间层即使不使用用户信息也必须透传 +- **框架集成困难**:事件处理器、异步任务、AOP 切面等非调用链场景无法方便地获取用户身份 + +本能力通过 `ThreadLocal` 在当前线程中绑定用户身份对象,使任意层级的代码都能直接获取当前登录用户,无需显式传参。配合安全模块(`springboot-starter-security`)的认证过滤器,在请求入口处自动设置、请求结束时自动清理。 + +## 如何使用 + +### 核心接口与类 + +| 类型 | 说明 | +|------|------| +| `IUser` | 用户身份标记接口,业务模块实现此接口定义自己的用户实体 | +| `UserContext` | 单例用户上下文管理器,基于 `ThreadLocal` 存储当前线程的用户身份 | + +### API + +```java +// 获取全局单例 +UserContext ctx = UserContext.getInstance(); + +// 设置当前线程的用户身份 +ctx.setCurrent(IUser user); + +// 获取当前线程的用户身份 +IUser user = ctx.current(); +``` + +### 实现 IUser + +`IUser` 是一个纯标记接口(无方法声明),业务模块自行定义用户属性: + +```java +public class LoginUser implements IUser { + private String userId; + private String username; + private List roles; + // getter/setter... +} +``` + +### 与安全模块集成 + +在认证过滤器中设置用户上下文: + +```java +// 认证通过后设置 +UserContext.getInstance().setCurrent(loginUser); + +// 请求结束后清理(防止线程池复用导致上下文泄漏) +UserContext.getInstance().setCurrent(null); +``` + +### 注意事项 + +- `UserContext` 基于 `ThreadLocal`,仅在同一个线程内有效 +- 异步场景(`@Async`、线程池)需要手动传递或配合 `TaskDecorator` 复制上下文 +- 务必在请求结束时清理(设为 `null`),避免线程池场景下的用户信息泄漏 + +## 使用实例 + +### 1. 在 Service 层获取当前用户 + +```java +@Service +public class OrderService { + + public Order createOrder(CreateOrderCmd cmd) { + // 无需方法参数传入用户,直接从上下文获取 + IUser currentUser = UserContext.getInstance().current(); + LoginUser loginUser = (LoginUser) currentUser; + + Order order = new Order(); + order.setCreatorId(loginUser.getUserId()); + order.setCreatorName(loginUser.getUsername()); + // ... + return orderRepository.save(order); + } +} +``` + +### 2. 在事件 Handler 中获取操作用户 + +```java +@Handler +@Component +public class AuditLogHandler implements IHandler { + + @Override + public void handler(DomainCreateEvent event) { + IUser user = UserContext.getInstance().current(); + // 记录审计日志:谁在什么时候创建了什么 + auditLogRepository.save(new AuditLog( + user != null ? user.toString() : "system", + event.getEntityClass().getSimpleName(), + LocalDateTime.now() + )); + } +} +``` + +### 3. 在认证过滤器中设置与清理 + +```java +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + try { + // 解析 Token → 构建用户对象 + LoginUser loginUser = tokenGateway.parseAndBuildUser(request); + if (loginUser != null) { + UserContext.getInstance().setCurrent(loginUser); + } + chain.doFilter(request, response); + } finally { + // 请求结束必须清理,防止线程池复用导致上下文污染 + UserContext.getInstance().setCurrent(null); + } + } +} +``` diff --git a/docs/capabilities/workflow-engine.md b/docs/capabilities/workflow-engine.md new file mode 100644 index 000000000..763b690e6 --- /dev/null +++ b/docs/capabilities/workflow-engine.md @@ -0,0 +1,135 @@ +--- +name: workflow-engine +description: 工作流引擎,支持流程定义、节点流转、审批、委托、会签、数据快照和事件通知 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 0fc02aca +code_files: + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/FlowConfiguration.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/record/FlowProcess.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/record/FlowRecord.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/record/FlowBackup.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/record/FlowMerge.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/FlowService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/FlowNodeService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/impl/FlowStartService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/impl/FlowSubmitService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/impl/FlowRecallService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/impl/FlowStopService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/service/impl/FlowTrySubmitService.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/repository/FlowWorkRepository.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/repository/FlowProcessRepository.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/repository/FlowRecordRepository.java + - springboot-starter-flow/src/main/java/com/codingapi/springboot/flow/repository/FlowBackupRepository.java +--- + +## 解决什么问题 + +企业级审批场景(请假、报销、采购等)需要标准化的流程引擎来管理: +- **流程定义与版本管理**:流程模板可修改、可版本化,运行中流程不受定义变更影响 +- **节点流转规则**:支持串行、并行(会签)、条件分支等多种流转模式 +- **审批操作**:通过、驳回、退回、撤回、转办、催办、终止等完整操作集 +- **数据快照**:发起时锁定流程定义版本,确保审批过程一致性 +- **事件通知**:每个状态变更自动推送 `FlowApprovalEvent`,驱动业务联动 + +## 如何使用 + +### 核心数据模型 + +``` +FlowWork(流程定义) + └── FlowNode(节点) + └── FlowSource(连线/分支条件) + +FlowProcess(流程实例) + └── FlowRecord(审批记录) + └── FlowBackup(版本快照) +``` + +| 模型 | 说明 | +|------|------| +| `FlowWork` | 流程模板定义,包含节点列表和连线关系 | +| `FlowProcess` | 流程实例(由 `FlowStartService` 创建),关联 backupId 锁定版本 | +| `FlowRecord` | 审批记录,记录每个节点的审批人、结果、意见、时间 | +| `FlowBackup` | 流程定义快照(Kryo 序列化),确保运行中流程不受模板修改影响 | + +### 服务层 API + +| 服务 | 方法 | 说明 | +|------|------|------| +| `FlowStartService` | `startFlow(...)` | 发起流程,创建 FlowProcess + FlowBackup + 初始 FlowRecord | +| `FlowSubmitService` | `submitFlow(...)` | 提交审批(通过/驳回) | +| `FlowRecallService` | `recallFlow(...)` | 撤回已提交的流程 | +| `FlowTrySubmitService` | `trySubmitFlow(...)` | 试提交 — 获取下一节点审批人(不实际流转) | +| `FlowStopService` | `stopFlow(...)` | 终止流程 | +| `FlowUrgeService` | `urgeFlow(...)` | 催办 | +| `FlowNodeService` | `getFlowRecords(...)` | 获取流程审批记录 | +| `FlowStepService` | `getFlowSteps(...)` | 获取流程步骤图 | +| `FlowCustomEventService` | `customEvent(...)` | 触发自定义按钮事件 | + +### 流程状态(FlowStatus) + +``` +RUNNING → FINISH / VOIDED / STOP +``` + +### 审批操作类型(FlowType) + +CREATE、SAVE、PASS、REJECT、RECALL、DELETE、VOIDED、BACK、FINISH、TRANSFER、URGE、STOP + +### 事件通知 + +每次状态变更通过 `EventPusher` 推送 `FlowApprovalEvent`,业务侧通过 `IHandler` 监听: + +```java +@Component +public class LeaveHandler implements IHandler { + @Override + public void handler(FlowApprovalEvent event) { + if (event.getFlowType() == FlowType.FINISH) { + // 审批完成 — 更新请假状态 + } + } +} +``` + +### 配置 + +```java +@Configuration +public class FlowConfiguration { + // 自动注册所有 Flow Repository 和 Service + // 通过 FlowFrameworkRegister 注册框架级触发器 +} +``` + +## 使用实例 + +```java +// 1. 发起请假流程 +FlowProcess process = flowStartService.startFlow( + workId, // 流程定义ID + operatorId, // 发起人ID + bindDataId // 关联业务数据ID +); + +// 2. 试提交 — 查看下一节点审批人 +List nextApprovers = flowTrySubmitService.trySubmitFlow( + process.getProcessId(), operatorId, "同意请假" +); + +// 3. 正式提交审批 +flowSubmitService.submitFlow( + process.getProcessId(), operatorId, "同意请假", ApprovalType.PASS +); + +// 4. 撤回(仅在下一节点未审批前可撤回) +flowRecallService.recallFlow(process.getProcessId(), operatorId); + +// 5. 查询审批记录 +List records = flowNodeService.getFlowRecords(process.getProcessId()); + +// 6. 终止流程 +flowStopService.stopFlow(process.getProcessId(), operatorId, "业务取消"); +``` diff --git a/docs/conventions/autoconfiguration-pattern.md b/docs/conventions/autoconfiguration-pattern.md new file mode 100644 index 000000000..e33758ace --- /dev/null +++ b/docs/conventions/autoconfiguration-pattern.md @@ -0,0 +1,134 @@ +--- +name: autoconfiguration-pattern +description: 每个 starter 模块必须同时注册 spring.factories(旧版兼容)和 AutoConfiguration.imports(Spring Boot 3.x 标准),使用 @ConfigurationProperties + Context 单例模式 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 303b377f +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/properties/PropertiesContext.java + - springboot-starter/src/main/resources/META-INF/spring.factories + - springboot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +--- + +## 解决什么问题 + +创建 Spring Boot Starter 时,如果不遵循标准的自动配置注册规范,会导致以下问题: + +1. **版本兼容性断裂**:Spring Boot 3.x 废弃了 `spring.factories` 中的 `EnableAutoConfiguration` 键,仅支持 `AutoConfiguration.imports`;但部分运行环境或工具链仍读取旧格式,缺失任一都会导致自动配置不生效 +2. **配置属性无法绑定**:不使用 `@ConfigurationProperties` 注解则无法将 `application.properties` 中的配置自动映射到 Java 对象 +3. **非 Spring 上下文无法访问配置**:在工具类、拦截器、代理等非 Bean 环境中,无法通过 `@Autowired` 注入配置,需要额外的 Context 单例桥接 +4. **新模块遗漏注册**:没有统一的注册约定,新增 starter 时容易忘记配置文件导致功能静默失效 + +## 如何使用 + +### 1. 双文件注册(必须同时维护) + +在每个 starter 模块的 `src/main/resources/META-INF/` 下创建两个文件: + +**`spring.factories`**(兼容 Spring Boot 2.x 及旧工具链): +```properties +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.example.mystarter.MyAutoConfiguration,\ +com.example.mystarter.config.SomeConfiguration +``` + +**`spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`**(Spring Boot 3.x 标准): +``` +com.example.mystarter.MyAutoConfiguration +com.example.mystarter.config.SomeConfiguration +``` + +> ⚠️ 两个文件中列出的配置类**必须完全一致**,仅格式不同。 + +### 2. ConfigurationProperties + Context 模式 + +对于需要在非 Spring Bean 中访问的配置: + +1. 定义 Properties 类并标注 `@ConfigurationProperties(prefix = "...")` +2. 定义对应的 `*Context` 单例类持有该 Properties +3. 在 `@Configuration` 类的 `@Bean` 方法中创建 Properties 并同步设置到 Context + +### 3. 规则 + +1. **新增 starter 模块时必须同时创建两个注册文件** +2. 修改自动配置类列表时,两个文件必须同步更新 +3. 配置类使用 `@Configuration` 注解,可配合 `@ConditionalOnClass` / `@ConditionalOnProperty` 做条件加载 +4. 需要暴露给非 Bean 环境的配置必须通过 Context 单例桥接 + +## 使用实例 + +### ✅ 正确示例 + +**目录结构:** +``` +springboot-starter-mymodule/ + src/main/ + java/com/example/mymodule/ + AutoConfiguration.java + MyModuleProperties.java + MyModuleContext.java + resources/META-INF/ + spring.factories + spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +``` + +**spring.factories:** +```properties +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.example.mymodule.AutoConfiguration +``` + +**AutoConfiguration.imports:** +``` +com.example.mymodule.AutoConfiguration +``` + +**AutoConfiguration.java:** +```java +@Configuration +public class AutoConfiguration implements InitializingBean { + + @Bean + @ConfigurationProperties(prefix = "codingapi.mymodule") + public MyModuleProperties myModuleProperties() { + MyModuleProperties properties = new MyModuleProperties(); + MyModuleContext.getInstance().setProperties(properties); + return properties; + } + + @Override + public void afterPropertiesSet() throws Exception { + // 启动时的初始化逻辑 + } +} +``` + +### ❌ 错误示例 + +```properties +# 错误:仅有 spring.factories,缺少 AutoConfiguration.imports +# Spring Boot 3.x 环境下自动配置不会生效 +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.example.mymodule.AutoConfiguration +``` + +```java +// 错误:直接使用 @Value 注入配置,非 Bean 环境无法获取 +@Component +public class MyService { + @Value("${codingapi.mymodule.timeout}") + private int timeout; +} + +// 错误:两个文件内容不一致 +// spring.factories 列出了 A, B +// AutoConfiguration.imports 只列出了 A +// → 某些环境下 B 配置不生效,难以排查 + +// 错误:配置类未使用 @ConfigurationProperties +@Bean +public MyModuleProperties myModuleProperties() { + return new MyModuleProperties(); // 属性不会被自动绑定 +} +``` diff --git a/docs/conventions/context-register-pairing.md b/docs/conventions/context-register-pairing.md new file mode 100644 index 000000000..fdcbd0de3 --- /dev/null +++ b/docs/conventions/context-register-pairing.md @@ -0,0 +1,222 @@ +--- +name: context-register-pairing +description: 每个 Context 单例必须配套 Register 类和 Configuration 类,形成三件套:Context(持有状态)+ Register(注入依赖)+ Configuration(注册 Bean) +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 303b377f +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContextRegister.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContextConfiguration.java +--- + +## 解决什么问题 + +本规范是对已有 `context-singleton` 规范的补充细化。`context-singleton` 定义了 Context 单例的基本结构,但在实际开发中经常出现以下问题: + +1. **Register 缺失**:开发者创建了 Context 单例,但忘记编写对应的 Register 类来注入 Spring Bean 依赖,导致 Context 中的依赖为 null,运行时才暴露 NPE。 +2. **Configuration 遗漏**:Register 类虽然写了,但没有通过 `@Configuration` + `@Bean` 注册到 Spring 容器中,Register 不会被实例化,`afterPropertiesSet()` 永远不会被调用。 +3. **三者分散在不同模块**:Context、Register、Configuration 散落在不同的包或模块中,新增功能时难以找到完整的配对关系,维护成本高。 +4. **依赖注入方式不一致**:有的用构造函数注入,有的用 `@Autowired` 字段注入,有的直接在 Configuration 中调用 setter,缺乏统一模式。 + +本规范要求:**每创建一个 Context 单例,必须同时创建配套的 Register 和 Configuration 类,三者缺一不可,且放在同一个包下。** + +## 如何使用 + +### 三件套命名规范 + +以 `Xxx` 为业务前缀,三个类的命名固定为: + +| 类名 | 职责 | 关键特征 | +|------|------|---------| +| `XxxContext` | 持有全局状态的饿汉式单例 | `private static final XxxContext instance = new XxxContext()`;私有构造函数;提供 `getInstance()` | +| `XxxContextRegister` | 实现 `InitializingBean`,在 Bean 初始化完成后将依赖注入到 Context | 构造函数接收所需依赖;`afterPropertiesSet()` 中调用 Context 的 setter | +| `XxxContextConfiguration` | `@Configuration` 类,通过 `@Bean` 方法注册 Register 实例 | 可声明 `@ConditionalOnClass` 等条件注解;依赖参数使用 `@Autowired(required = false)` 避免启动失败 | + +### 文件组织 + +三个类必须放在**同一个 Java 包**下,文件名与类名一致: + +``` +com.codingapi.springboot.framework.transaction/ +├── TransactionManagerContext.java +├── TransactionManagerContextRegister.java +└── TransactionManagerContextConfiguration.java +``` + +### Context 类模板 + +```java +@Slf4j +public class XxxContext { + + @Getter + private static final XxxContext instance = new XxxContext(); + + private SomeDependency dependency; + + private XxxContext() {} + + public void setDependency(SomeDependency dependency) { + this.dependency = dependency; + if (dependency != null) { + log.info("{} load success", dependency); + } + } + + // 对外 API 方法 + public T execute(Supplier supplier) { + if (dependency != null) { + // 使用依赖执行业务逻辑 + } + return supplier.get(); + } +} +``` + +### Register 类模板 + +```java +public class XxxContextRegister implements InitializingBean { + + private final SomeDependency dependency; + + public XxxContextRegister(SomeDependency dependency) { + this.dependency = dependency; + } + + @Override + public void afterPropertiesSet() throws Exception { + XxxContext.getInstance().setDependency(dependency); + } +} +``` + +### Configuration 类模板 + +```java +@Configuration +public class XxxContextConfiguration { + + @Bean + public XxxContextRegister xxxContextRegister( + @Autowired(required = false) SomeDependency dependency) { + return new XxxContextRegister(dependency); + } +} +``` + +### 关键规则 + +1. **Register 只负责注入**:`afterPropertiesSet()` 中只做 setter 调用,不包含业务逻辑。 +2. **Configuration 只负责注册**:`@Bean` 方法只构造 Register 实例,不做其他操作。 +3. **可选依赖使用 `required = false`**:当依赖可能不存在时(如 `PlatformTransactionManager` 在某些测试环境中),使用 `@Autowired(required = false)` 避免启动失败。Context 的 API 方法内部需对 null 做防御性检查。 +4. **Context 的 setter 应有日志**:注入成功时打印 INFO 日志,便于排查启动问题。 +5. **自动配置注册**:Configuration 类需在 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 和 `META-INF/spring.factories` 中同时注册。 + +## 使用实例 + +```java +// ✅ 正确 — 完整的三件套(以 TransactionManager 为例) + +// 1. Context:持有 PlatformTransactionManager,提供编程式事务 API +public class TransactionManagerContext { + @Getter + private static final TransactionManagerContext instance = new TransactionManagerContext(); + @Getter + private PlatformTransactionManager platformTransactionManager; + + private TransactionManagerContext() {} + + public void setPlatformTransactionManager(PlatformTransactionManager ptm) { + this.platformTransactionManager = ptm; + if (ptm != null) { + log.info("platformTransactionManager:{} load success", ptm); + } + } + + public T commit(Supplier supplier) { + if (platformTransactionManager != null) { + DefaultTransactionDefinition def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + TransactionStatus status = platformTransactionManager.getTransaction(def); + try { + T result = supplier.get(); + platformTransactionManager.commit(status); + return result; + } catch (Exception e) { + platformTransactionManager.rollback(status); + throw e; + } + } + return supplier.get(); // 无事务管理器时降级执行 + } +} + +// 2. Register:启动时将 TransactionManager 注入到 Context +public class TransactionManagerContextRegister implements InitializingBean { + private final PlatformTransactionManager transactionManager; + + public TransactionManagerContextRegister(PlatformTransactionManager transactionManager) { + this.transactionManager = transactionManager; + } + + @Override + public void afterPropertiesSet() { + TransactionManagerContext.getInstance() + .setPlatformTransactionManager(transactionManager); + } +} + +// 3. Configuration:注册 Register Bean +@Configuration +public class TransactionManagerContextConfiguration { + @Bean + public TransactionManagerContextRegister transactionManagerContextRegister( + @Autowired(required = false) PlatformTransactionManager ptm) { + return new TransactionManagerContextRegister(ptm); + } +} + +// ✅ 正确 — 在非 Spring 管理的代码中使用 Context +public class GroovyScriptRuntime { + public T invoke(String method, String script, TransactionMode mode) { + if (mode == TransactionMode.COMMIT) { + return TransactionManagerContext.getInstance().commit(() -> { + return (T) runtime.invokeMethod(method, args); + }); + } + // ... + } +} + +// ❌ 错误 — 只有 Context,缺少 Register 和 Configuration +public class CacheContext { + @Getter + private static final CacheContext instance = new CacheContext(); + private CacheManager cacheManager; + + private CacheContext() {} + + // 没有 Register 注入 cacheManager,永远为 null! + public void put(String key, Object value) { + cacheManager.put(key, value); // NPE! + } +} + +// ❌ 错误 — Register 存在但没有 Configuration 注册 +// TransactionManagerContextRegister 写了,但没有 @Configuration 类将其注册为 Bean +// afterPropertiesSet() 永远不会被调用 + +// ❌ 错误 — 在 Configuration 中直接操作 Context,跳过 Register +@Configuration +public class BadConfiguration { + @Bean + public SomeBean someBean(PlatformTransactionManager ptm) { + // 不应在普通 Bean 定义中直接操作 Context + TransactionManagerContext.getInstance().setPlatformTransactionManager(ptm); + return new SomeBean(); + } +} +``` diff --git a/docs/conventions/context-singleton.md b/docs/conventions/context-singleton.md new file mode 100644 index 000000000..7489a39c4 --- /dev/null +++ b/docs/conventions/context-singleton.md @@ -0,0 +1,124 @@ +--- +name: context-singleton +description: 全局状态通过单例 Context 类持有,由 Register 类在启动时注入依赖 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 303b377f +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/properties/PropertiesContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/MessageContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/transaction/TransactionManagerContextRegister.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/user/UserContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/rest/RestTemplateContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/event/DomainEventContext.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/trigger/TriggerContext.java + - springboot-starter-security/src/main/java/com/codingapi/springboot/security/gateway/TokenContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLInterceptorContext.java + - springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java +--- + +## 解决什么问题 + +框架层工具类(如事件推送、事务管理、异常国际化)需要在非 Spring 管理的上下文中使用(例如 Groovy 脚本运行时、静态方法调用、线程池异步任务)。如果直接依赖 Spring Bean 注入,在这些场景中将无法获取实例。 + +单例 Context 模式解决了这个问题:通过静态单例持有全局状态,由 Spring 管理的 `*Register` 类在应用启动时注入所需依赖,使得框架能力在任何地方都能通过 `XxxContext.getInstance()` 访问。 + +## 如何使用 + +### 命名规范 + +| 类后缀 | 职责 | 示例 | +|--------|------|------| +| `*Context` | 持有全局状态的单例类 | `PropertiesContext`, `UserContext`, `TransactionManagerContext` | +| `*Register` | `InitializingBean` 实现,启动时注入依赖到 Context | `TransactionManagerContextRegister` | +| `*Configuration` | `@Configuration` 类,注册 Context Bean 和 Register Bean | `TransactionManagerContextConfiguration` | + +### Context 类结构 + +```java +public class XxxContext { + // 饿汉式单例(推荐)或懒汉式(需双重检查锁) + @Getter + private static final XxxContext instance = new XxxContext(); + + private XxxContext() {} + + // 注入的依赖(由 Register 设置) + private SomeDependency dependency; + + public void setDependency(SomeDependency dependency) { + this.dependency = dependency; + } + + // 对外 API + public void doSomething() { + dependency.execute(); + } +} +``` + +### Register 类结构 + +```java +public class XxxContextRegister implements InitializingBean { + private final SomeDependency dependency; + + public XxxContextRegister(SomeDependency dependency) { + this.dependency = dependency; + } + + @Override + public void afterPropertiesSet() { + XxxContext.getInstance().setDependency(dependency); + } +} +``` + +### 已注册的 Context 类 + +| Context | 持有状态 | 用途 | +|---------|---------|------| +| `PropertiesContext` | `FrameworkProperties` | 框架配置属性 | +| `MessageContext` | `MessageSource` | 国际化消息 | +| `TransactionManagerContext` | `PlatformTransactionManager` | 编程式事务 | +| `UserContext` | `ThreadLocal` | 当前登录用户 | +| `RestTemplateContext` | `RestTemplate` | HTTP 客户端 | +| `DomainEventContext` | 领域事件上下文 | 事件变更追踪 | +| `TokenContext` | `ThreadLocal` | 当前 Token 扩展信息 | +| `SQLInterceptorContext` | SQL 拦截状态 | 数据权限上下文 | +| `TriggerContext` | 触发器注册表 | 触发器管理 | + +## 使用实例 + +```java +// ✅ 正确 — 通过 Context 单例访问 +public class GroovyScriptRuntime { + public Object execute(String script) { + // 在 Groovy 脚本中使用框架事务 + return TransactionManagerContext.getInstance().commit(() -> { + return runScript(script); + }); + } +} + +// ✅ 正确 — Register 在启动时注入 +@Bean +public TransactionManagerContextRegister transactionManagerContextRegister( + PlatformTransactionManager transactionManager) { + return new TransactionManagerContextRegister(transactionManager); +} + +// ❌ 错误 — 在非 Spring 管理的类中直接注入 Bean +public class GroovyScriptRuntime { + @Autowired // 无法注入!此类不是 Spring Bean + private PlatformTransactionManager transactionManager; +} + +// ❌ 错误 — 在静态工具类中使用 ThreadLocal 之外的 Spring Bean +public class EventPusher { + @Autowired // 静态上下文无法注入 + private static ApplicationEventPublisher publisher; +} +``` diff --git a/docs/conventions/index.md b/docs/conventions/index.md new file mode 100644 index 000000000..647519f8e --- /dev/null +++ b/docs/conventions/index.md @@ -0,0 +1,20 @@ +# 规范(Conventions) + +> 🔄 此文件由 `rebuild_pkr_index.py` 自动生成,请勿手动编辑。 + +## ✅ 已实现 + +| 名称 | 描述 | 范围 | 来源 | +|------|------|------|------| +| [autoconfiguration-pattern](./autoconfiguration-pattern.md) | 每个 starter 模块必须同时注册 spring.factories(旧版兼容)和 AutoConfiguration.imports(Spring ... | 后端 | 项目自有 | +| [context-register-pairing](./context-register-pairing.md) | 每个 Context 单例必须配套 Register 类和 Configuration 类,形成三件套:Context(持有状态)+ Register(注... | 后端 | 项目自有 | +| [context-singleton](./context-singleton.md) | 全局状态通过单例 Context 类持有,由 Register 类在启动时注入依赖 | 后端 | 项目自有 | +| [locale-exception-handling](./locale-exception-handling.md) | 所有业务异常必须使用 LocaleMessageException,通过 errCode 实现国际化,由全局 HandlerExceptionResolv... | 后端 | 项目自有 | +| [meta-table-annotation](./meta-table-annotation.md) | 通过 @MetaTable/@MetaColumn/@MetaRelation 注解声明表的元数据描述,驱动动态实体生成和元数据查询 | 后端 | 项目自有 | +| [servlet-exception-resolver](./servlet-exception-resolver.md) | 使用 HandlerExceptionResolver(而非 @ControllerAdvice)作为全局异常处理机制,返回统一 JSON 响应格式 | 后端 | 项目自有 | +| [slf4j-logging](./slf4j-logging.md) | 所有 Java 类必须使用 Lombok @Slf4j 注解进行日志输出,禁止直接使用 LoggerFactory.getLogger() | 全栈 | 项目自有 | +| [unified-response](./unified-response.md) | 所有 API 返回值必须使用统一响应封装(Response → SingleResponse / MultiResponse / MapResponse)... | 后端 | 项目自有 | + +--- + +**统计**: 共 8 篇 — 已实现 8 / 计划中 0 / 已废弃 0 diff --git a/docs/conventions/locale-exception-handling.md b/docs/conventions/locale-exception-handling.md new file mode 100644 index 000000000..d47af6441 --- /dev/null +++ b/docs/conventions/locale-exception-handling.md @@ -0,0 +1,127 @@ +--- +name: locale-exception-handling +description: 所有业务异常必须使用 LocaleMessageException,通过 errCode 实现国际化,由全局 HandlerExceptionResolver 统一处理 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 0c4299a1 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/LocaleMessageException.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/servlet/BasicHandlerExceptionResolverConfiguration.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/exception/ExceptionConfiguration.java +--- + +## 解决什么问题 + +如果业务代码中随意抛出 `RuntimeException`、`IllegalArgumentException` 等原生异常,会导致以下问题: + +1. **错误信息无法国际化**:硬编码的中文字符串无法根据用户语言环境切换 +2. **缺少结构化错误码**:前端无法通过 `errCode` 精确判断错误类型并做差异化处理 +3. **异常响应格式不统一**:不同异常类型被 Spring 默认处理器转为不同的 HTTP 响应结构 +4. **敏感信息泄露**:未捕获的异常堆栈可能暴露内部实现细节给客户端 + +框架通过 `LocaleMessageException` + `BasicHandlerExceptionResolverConfiguration` 实现统一的国际化异常处理链: +- `LocaleMessageException` 携带 `errCode`,可选携带占位符参数 +- `LocaleMessage` 从 Spring `MessageSource`(`messages.properties`)读取国际化消息 +- `ServletExceptionHandler` 将所有异常统一转为 `{success, errCode, errMessage}` JSON 响应 + +## 如何使用 + +### 1. 定义国际化消息 + +在 `src/main/resources/messages.properties` 和 `messages_{locale}.properties` 中配置错误码对应的消息: + +```properties +# messages.properties (默认/英文) +user.not.found=User not found +order.amount.invalid=Order amount must be greater than {0} + +# messages_zh_CN.properties (中文) +user.not.found=用户不存在 +order.amount.invalid=订单金额必须大于 {0} +``` + +### 2. 抛出 LocaleMessageException + +| 构造方式 | 说明 | +|----------|------| +| `new LocaleMessageException("err.code")` | 仅指定错误码,自动从 MessageSource 查找消息 | +| `new LocaleMessageException("err.code", "fallback message")` | 指定错误码和回退消息(不走国际化) | +| `LocaleMessageException.of("err.code", arg1, arg2)` | 带占位符参数的国际化消息(推荐) | +| `new LocaleMessageException("err.code", new Object[]{arg1}, cause)` | 带参数和原始异常 | + +### 3. 规则 + +1. **所有业务异常必须使用 `LocaleMessageException`**,禁止直接抛出 `RuntimeException` 或其他原生异常 +2. 错误码采用点分隔的小写命名(如 `user.not.found`、`order.amount.invalid`) +3. 需要动态内容时使用占位符 `{0}`、`{1}`,通过 `LocaleMessageException.of()` 传入参数 +4. Controller 方法无需 try-catch,由全局 `ServletExceptionHandler` 统一处理并返回标准错误响应 + +## 使用实例 + +### ✅ 正确示例 + +```java +// 1. 在 Service 层抛出国际化异常 +@Service +public class UserService { + + public UserEntity getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new LocaleMessageException("user.not.found")); + } + + public void createOrder(BigDecimal amount) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw LocaleMessageException.of("order.amount.invalid", amount); + } + } +} + +// 2. Controller 无需手动处理异常,返回类型仍为统一响应 +@RestController +@RequestMapping("/api/users") +public class UserController { + + @GetMapping("/{id}") + public SingleResponse getById(@PathVariable Long id) { + return SingleResponse.of(userService.getById(id)); + } +} + +// 3. 当 user.not.found 触发时,框架自动返回: +// {"success": false, "errCode": "user.not.found", "errMessage": "用户不存在"} +``` + +### ❌ 错误示例 + +```java +// 错误:直接抛出 RuntimeException,无错误码、不支持国际化 +public UserEntity getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new RuntimeException("用户不存在")); +} + +// 错误:使用 IllegalArgumentException,前端无法识别错误类型 +public void createOrder(BigDecimal amount) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("金额无效"); + } +} + +// 错误:在 Controller 中手动 try-catch 构建错误响应 +@GetMapping("/{id}") +public Response getById(@PathVariable Long id) { + try { + UserEntity user = userService.getById(id); + return SingleResponse.of(user); + } catch (Exception e) { + return Response.buildFailure("error", e.getMessage()); + } +} + +// 错误:自定义异常类但未继承 LocaleMessageException +public class BusinessException extends RuntimeException { + // 不会被全局异常处理器识别为业务异常 +} +``` diff --git a/docs/conventions/meta-table-annotation.md b/docs/conventions/meta-table-annotation.md new file mode 100644 index 000000000..0cc14b1dc --- /dev/null +++ b/docs/conventions/meta-table-annotation.md @@ -0,0 +1,146 @@ +--- +name: meta-table-annotation +description: 通过 @MetaTable/@MetaColumn/@MetaRelation 注解声明表的元数据描述,驱动动态实体生成和元数据查询 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: a59736ac +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/annotation/MetaTable.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/annotation/MetaColumn.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/annotation/MetaRelation.java +--- + +## 解决什么问题 + +在 fast-repository 模块的动态实体生成场景中(如 Groovy 脚本定义的数据表、运行时创建的临时查询实体),需要在没有 JPA Entity 类的情况下描述一张数据库表的结构。传统的做法是手动构建 `TableEntityMetadata` 对象并逐个添加列信息,代码冗长且容易出错: + +```java +// 手动构建元数据 — 繁琐、易错 +TableEntityMetadata metadata = new TableEntityMetadata("com.example.DynamicUser"); +metadata.setTable("t_user", "用户表"); +metadata.addPrimaryKeyColumn(Long.class, "id", "id", GenerationType.IDENTITY, ...); +metadata.addColumn(String.class, "name", "name", "姓名", false, false, true, ...); +``` + +`@MetaTable` / `@MetaColumn` / `@MetaRelation` 注解体系提供了一种**声明式**的元数据描述方式,将表结构信息直接标注在 POJO 类上,由框架自动扫描并转换为 `TableEntityMetadata`,大幅简化了动态实体的定义流程。 + +## 如何使用 + +### 注解说明 + +#### @MetaTable(类级别) + +标注在类上,声明该类对应的数据库表信息。 + +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | String | 是 | 数据库表名称 | +| `desc` | String | 是 | 表的中文说明/备注 | + +#### @MetaColumn(字段级别) + +标注在字段上,声明该字段对应的数据库列信息。 + +| 属性 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `name` | String | 是 | — | 数据库列名称 | +| `desc` | String | 是 | — | 列的中文说明/备注 | +| `primaryKey` | boolean | 否 | `false` | 是否为主键 | +| `type` | ColumnType | 否 | `ColumnType.String` | 字段数据类型 | +| `format` | String | 否 | `""` | 格式化模板(如日期格式) | +| `dependent` | MetaRelation | 否 | 空关联 | 外键依赖关系 | + +#### @MetaRelation(嵌套在 @MetaColumn 中) + +声明字段的外键关联关系,作为 `@MetaColumn.dependent` 的值使用。 + +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `tableName` | String | 是 | 关联的目标表名 | +| `columnName` | String | 是 | 关联的目标列名 | + +#### ColumnType 枚举 + +| 值 | 说明 | +|----|------| +| `Number` | 整数 | +| `Float` | 浮点数 | +| `String` | 字符串 | +| `Date` | 日期 | +| `File` | 文件 | +| `Boolean` | 布尔 | +| `Bytes` | 字节数组 | +| `JSON` | JSON 对象 | +| `Any` | 任意类型 | + +### 命名规范 + +- 注解类统一放在 `com.codingapi.springboot.framework.annotation` 包下 +- `@MetaTable` 只能用在类上(`@Target(ElementType.TYPE)`) +- `@MetaColumn` 只能用在字段上(`@Target(ElementType.FIELD)`) +- 所有注解均为 `@Retention(RetentionPolicy.RUNTIME)`,支持运行时反射读取 + +## 使用实例 + +```java +// ✅ 正确 — 完整的元数据注解声明 +@MetaTable(name = "t_leave_request", desc = "请假申请表") +public class LeaveRequestMeta { + + @MetaColumn(name = "id", desc = "主键ID", primaryKey = true, type = ColumnType.Number) + private Long id; + + @MetaColumn(name = "user_id", desc = "申请人ID", type = ColumnType.Number, + dependent = @MetaRelation(tableName = "t_user", columnName = "id")) + private Long userId; + + @MetaColumn(name = "start_date", desc = "开始日期", type = ColumnType.Date, format = "yyyy-MM-dd") + private String startDate; + + @MetaColumn(name = "end_date", desc = "结束日期", type = ColumnType.Date, format = "yyyy-MM-dd") + private String endDate; + + @MetaColumn(name = "reason", desc = "请假原因", type = ColumnType.String) + private String reason; + + @MetaColumn(name = "approved", desc = "是否批准", type = ColumnType.Boolean) + private Boolean approved; +} + +// ✅ 正确 — 最简声明(使用默认值) +@MetaTable(name = "t_dict", desc = "数据字典") +public class DictMeta { + + @MetaColumn(name = "id", desc = "主键", primaryKey = true) + private Long id; + + @MetaColumn(name = "code", desc = "编码") + private String code; + + @MetaColumn(name = "label", desc = "显示文本") + private String label; +} + +// ❌ 错误 — 缺少 @MetaTable 注解 +public class BadMeta { + @MetaColumn(name = "id", desc = "主键", primaryKey = true) + private Long id; +} + +// ❌ 错误 — @MetaColumn 缺少必填属性 name 和 desc +@MetaTable(name = "t_test", desc = "测试表") +public class IncompleteMeta { + @MetaColumn(desc = "主键") // 缺少 name!编译报错 + private Long id; +} + +// ❌ 错误 — 不要混用 JPA 注解代替 Meta 注解 +@Entity // 这是 JPA 注解,不是元数据注解 +@Table(name = "t_user") +public class UserMeta { + @Id + @Column(name = "id") + private Long id; +} +``` diff --git a/docs/conventions/servlet-exception-resolver.md b/docs/conventions/servlet-exception-resolver.md new file mode 100644 index 000000000..6ca773d9c --- /dev/null +++ b/docs/conventions/servlet-exception-resolver.md @@ -0,0 +1,111 @@ +--- +name: servlet-exception-resolver +description: 使用 HandlerExceptionResolver(而非 @ControllerAdvice)作为全局异常处理机制,返回统一 JSON 响应格式 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 9c0b4ac5 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/servlet/BasicHandlerExceptionResolverConfiguration.java +--- + +## 解决什么问题 + +Spring MVC 中常见的全局异常处理方式是通过 `@ControllerAdvice` + `@ExceptionHandler` 注解实现。这种方式存在以下局限: + +1. **与 Controller 层强耦合**:`@ControllerAdvice` 本质上是一个增强型 Controller,其生命周期和优先级受 DispatcherServlet 的 HandlerAdapter 调度影响,在某些边界场景(如 Filter 链异常、非标准 Handler 类型)下可能无法捕获所有异常。 +2. **多 Advice 优先级冲突**:当项目中引入多个第三方库各自提供 `@ControllerAdvice` 时,需要通过 `@Order` 手动协调优先级,容易产生遗漏或覆盖。 +3. **响应格式不统一**:各 `@ExceptionHandler` 方法可能返回不同的响应结构,导致前端需要适配多种错误格式。 + +本框架采用 Servlet 层面的 `HandlerExceptionResolver` 接口作为全局异常处理入口,直接注册为 Spring Bean,由 DispatcherServlet 在异常解析阶段统一调用,确保所有 Controller 异常都能被拦截并输出统一的 `{success, errCode, errMessage}` JSON 格式。 + +## 如何使用 + +### 核心实现类 + +`BasicHandlerExceptionResolverConfiguration` 是框架内置的自动配置类,通过 `@Bean` 注册一个 `HandlerExceptionResolver` 实例(内部类 `ServletExceptionHandler`)。 + +### 异常处理规则 + +| 异常类型 | errCode | errMessage | 日志级别 | +|---------|---------|------------|---------| +| `LocaleMessageException` | 异常自带的 `errCode` | 国际化消息内容 | WARN | +| 其他异常 | `"system.err"` | `ex.getMessage()` | WARN(含堆栈) | + +### 响应格式 + +所有异常均通过 `MappingJackson2JsonView` 输出以下 JSON 结构: + +```json +{ + "success": false, + "errCode": "错误码", + "errMessage": "错误描述" +} +``` + +### 业务异常抛出方式 + +业务代码应使用 `LocaleMessageException` 抛出异常,支持国际化错误码: + +```java +// 直接使用错误码(从 message.properties 读取消息) +throw new LocaleMessageException("user.not.found"); + +// 带参数的错误码 +throw LocaleMessageException.of("field.invalid", fieldName); + +// 自定义错误消息 +throw new LocaleMessageException("custom.error", "自定义错误信息"); +``` + +### 扩展须知 + +如需新增自定义异常处理逻辑,应修改 `ServletExceptionHandler.resolveException()` 方法中的判断分支,而不是新建额外的 `@ControllerAdvice` 类。这保证了异常处理入口的唯一性。 + +## 使用实例 + +```java +// ✅ 正确 — 业务代码抛出 LocaleMessageException,由框架统一处理 +@GetMapping("/users/{id}") +public SingleResponse getUser(@PathVariable Long id) { + User user = userRepository.findById(id).orElse(null); + if (user == null) { + throw new LocaleMessageException("user.not.found"); + } + return Response.of(user); +} +// 异常被 ServletExceptionHandler 捕获,返回: +// {"success": false, "errCode": "user.not.found", "errMessage": "用户不存在"} + +// ✅ 正确 — 未预期异常也会被捕获 +@PostMapping("/orders") +public SingleResponse createOrder(@RequestBody OrderRequest request) { + // 某个 NPE 或 IllegalArgumentException + return Response.of(orderService.create(request)); +} +// 返回:{"success": false, "errCode": "system.err", "errMessage": "..."} + +// ❌ 错误 — 不要使用 @ControllerAdvice 处理全局异常 +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + Map body = new HashMap<>(); + body.put("code", 500); // 格式与框架不一致! + body.put("msg", ex.getMessage()); + return ResponseEntity.status(500).body(body); + } +} + +// ❌ 错误 — 不要在 Controller 中自行 try-catch 并构造响应 +@GetMapping("/users/{id}") +public Map getUser(@PathVariable Long id) { + try { + User user = userService.findById(id); + return Map.of("data", user); + } catch (Exception e) { + return Map.of("error", e.getMessage()); // 绕过了框架异常处理! + } +} +``` diff --git a/docs/conventions/slf4j-logging.md b/docs/conventions/slf4j-logging.md new file mode 100644 index 000000000..7bab85aff --- /dev/null +++ b/docs/conventions/slf4j-logging.md @@ -0,0 +1,118 @@ +--- +name: slf4j-logging +description: 所有 Java 类必须使用 Lombok @Slf4j 注解进行日志输出,禁止直接使用 LoggerFactory.getLogger() +status: 已实现 +scope: 全栈 +source: 项目自有 +last_commit: N/A +code_files: [] +--- + +## 解决什么问题 + +如果每个类都手动创建 Logger 实例,会导致以下问题: + +1. **样板代码冗余**:每个类都需要声明 `private static final Logger log = LoggerFactory.getLogger(XxxClass.class)`,增加无意义的代码量 +2. **类名引用易出错**:复制粘贴时忘记修改 `getLogger()` 中的类名参数,导致日志输出错误的来源标识 +3. **重构风险**:重命名类后若未同步修改 Logger 声明,日志追踪将指向旧类名 +4. **风格不一致**:部分开发者使用 `Logger`、部分使用 `Log`、部分使用 `logger` 等不同的变量命名 + +Lombok 的 `@Slf4j` 注解在编译期自动生成 `private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(当前类.class);`,消除上述所有问题。 + +## 如何使用 + +### 规则 + +1. **所有需要日志输出的 Java 类必须在类级别添加 `@Slf4j` 注解** +2. 使用生成的 `log` 变量进行日志输出(`log.info()`、`log.warn()`、`log.error()` 等) +3. **禁止**在代码中出现 `LoggerFactory.getLogger()` 调用 +4. **禁止**手动声明 `Logger` / `Log` 字段 +5. 日志消息使用 SLF4J 占位符 `{}` 格式,不使用字符串拼接 +6. 异常日志应将异常对象作为最后一个参数传入,而非调用 `e.getMessage()` + +### 注意事项 + +- `@Slf4j` 是 Lombok 注解,需确保项目中已引入 Lombok 依赖且 IDE 已安装 Lombok 插件 +- 对于内部静态类,Lombok 会自动生成对应内部类的 Logger,无需额外处理 +- 如需自定义 Logger 名称(极少数场景),可使用 `@Slf4j(topic = "custom.topic")` + +## 使用实例 + +### ✅ 正确示例 + +```java +@Slf4j +@Service +public class UserService { + + public UserEntity getById(Long id) { + log.info("查询用户, id={}", id); + UserEntity user = userRepository.findById(id) + .orElseThrow(() -> new LocaleMessageException("user.not.found")); + log.debug("用户查询成功, id={}, name={}", id, user.getName()); + return user; + } + + public void deleteUser(Long id) { + try { + userRepository.deleteById(id); + log.info("用户删除成功, id={}", id); + } catch (Exception e) { + // 异常对象作为最后一个参数,SLF4J 会自动打印堆栈 + log.error("用户删除失败, id={}", id, e); + throw e; + } + } +} + +// 内部静态类也可正常使用 +@Configuration +public class BasicHandlerExceptionResolverConfiguration { + + @Slf4j + public static class ServletExceptionHandler implements HandlerExceptionResolver { + @Override + public ModelAndView resolveException(...) { + log.warn("controller exception:{}", ex.getLocalizedMessage(), ex); + // ... + } + } +} +``` + +### ❌ 错误示例 + +```java +// 错误:手动创建 Logger,存在样板代码和类名引用风险 +@Service +public class UserService { + private static final Logger logger = LoggerFactory.getLogger(UserService.class); + + public UserEntity getById(Long id) { + logger.info("查询用户, id=" + id); // 还使用了字符串拼接 + // ... + } +} + +// 错误:使用不同变量名,风格不统一 +@Service +public class OrderService { + private static final Log LOG = LogFactory.getLog(OrderService.class); + // ... +} + +// 错误:异常日志未传入异常对象 +try { + doSomething(); +} catch (Exception e) { + log.error("操作失败: " + e.getMessage()); // 丢失堆栈信息 +} + +// 错误:缺少 @Slf4j 注解却直接使用 log 变量(编译报错) +@Service +public class PaymentService { + public void pay() { + log.info("开始支付"); // 编译错误:找不到符号 log + } +} +``` diff --git a/docs/conventions/unified-response.md b/docs/conventions/unified-response.md new file mode 100644 index 000000000..35c9292bc --- /dev/null +++ b/docs/conventions/unified-response.md @@ -0,0 +1,117 @@ +--- +name: unified-response +description: 所有 API 返回值必须使用统一响应封装(Response → SingleResponse / MultiResponse / MapResponse),禁止直接返回裸对象 +status: 已实现 +scope: 后端 +source: 项目自有 +last_commit: 841c49b8 +code_files: + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/response/Response.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/response/SingleResponse.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/response/MultiResponse.java + - springboot-starter/src/main/java/com/codingapi/springboot/framework/dto/response/MapResponse.java +--- + +## 解决什么问题 + +如果 Controller 直接返回裸对象(如 `UserEntity`、`List`、`Map`),会导致以下问题: + +1. **前后端协议不一致**:不同接口返回结构各异,前端无法统一解析成功/失败状态 +2. **错误信息缺失**:裸对象不包含 `errCode` / `errMessage`,异常处理与正常响应格式不统一 +3. **分页元数据丢失**:直接返回 `List` 时缺少 `total` 等分页信息,前端无法正确渲染分页组件 +4. **扩展困难**:后续如需增加请求 ID、时间戳等通用字段,需逐个修改所有接口 + +## 如何使用 + +框架提供四种响应类型,均继承自 `Response` 基类: + +| 类型 | 用途 | 工厂方法 | +|------|------|----------| +| `Response` | 无数据的操作结果(仅 success/errCode/errMessage) | `Response.buildSuccess()` / `Response.buildFailure(errCode, errMessage)` | +| `SingleResponse` | 返回单个对象 | `SingleResponse.of(data)` / `SingleResponse.empty()` | +| `MultiResponse` | 返回列表(支持分页) | `MultiResponse.of(collection, total)` / `MultiResponse.of(page)` / `MultiResponse.of(collection)` / `MultiResponse.empty()` | +| `MapResponse` | 返回键值对数据 | `MapResponse.create().add(key, value)` / `MapResponse.empty()` | + +### 规则 + +1. **Controller 方法的返回类型必须是上述四种之一**,不得返回实体类、集合或 Map 裸对象 +2. 成功时使用 `of()` / `buildSuccess()` 等静态工厂方法创建响应 +3. 失败时优先通过抛出 `LocaleMessageException` 由全局异常处理器自动构建错误响应;仅在非异常场景下手动调用 `Response.buildFailure()` +4. `MultiResponse` 接收 Spring Data `Page` 对象时,使用 `MultiResponse.of(page)` 自动提取 `content` 和 `totalElements` + +## 使用实例 + +### ✅ 正确示例 + +```java +@RestController +@RequestMapping("/api/users") +public class UserQueryController { + + @Autowired + private UserQueryService userQueryService; + + // 返回单个对象 + @GetMapping("/{id}") + public SingleResponse getById(@PathVariable Long id) { + return SingleResponse.of(userQueryService.getById(id)); + } + + // 返回列表(带分页) + @PostMapping("/list") + public MultiResponse list(@RequestBody SearchRequest searchRequest) { + return MultiResponse.of(userQueryService.list(searchRequest)); + } + + // 返回键值对 + @GetMapping("/stats") + public MapResponse stats() { + return MapResponse.create() + .add("totalUsers", 100) + .add("activeUsers", 85); + } + + // 无数据操作 + @DeleteMapping("/{id}") + public Response delete(@PathVariable Long id) { + userCommandService.delete(id); + return Response.buildSuccess(); + } +} +``` + +### ❌ 错误示例 + +```java +@RestController +@RequestMapping("/api/users") +public class UserQueryController { + + // 错误:直接返回实体类 + @GetMapping("/{id}") + public UserEntity getById(@PathVariable Long id) { + return userQueryService.getById(id); + } + + // 错误:直接返回 List,缺少 total 和统一包装 + @GetMapping("/list") + public List list() { + return userQueryService.listAll(); + } + + // 错误:直接返回 Map + @GetMapping("/stats") + public Map stats() { + Map map = new HashMap<>(); + map.put("totalUsers", 100); + return map; + } + + // 错误:使用 ResponseEntity 包装裸对象 + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + userCommandService.delete(id); + return ResponseEntity.ok().build(); + } +} +```