# 4.11 扩展性设计 :::tip 概述 系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、自动参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。 ::: ## 扩展机制总览 | 机制 | 位置 | 作用 | 扩展方式 | |------|------|------|----------| | 属性配置 | Logic 子类 | 声明式行为定义 | 设置属性值 | | 生命周期钩子 | Logic 子类 | 注入定制逻辑 | 重写钩子方法 | | 自动参数校验 | Validate + Logic | 参数格式校验 | 创建 Validate + 配置 `$validateClass` | | 中间件链 | middleware/ | 请求拦截处理 | 创建中间件 + 注册 | | 注解系统 | attribute/ | 声明式元数据 | 创建注解 + 中间件读取 | | 服务层复用 | service/ | 跨模块通用能力 | 创建 Service 类 | ## 1. 属性配置模式 Logic 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。 ### 完整属性清单 ```php class NewModuleLogic extends BaseLogic { /** * 关联的模型类 * * 所有增删改查操作都会基于此模型执行,子类必须指定 * * @var string */ protected string $modelClass = NewModule::class; /** * 参数验证器类 * * 配置后,新增 / 修改等操作会自动调用该验证器进行参数校验, * 校验不通过会直接抛出异常,无需在业务代码中手动验证 * * @var string */ protected string $validateClass = NewModuleValidate::class; /** * 需要自动处理的文件上传字段 * * 提交数据中若包含这些字段,框架会自动完成文件上传、存储与路径回填 * * @var array */ protected array $fileFields = ['cover', 'avatar']; /** * 文件保存的子目录 * * 上传的文件会保存在该子目录下(相对于文件存储根目录) * * @var string */ protected string $fileSaveDir = 'new_module'; /** * 富文本字段 * * 这些字段会做富文本特殊处理(如 XSS 过滤、图片路径修正等) * * @var array */ protected array $contentFields = ['content']; /** * 列表查询中需要 LIKE 模糊匹配的字段 * * 请求参数中若带有这些字段,会自动以 LIKE '%value%' 的方式参与查询 * * @var array */ protected array $pageLikeFields = ['name', 'title']; /** * 列表查询中需要精确匹配的字段 * * 请求参数中若带有这些字段,会自动以 = 的方式参与查询 * * @var array */ protected array $pageEqFields = ['status', 'type']; /** * 列表查询的默认排序规则 * * field:排序字段;type:排序方式(asc / desc) * * @var array */ protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc']; /** * 唯一性校验字段 * * 支持两种写法: * - 字符串:单字段全局唯一,如 'code' * - 数组:多字段组合唯一,如 ['name', 'pid'] 表示同层级下 name 唯一 * * 新增 / 修改时会自动校验,冲突则抛出异常 * * @var array */ protected array $uniqueFields = ['code', ['name', 'pid']]; /** * 枚举字段与显示名的映射 * * 键为数据表字段名,值为对应的枚举字典 key, * 返回数据时会自动附加对应的枚举显示名(如 status_text) * * @var array */ protected array $serializeMaps = ['status' => 'module_status']; /** * 数据权限-按创建人过滤的字段 * * 配置后,非管理员用户只能看到自己创建的数据 * * @var string */ protected string $dataScopeUserField = 'create_user'; /** * 数据权限-按部门过滤的字段 * * 配置后,非管理员用户只能看到本部门(及子部门)的数据 * * @var string */ protected string $dataScopeDeptField = 'dept_id'; /** * 租户隔离字段 * * 继承 BaseTenantLogic 时自动启用,所有查询会自动追加 tenant_id 条件, * 保证多租户之间的数据完全隔离 * * @var string */ // protected string $tenantScopeField = 'tenant_id'; /** * 树形结构的父级字段 * * 配置后,列表接口会自动将平铺数据组装成树形结构 * * @var string */ protected string $treeParentField = 'parent_id'; /** * 树形结构的搜索字段 * * 树形搜索时对该字段做模糊匹配,命中节点会自动保留其父链 * * @var string */ protected string $treeLikeField = 'name'; } ``` ### 属性驱动的自动行为 | 属性 | 触发的自动行为 | |------|---------------| | `validateClass` | add/update 时自动调用验证器校验参数 | | `fileFields` | add/update 时迁移临时文件,查询时补全域名 | | `contentFields` | add/update 时处理富文本媒体文件,查询时替换占位符 | | `pageLikeFields` | pageList 时自动构建 LIKE 查询 | | `pageEqFields` | pageList 时自动构建精确查询 | | `pageOrderBy` | pageList/allList 时自动应用默认排序 | | `uniqueFields` | add/update 时自动校验唯一性 | | `serializeMaps` | 查询时自动补全 `{字段名}Text` 枚举显示名 | | `dataScopeUserField` | 查询时自动按创建人过滤 | | `dataScopeDeptField` | 查询时自动按部门过滤 | | `tenantScopeField` | 查询时自动叠加 tenant_id 条件,新增时自动填充 | ## 2. 生命周期钩子 通过重写钩子注入定制逻辑,无需修改基类。钩子方法在基类中定义为空实现或默认返回,子类按需覆盖。 ### 钩子清单 | 钩子 | 时机 | 参数 | 返回值 | 可拦截 | |------|------|------|--------|--------| | `beforeAdd` | 新增前 | `$data` | 处理后的 `$data` | ✅ 抛异常 | | `afterAdd` | 新增后 | `$id, $data` | void | ❌ | | `beforeUpdate` | 修改前 | `$id, $data` | 处理后的 `$data` | ✅ 抛异常 | | `afterUpdate` | 修改后 | `$id, $data` | void | ❌ | | `beforeDelete` | 删除前 | `$id` | void | ✅ 抛异常 | | `afterDelete` | 删除后 | `$id` | void | ❌ | | `beforeBatchDelete` | 批量删除前 | `$ids` | void | ✅ 抛异常 | | `afterBatchDelete` | 批量删除后 | `$ids` | void | ❌ | | `afterDetail` | 详情查询后 | `$id, &$data` | void(引用传递) | ❌ | | `afterPageList` | 列表查询后 | `&$records, $params` | void(引用传递,分页与全量列表均触发) | ❌ | ### 典型用法 ```php class ArticleLogic extends BaseTenantLogic { /** * 新增前处理:设置默认值 * * @param array $data 待新增的数据 * @return array 处理后的数据 */ protected function beforeAdd(array $data): array { // sort:排序字段,若未传则默认为 0 $data['sort'] = $data['sort'] ?? 0; // author:作者字段,若未传则尝试从当前登录用户信息中取用户名 $data['author'] = $data['author'] ?? request()->userInfo->username ?? ''; return $data; } /** * 新增后处理:保存关联数据 * * @param int $id 新增文章的主键 ID * @param array $data 新增时提交的原始数据 * @return void */ protected function afterAdd(int $id, array $data): void { // 若提交数据中带有 tags(标签数组),则批量写入文章-标签关联表 if (!empty($data['tags'])) { ArticleTag::insertAll(array_map(fn($tag) => [ 'article_id' => $id, 'tag' => $tag ], $data['tags'])); } } /** * 修改前处理:过滤不可修改字段 * * @param int $id 文章 ID * @param array $data 待更新的数据 * @return array 过滤后的数据 */ protected function beforeUpdate(int $id, array $data): array { // author(作者)字段不允许通过更新接口修改,直接剔除 unset($data['author']); return $data; } /** * 删除前处理:业务约束校验 * * @param int $id 文章 ID * @return void * @throws \Exception 文章下存在评论时抛出 */ protected function beforeDelete(int $id): void { // 若该文章下存在评论,则不允许删除 $commentCount = Comment::where('article_id', $id)->count(); if ($commentCount > 0) { throw new \Exception('该文章下存在评论,不可删除'); } } /** * 详情查询后处理:补充单条记录的关联数据 * * @param int $id 文章 ID * @param array &$data 详情数据(引用传递) * @return void */ protected function afterDetail(int $id, array &$data): void { $data['tags'] = ArticleTag::where('article_id', $id)->column('tag'); $data['commentCount'] = Comment::where('article_id', $id)->count(); } /** * 列表查询后处理:批量补充关联数据 * * @param array &$records 记录列表(引用传递) * @param array $params 查询参数 * @return void */ protected function afterPageList(array &$records, array $params): void { if (empty($records)) return; // 提取当前页所有文章 ID $ids = array_column($records, 'id'); // 一次性统计每篇文章的评论数,返回 [article_id => count] 结构 $counts = Comment::whereIn('article_id', $ids) ->group('article_id') ->column('count(*)', 'article_id'); // 将统计结果回填到每条记录 foreach ($records as &$record) { $record['commentCount'] = $counts[$record['id']] ?? 0; } } } ``` ### 执行顺序 **add 流程:** `beforeAdd` → `validateData` → `camel_to_snake` → `processTenantId` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::create` → `afterAdd` **update 流程:** `beforeUpdate` → `validateData` → `camel_to_snake` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::find + save` → `afterUpdate` **delete 流程:** `beforeDelete` → `is_delete = 1` → `afterDelete` **pageList/allList 流程:** `buildQuery` → `排序` → `分页` → `processFile` → `processContent` → `processSerializeMaps` → `filterFields` → `afterPageList` ## 3. 自动参数校验 通过在 Logic 中配置 `$validateClass`,add/update 时自动调用验证器,无需手动调用。 ### 扩展方式 ```php // 1. 创建验证器 // app/validate/NewModuleValidate.php class NewModuleValidate extends Validate { protected $rule = [ 'name' => 'require|max:100', 'code' => 'require|unique:new_module', ]; protected $message = [ 'name.require' => '名称不能为空', 'code.unique' => '编码已存在', ]; protected $scene = [ 'add' => ['name', 'code'], 'update' => ['name'], ]; } // 2. 在 Logic 中配置 class NewModuleLogic extends BaseLogic { protected string $validateClass = NewModuleValidate::class; // ... } ``` 详见 [3.4 验证器开发](/zh/guide/validator.html)。 ## 4. 中间件机制 通过中间件链式处理请求,可灵活添加新的拦截逻辑。 ### 现有中间件 ```text 请求进入 │ ▼ CorsMiddleware(全局) ← OPTIONS 预检 + 跨域响应头 │ ▼ AuthMiddleware(路由级) ← JWT 认证 + 权限校验 → 401/403 │ 注入 userInfo ▼ TenantMiddleware(路由级) ← 租户上下文注入 → 403 │ 注入 tenantId ▼ DemoMiddleware(路由级) ← 演示环境拦截写操作 → 403 │ ▼ LogMiddleware(路由级) ← 操作日志记录 │ ▼ 控制器方法 ``` ### 创建自定义中间件 ```php // 1. 创建中间件类 // app/middleware/RateLimitMiddleware.php namespace app\middleware; use think\facade\Cache; class RateLimitMiddleware { public function handle($request, \Closure $next) { $ip = $request->ip(); $key = 'rate_limit_' . $ip; $count = Cache::get($key, 0); if ($count > 100) { return json(['code' => 1, 'msg' => '请求过于频繁'], 429); } Cache::set($key, $count + 1, 60); return $next($request); } } // 2. 注册中间件 // 方式一:全局注册(app/middleware.php) return [ \app\middleware\RateLimitMiddleware::class, ]; // 方式二:路由级注册(route/app.php) Route::group('api', function () { // ... })->middleware(\app\middleware\RateLimitMiddleware::class); // 方式三:控制器级注册 class SomeController extends BaseController { protected $middleware = [ \app\middleware\RateLimitMiddleware::class, ]; } ``` ## 5. 注解系统 通过 PHP 8 注解声明元数据,中间件通过反射读取,业务代码零侵入。 ### 现有注解 | 注解 | 目标 | 说明 | 读取方 | |------|------|------|--------| | `#[Log]` | 方法 | 操作日志(标题、类型、描述) | LogMiddleware | | `#[Permission]` | 方法/类 | 权限校验(权限编码) | AuthMiddleware | | `#[DemoAllow]` | 方法 | 演示环境放行标记 | DemoMiddleware | ### 使用示例 ```php // 操作日志 #[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')] // 权限校验 #[Permission('sys:user:add', '添加用户')] // 演示环境放行(允许在演示环境执行写操作) #[DemoAllow] // 组合使用 #[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')] #[Permission('sys:article:delete', '删除文章')] public function delete(int $id): Json { return parent::_remove($this->logic, $id); } ``` ### 创建自定义注解 ```php // 1. 定义注解类 // app/attribute/Cacheable.php namespace app\attribute; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] class Cacheable { public function __construct( public string $key = '', public int $ttl = 3600, ) {} } // 2. 在控制器方法上使用 #[Cacheable(key: 'article_list', ttl: 1800)] public function list(): Json { ... } // 3. 在中间件中读取 // 通过 AttributeService 或反射获取注解实例 $ref = new \ReflectionMethod($controller, $method); $attrs = $ref->getAttributes(Cacheable::class); if (!empty($attrs)) { $cacheable = $attrs[0]->newInstance(); // $cacheable->key, $cacheable->ttl } ``` ### 注解读取服务 `AttributeService` 提供统一的注解读取接口(带缓存): ```php AttributeService::getLog($class, $method); // 获取 Log 注解 AttributeService::getPermission($class, $method); // 获取 Permission 注解 ``` ## 6. 服务层复用 通用能力封装为 Service,跨模块复用,无状态、无副作用。 ### 现有服务清单 | 服务 | 文件 | 能力 | |------|------|------| | `JwtService` | `app/service/JwtService.php` | 登录、刷新、解析 Token | | `PasswordService` | `app/service/PasswordService.php` | bcrypt 双重加盐加密 | | `DictService` | `app/service/DictService.php` | 字典查询(两级缓存) | | `ParamService` | `app/service/ParamService.php` | 系统参数查询(两级缓存) | | `CaptchaService` | `app/service/CaptchaService.php` | 图片验证码生成与校验 | | `RequestInfoService` | `app/service/RequestInfoService.php` | 客户端 IP、UA、归属地解析 | | `AttributeService` | `app/service/AttributeService.php` | 注解反射读取 + 缓存 | | `GeneratorService` | `app/service/GeneratorService.php` | 代码生成 | | `ExcelService` | `app/service/ExcelService.php` | Excel 导入导出 | | `DbMigrateService` | `app/service/DbMigrateService.php` | 跨数据库迁移引擎 | | `DbSchemaBuilder` | `app/service/DbSchemaBuilder.php` | DDL 生成器(跨库映射) | ### 使用示例 ```php // 字典服务 DictService::getText('gender', 1); // → '男' DictService::getValue('user_status', '启用'); // → 1 DictService::getOptions('gender'); // → [['value'=>0,'label'=>'女'], ...] // 密码服务 $encrypted = PasswordService::encrypt('123456'); // → ['password'=>'...', 'salt'=>'...'] $default = PasswordService::getDefaultPasswordPlain(); // → '123456' // Excel 服务 ExcelService::importWithMap($filePath, $headerMap); ExcelService::chunkExport($headers, $callback, '导出.xlsx', 500); // 请求信息 $requestInfo = RequestInfoService::create(request()); $ip = $requestInfo->getIp(); // 客户端 IP(经 ProxyMiddleware 还原真实 IP) $location = $requestInfo->getIpLocation(); // IP 归属地(基于 zoujingli/ip2region) $os = $requestInfo->getOs(); // 操作系统 $browser = $requestInfo->getBrowser(); // 浏览器 ``` ### 创建自定义服务 ```php // app/service/NotificationService.php namespace app\service; class NotificationService { // 发送站内通知 public static function send(int $userId, string $title, string $content): void { \app\model\Notice::create([ 'user_id' => $userId, 'title' => $title, 'content' => $content, 'status' => 0, ]); } // 批量发送 public static function batchSend(array $userIds, string $title, string $content): void { foreach ($userIds as $userId) { self::send($userId, $title, $content); } } } // 使用 NotificationService::send($userId, '系统通知', '您有一条新消息'); ``` ## 扩展性设计原则 ### 开闭原则(OCP) | 扩展场景 | 做法 | 不应该做 | |----------|------|----------| | 新增 CRUD 模块 | 创建 Controller/Logic/Model/Validate | 修改基类 | | 新增查询条件 | 设置 `pageLikeFields`/`pageEqFields` | 重写 `buildQuery` | | 新增文件处理 | 设置 `fileFields` | 手动调用 `save_file` | | 新增校验规则 | 创建 Validate + 配置 `validateClass` | 在 Controller 手动 validate | | 新增请求拦截 | 创建中间件 | 修改现有中间件 | | 新增操作日志 | 添加 `#[Log]` 注解 | 在方法内手动记录日志 | | 新增权限节点 | 添加 `#[Permission]` 注解 | 在方法内手动校验权限 | | 新增通用能力 | 创建 Service | 在 Logic 中写静态方法 | ### 最小改动原则 新增一个完整的业务模块,只需要: 1. 建表(含公共字段) 2. 创建 Model(继承 BaseModel,声明表名) 3. 创建 Validate(定义规则 + 场景) 4. 创建 Logic(继承 BaseLogic/BaseTenantLogic,配置属性) 5. 创建 Controller(继承 BaseController,初始化 Logic,CRUD 一行调用) 6. 注册路由 **不需要修改的文件:** 基类、中间件、配置文件、服务层。