签入版本
This commit is contained in:
@@ -1,311 +1,189 @@
|
||||
# 4.11 扩展性设计
|
||||
|
||||
:::tip 概述
|
||||
系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、自动参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。
|
||||
系统通过六种机制保证良好的扩展性:属性配置、生命周期钩子、FormRequest 参数校验、中间件链、注解系统、服务层复用。新增模块无需修改框架代码,只需继承基类 + 配置属性 + 按需重写钩子。
|
||||
:::
|
||||
|
||||
## 扩展机制总览
|
||||
|
||||
| 机制 | 位置 | 作用 | 扩展方式 |
|
||||
|------|------|------|----------|
|
||||
| 属性配置 | Logic 子类 | 声明式行为定义 | 设置属性值 |
|
||||
| 生命周期钩子 | Logic 子类 | 注入定制逻辑 | 重写钩子方法 |
|
||||
| 自动参数校验 | Validate + Logic | 参数格式校验 | 创建 Validate + 配置 `$validateClass` |
|
||||
| 中间件链 | middleware/ | 请求拦截处理 | 创建中间件 + 注册 |
|
||||
| 注解系统 | attribute/ | 声明式元数据 | 创建注解 + 中间件读取 |
|
||||
| 服务层复用 | service/ | 跨模块通用能力 | 创建 Service 类 |
|
||||
| 属性配置 | Service 子类 | 声明式行为定义 | 设置属性值 |
|
||||
| 生命周期钩子 | Service 子类 | 注入定制逻辑 | 重写钩子方法 |
|
||||
| 参数格式校验 | FormRequest | HTTP 入口拦截非法输入 | 创建 Request + 类型注入 |
|
||||
| 中间件链 | `Http/Middleware/` | 请求拦截处理 | 创建中间件 + 注册别名 |
|
||||
| 注解系统 | `Attributes/` | 声明式元数据 | 创建注解 + 中间件反射读取 |
|
||||
| 服务层复用 | `Services/` | 跨模块通用能力 | 创建 Service 类 |
|
||||
|
||||
## 1. 属性配置模式
|
||||
|
||||
Logic 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。
|
||||
Service 层通过属性配置声明行为,新增模块只需设置属性,无需编写 CRUD 代码。
|
||||
|
||||
### 完整属性清单
|
||||
### 完整属性清单(摘自 `app/Services/ArticleService.php` 实际代码)
|
||||
|
||||
```php
|
||||
class NewModuleLogic extends BaseLogic
|
||||
class ArticleService extends BaseTenantService
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* 所有增删改查操作都会基于此模型执行,子类必须指定
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $modelClass = NewModule::class;
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps;
|
||||
// 数据权限能力按需 use HasDataScope
|
||||
|
||||
/**
|
||||
* 参数验证器类
|
||||
*
|
||||
* 配置后,新增 / 修改等操作会自动调用该验证器进行参数校验,
|
||||
* 校验不通过会直接抛出异常,无需在业务代码中手动验证
|
||||
*
|
||||
* @var string
|
||||
* 绑定的模型类
|
||||
*/
|
||||
protected string $validateClass = NewModuleValidate::class;
|
||||
protected string $modelClass = Article::class;
|
||||
|
||||
/**
|
||||
* 需要自动处理的文件上传字段
|
||||
* 单文件字段:保存时迁移临时文件,读取时补全域名
|
||||
*
|
||||
* 提交数据中若包含这些字段,框架会自动完成文件上传、存储与路径回填
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $fileFields = ['cover', 'avatar'];
|
||||
protected array $fileFields = ['cover'];
|
||||
|
||||
/**
|
||||
* 文件保存的子目录
|
||||
* 多文件字段:保存时逐个迁移,读取时逐个补全域名
|
||||
*
|
||||
* 上传的文件会保存在该子目录下(相对于文件存储根目录)
|
||||
*
|
||||
* @var string
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected string $fileSaveDir = 'new_module';
|
||||
protected array $multiFileFields = ['images'];
|
||||
|
||||
/**
|
||||
* 富文本字段
|
||||
*
|
||||
* 这些字段会做富文本特殊处理(如 XSS 过滤、图片路径修正等)
|
||||
*
|
||||
* @var array
|
||||
* 文件保存子目录
|
||||
*/
|
||||
protected array $contentFields = ['content'];
|
||||
protected string $fileSaveDir = 'article';
|
||||
|
||||
/**
|
||||
* 列表查询中需要 LIKE 模糊匹配的字段
|
||||
* 分页时模糊匹配的字段
|
||||
*
|
||||
* 请求参数中若带有这些字段,会自动以 LIKE '%value%' 的方式参与查询
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $pageLikeFields = ['name', 'title'];
|
||||
protected array $pageLikeFields = ['title', 'author'];
|
||||
|
||||
/**
|
||||
* 列表查询中需要精确匹配的字段
|
||||
* 分页时精确匹配的字段
|
||||
*
|
||||
* 请求参数中若带有这些字段,会自动以 = 的方式参与查询
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $pageEqFields = ['status', 'type'];
|
||||
protected array $pageEqFields = ['category_id', 'status'];
|
||||
|
||||
/**
|
||||
* 列表查询的默认排序规则
|
||||
* 默认排序规则(多字段)
|
||||
*
|
||||
* field:排序字段;type:排序方式(asc / desc)
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, array<string, string>>
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 唯一性校验字段
|
||||
* 字典翻译配置:[字段 => 字典编码]
|
||||
*
|
||||
* 支持两种写法:
|
||||
* - 字符串:单字段全局唯一,如 'code'
|
||||
* - 数组:多字段组合唯一,如 ['name', 'pid'] 表示同层级下 name 唯一
|
||||
*
|
||||
* 新增 / 修改时会自动校验,冲突则抛出异常
|
||||
*
|
||||
* @var array
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $uniqueFields = ['code', ['name', 'pid']];
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'article_status',
|
||||
];
|
||||
|
||||
/**
|
||||
* 枚举字段与显示名的映射
|
||||
*
|
||||
* 键为数据表字段名,值为对应的枚举字典 key,
|
||||
* 返回数据时会自动附加对应的枚举显示名(如 status_text)
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = ['status' => 'module_status'];
|
||||
|
||||
/**
|
||||
* 数据权限-按创建人过滤的字段
|
||||
*
|
||||
* 配置后,非管理员用户只能看到自己创建的数据
|
||||
*
|
||||
* @var string
|
||||
* 数据权限的用户字段(data_scope=3 仅本人时按此字段过滤)
|
||||
*/
|
||||
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';
|
||||
}
|
||||
```
|
||||
|
||||
其它可声明属性(`uniqueFields`、`contentFields`、`dataScopeDeptField`、`tenantScopeField`、`treeParentField` / `treeLikeField` 等)均在 `BaseService` 中声明,完整清单见 [4.6 核心基类体系](/zh/architecture/base-classes.html)。
|
||||
|
||||
### 属性驱动的自动行为
|
||||
|
||||
| 属性 | 触发的自动行为 |
|
||||
|------|---------------|
|
||||
| `validateClass` | add/update 时自动调用验证器校验参数 |
|
||||
| `fileFields` | add/update 时迁移临时文件,查询时补全域名 |
|
||||
| `multiFileFields` | 逗号分隔多文件逐个迁移/补全 |
|
||||
| `contentFields` | add/update 时处理富文本媒体文件,查询时替换占位符 |
|
||||
| `pageLikeFields` | pageList 时自动构建 LIKE 查询 |
|
||||
| `pageEqFields` | pageList 时自动构建精确查询 |
|
||||
| `pageOrderBy` | pageList/allList 时自动应用默认排序 |
|
||||
| `uniqueFields` | add/update 时自动校验唯一性 |
|
||||
| `serializeMaps` | 查询时自动补全 `{字段名}Text` 枚举显示名 |
|
||||
| `dataScopeUserField` | 查询时自动按创建人过滤 |
|
||||
| `dataScopeDeptField` | 查询时自动按部门过滤 |
|
||||
| `tenantScopeField` | 查询时自动叠加 tenant_id 条件,新增时自动填充 |
|
||||
| `pageOrderBy` / `orderFieldAllow` | 自动应用默认排序 / 动态排序白名单 |
|
||||
| `uniqueFields` | add/update 时自动校验唯一性(BizException 拦截) |
|
||||
| `serializeMaps` | 查询时自动补全 `{字段}Text` 字典显示名 |
|
||||
| `dataScopeUserField` / `dataScopeDeptField` | 查询时自动按创建人/部门过滤 |
|
||||
| `tenantScopeField` | 查询时自动叠加 `tenant_id` 条件,新增时自动填充 |
|
||||
| `treeParentField` / `treeLikeField` | 树形列表自动组装 / 子树模糊搜索 |
|
||||
|
||||
:::tip 未启用的属性是空操作
|
||||
`BaseService` 为 6 个 Trait 的方法提供占位实现,子类 `use` 对应 Trait 后真实实现才生效(占位覆盖机制)。属性声明了但未 `use` 对应 Trait 时,对应自动行为不会执行。
|
||||
:::
|
||||
|
||||
## 2. 生命周期钩子
|
||||
|
||||
通过重写钩子注入定制逻辑,无需修改基类。钩子方法在基类中定义为空实现或默认返回,子类按需覆盖。
|
||||
通过重写钩子注入定制逻辑,无需修改基类。钩子方法在 `HasCrudOperations` 中定义为空实现或默认返回,子类按需覆盖。
|
||||
|
||||
### 钩子清单
|
||||
|
||||
| 钩子 | 时机 | 参数 | 返回值 | 可拦截 |
|
||||
|------|------|------|--------|--------|
|
||||
| `beforeAdd` | 新增前 | `$data` | 处理后的 `$data` | ✅ 抛异常 |
|
||||
| `afterAdd` | 新增后 | `$id, $data` | void | ❌ |
|
||||
| `afterAdd` | 新增后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 |
|
||||
| `beforeUpdate` | 修改前 | `$id, $data` | 处理后的 `$data` | ✅ 抛异常 |
|
||||
| `afterUpdate` | 修改后 | `$id, $data` | void | ❌ |
|
||||
| `afterUpdate` | 修改后 | `$id, $data` | void(事务内) | ✅ 抛异常回滚 |
|
||||
| `beforeDelete` | 删除前 | `$id` | void | ✅ 抛异常 |
|
||||
| `afterDelete` | 删除后 | `$id` | void | ❌ |
|
||||
| `afterDelete` | 删除后 | `$id` | void(事务内) | ✅ 抛异常回滚 |
|
||||
| `beforeBatchDelete` | 批量删除前 | `$ids` | void | ✅ 抛异常 |
|
||||
| `afterBatchDelete` | 批量删除后 | `$ids` | void | ❌ |
|
||||
| `afterBatchDelete` | 批量删除后 | `$ids` | void(事务内) | ✅ 抛异常回滚 |
|
||||
| `afterDetail` | 详情查询后 | `$id, &$data` | void(引用传递) | ❌ |
|
||||
| `afterPageList` | 列表查询后 | `&$records, $params` | void(引用传递,分页与全量列表均触发) | ❌ |
|
||||
|
||||
### 典型用法
|
||||
### 典型用法(摘自项目实际代码)
|
||||
|
||||
```php
|
||||
class ArticleLogic extends BaseTenantLogic
|
||||
// app/Services/DeptService.php —— 删除前校验(抛 BizException 拦截删除并回滚)
|
||||
protected function beforeDelete(int $id): void
|
||||
{
|
||||
/**
|
||||
* 新增前处理:设置默认值
|
||||
*
|
||||
* @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;
|
||||
$hasChildren = Dept::where('parent_id', $id)->exists();
|
||||
if ($hasChildren) {
|
||||
throw new BizException('存在子部门,不可删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增后处理:保存关联数据
|
||||
*
|
||||
* @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']));
|
||||
}
|
||||
$hasUsers = User::where('dept_id', $id)->exists();
|
||||
if ($hasUsers) {
|
||||
throw new BizException('该部门下存在用户,不可删除');
|
||||
}
|
||||
}
|
||||
|
||||
// app/Services/ArticleService.php —— 详情后处理(引用传递,补充关联数据)
|
||||
protected function afterDetail(int $id, array &$data): void
|
||||
{
|
||||
$images = $data['images'] ?? '';
|
||||
$data['imagesList'] = ! empty($images)
|
||||
? array_map('trim', explode(',', $images))
|
||||
: [];
|
||||
}
|
||||
|
||||
// app/Services/UserService.php —— 列表后处理(批量补充,避免 N+1)
|
||||
protected function afterPageList(array &$records, array $params): void
|
||||
{
|
||||
$userIds = array_column($records, 'id');
|
||||
if (empty($userIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改前处理:过滤不可修改字段
|
||||
*
|
||||
* @param int $id 文章 ID
|
||||
* @param array $data 待更新的数据
|
||||
* @return array 过滤后的数据
|
||||
*/
|
||||
protected function beforeUpdate(int $id, array $data): array
|
||||
{
|
||||
// author(作者)字段不允许通过更新接口修改,直接剔除
|
||||
unset($data['author']);
|
||||
return $data;
|
||||
// 批量获取用户角色(仅查所需字段)
|
||||
$userRoles = [];
|
||||
$roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get();
|
||||
foreach ($roleData as $rd) {
|
||||
$userRoles[$rd->user_id][] = $rd->role_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前处理:业务约束校验
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
$roleMap = Role::getNameMap();
|
||||
$deptMap = Dept::getNameMap();
|
||||
foreach ($records as &$item) {
|
||||
// 补充角色名称,组装为 [{id, name}]
|
||||
$roleIds = $userRoles[$item['id']] ?? [];
|
||||
$item['roles'] = array_map(
|
||||
fn ($roleId) => ['id' => $roleId, 'name' => $roleMap[$roleId] ?? ''],
|
||||
$roleIds
|
||||
);
|
||||
$item['deptName'] = $deptMap[$item['dept_id'] ?? 0] ?? '';
|
||||
unset($item['password'], $item['salt']);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -313,51 +191,72 @@ class ArticleLogic extends BaseTenantLogic
|
||||
### 执行顺序
|
||||
|
||||
**add 流程:**
|
||||
`beforeAdd` → `validateData` → `camel_to_snake` → `processTenantId` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::create` → `afterAdd`
|
||||
`beforeAdd` → `checkUnique` → `DB::transaction { processTenantId → processFile → processMultiFile → processContent → Model::create(审计填充)→ afterAdd }`
|
||||
|
||||
**update 流程:**
|
||||
`beforeUpdate` → `validateData` → `camel_to_snake` → `processFile` → `processContent` → `filterTableFields` → `checkUnique` → `Model::find + save` → `afterUpdate`
|
||||
`beforeUpdate` → `checkUnique(excludeId)` → `DB::transaction { 同上处理 → update(审计刷新)→ afterUpdate }`
|
||||
|
||||
**delete 流程:**
|
||||
`beforeDelete` → `is_delete = 1` → `afterDelete`
|
||||
`beforeDelete` → `DB::transaction { is_delete = 1 → afterDelete }`
|
||||
|
||||
**pageList/allList 流程:**
|
||||
`buildQuery` → `排序` → `分页` → `processFile` → `processContent` → `processSerializeMaps` → `filterFields` → `afterPageList`
|
||||
`buildQuery` → `排序` → `分页/全量` → `processAfterRead(文件/字典翻译/字段过滤)` → `afterPageList`
|
||||
|
||||
## 3. 自动参数校验
|
||||
(参数格式校验在入口 FormRequest 完成,不在此流程内。)
|
||||
|
||||
通过在 Logic 中配置 `$validateClass`,add/update 时自动调用验证器,无需手动调用。
|
||||
## 3. 参数格式校验(FormRequest)
|
||||
|
||||
### 扩展方式
|
||||
通过控制器方法类型注入 FormRequest,进入方法前自动校验,无需手动调用。
|
||||
|
||||
### 扩展方式(摘自 `app/Http/Requests/ExampleRequest.php` 实际代码)
|
||||
|
||||
```php
|
||||
// 1. 创建验证器
|
||||
// app/validate/NewModuleValidate.php
|
||||
class NewModuleValidate extends Validate
|
||||
// 1. 创建请求类
|
||||
// app/Http/Requests/ExampleRequest.php
|
||||
class ExampleRequest extends BaseRequest
|
||||
{
|
||||
protected $rule = [
|
||||
'name' => 'require|max:100',
|
||||
'code' => 'require|unique:new_module',
|
||||
];
|
||||
protected $message = [
|
||||
'name.require' => '名称不能为空',
|
||||
'code.unique' => '编码已存在',
|
||||
];
|
||||
protected $scene = [
|
||||
'add' => ['name', 'code'],
|
||||
'update' => ['name'],
|
||||
];
|
||||
/**
|
||||
* 校验规则
|
||||
*
|
||||
* @return array<string, string> 字段规则
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255', // 案例名称:必填,最长 255
|
||||
'avatar' => 'nullable|string|max:255', // 头像/图片地址:可空,最长 255
|
||||
'type' => 'nullable|integer|in:1,2,3,4', // 类型:1=类型1,2=类型2,3=类型3,4=类型4
|
||||
'status' => 'nullable|integer|in:1,2', // 状态:1=在用,2=停用
|
||||
'sort' => 'nullable|integer', // 排序值:可空,整数
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义错误消息
|
||||
*
|
||||
* @return array<string, string> 字段规则 => 错误消息
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => '案例名称不能为空',
|
||||
'name.max' => '案例名称不能超过 255 个字符',
|
||||
'avatar.max' => '图片地址不能超过 255 个字符',
|
||||
'type.in' => '案例类型不合法,允许:1-类型1 2-类型2 3-类型3 4-类型4',
|
||||
'status.in' => '状态值不合法,允许:1-在用 2-停用',
|
||||
'sort.integer' => '排序值格式不正确',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 在 Logic 中配置
|
||||
class NewModuleLogic extends BaseLogic
|
||||
// 2. 控制器类型注入(校验通过才进入方法)
|
||||
public function add(ExampleRequest $request): JsonResponse
|
||||
{
|
||||
protected string $validateClass = NewModuleValidate::class;
|
||||
// ...
|
||||
return $this->_add($this->exampleService, $request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
详见 [3.4 验证器开发](/zh/guide/validator.html)。
|
||||
详见 [3.4 FormRequest 验证器](/zh/guide/validator.html)。
|
||||
|
||||
## 4. 中间件机制
|
||||
|
||||
@@ -369,68 +268,95 @@ class NewModuleLogic extends BaseLogic
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
CorsMiddleware(全局) ← OPTIONS 预检 + 跨域响应头
|
||||
ProxyMiddleware(API 组前置) ← 受信代理真实 IP 还原
|
||||
│
|
||||
▼
|
||||
AuthMiddleware(路由级) ← JWT 认证 + 权限校验 → 401/403
|
||||
│ 注入 userInfo
|
||||
▼
|
||||
TenantMiddleware(路由级) ← 租户上下文注入 → 403
|
||||
│ 注入 tenantId
|
||||
▼
|
||||
DemoMiddleware(路由级) ← 演示环境拦截写操作 → 403
|
||||
Cors(API 组前置) ← OPTIONS 预检 + 跨域响应头
|
||||
│
|
||||
▼
|
||||
LogMiddleware(路由级) ← 操作日志记录
|
||||
ForceJsonResponse(API 组前置) ← Accept 强制 application/json
|
||||
│
|
||||
▼
|
||||
Authenticate(路由级,别名 auth.jwt) ← JWT 认证 + #[Permission] 权限校验 → 401/403
|
||||
│ 注入 attributes['userInfo']
|
||||
▼
|
||||
ResolveTenant(路由级,别名 tenant) ← 租户上下文注入 → 403
|
||||
│ 注入 attributes['tenantId']
|
||||
▼
|
||||
LogOperation(路由级,别名 log.operation) ← 操作日志采集(terminate 落库)
|
||||
│
|
||||
▼
|
||||
控制器方法
|
||||
(另有 DemoMiddleware,已注册 demo 别名,可挂载到路由组)
|
||||
```
|
||||
|
||||
### 创建自定义中间件
|
||||
|
||||
中间件结构参考(摘自 `app/Http/Middleware/DemoMiddleware.php` 实际代码):
|
||||
|
||||
```php
|
||||
// 1. 创建中间件类
|
||||
// app/middleware/RateLimitMiddleware.php
|
||||
namespace app\middleware;
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use think\facade\Cache;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RateLimitMiddleware
|
||||
class DemoMiddleware
|
||||
{
|
||||
public function handle($request, \Closure $next)
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$ip = $request->ip();
|
||||
$key = 'rate_limit_' . $ip;
|
||||
$count = Cache::get($key, 0);
|
||||
|
||||
if ($count > 100) {
|
||||
return json(['code' => 1, 'msg' => '请求过于频繁'], 429);
|
||||
// 非演示模式直接放行
|
||||
if (! config('app.demo_mode', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
Cache::set($key, $count + 1, 60);
|
||||
return $next($request);
|
||||
// GET 请求放行(读操作不受限)
|
||||
if ($request->isMethod('GET')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 检查是否有 #[DemoAllow] 注解(反射读取,存在即放行)
|
||||
$route = $request->route();
|
||||
if ($route) {
|
||||
$reflection = new \ReflectionMethod(
|
||||
$route->getControllerClass(),
|
||||
$route->getActionMethod()
|
||||
);
|
||||
if (! empty($reflection->getAttributes(DemoAllow::class))) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// 演示模式下拦截写操作
|
||||
return response()->json([
|
||||
'code' => 403,
|
||||
'ok' => false,
|
||||
'msg' => '演示模式下禁止操作',
|
||||
'data' => null,
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
// 2. 注册中间件
|
||||
// 方式一:全局注册(app/middleware.php)
|
||||
return [
|
||||
\app\middleware\RateLimitMiddleware::class,
|
||||
];
|
||||
注册方式(`bootstrap/app.php` 中的三种挂载点):
|
||||
|
||||
// 方式二:路由级注册(route/app.php)
|
||||
Route::group('api', function () {
|
||||
// ...
|
||||
})->middleware(\app\middleware\RateLimitMiddleware::class);
|
||||
```php
|
||||
// 方式一:注册别名(现有 7 个别名即在此处)
|
||||
$middleware->alias([
|
||||
'proxy' => ProxyMiddleware::class,
|
||||
'cors' => Cors::class,
|
||||
'force.json' => ForceJsonResponse::class,
|
||||
'auth.jwt' => Authenticate::class,
|
||||
'tenant' => ResolveTenant::class,
|
||||
'demo' => DemoMiddleware::class,
|
||||
'log.operation' => LogOperation::class,
|
||||
]);
|
||||
|
||||
// 方式三:控制器级注册
|
||||
class SomeController extends BaseController
|
||||
{
|
||||
protected $middleware = [
|
||||
\app\middleware\RateLimitMiddleware::class,
|
||||
];
|
||||
}
|
||||
// 方式二:API 组前置(对全部 /api 请求生效)
|
||||
$middleware->api(prepend: [ProxyMiddleware::class, Cors::class, ForceJsonResponse::class]);
|
||||
|
||||
// 方式三:路由组注册(routes/api.php)
|
||||
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () { /* ... */ });
|
||||
```
|
||||
|
||||
## 5. 注解系统
|
||||
@@ -441,15 +367,15 @@ class SomeController extends BaseController
|
||||
|
||||
| 注解 | 目标 | 说明 | 读取方 |
|
||||
|------|------|------|--------|
|
||||
| `#[Log]` | 方法 | 操作日志(标题、类型、描述) | LogMiddleware |
|
||||
| `#[Permission]` | 方法/类 | 权限校验(权限编码) | AuthMiddleware |
|
||||
| `#[DemoAllow]` | 方法 | 演示环境放行标记 | DemoMiddleware |
|
||||
| `#[OperationLog]` | 方法 | 操作日志(标题、类型、描述模板) | `LogOperation` 中间件(ReflectionMethod) |
|
||||
| `#[Permission]` | 方法 | 权限校验(权限编码、名称) | `Authenticate` 中间件(ReflectionMethod) |
|
||||
| `#[DemoAllow]` | 方法 | 演示环境放行标记 | `DemoMiddleware`(ReflectionMethod) |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 操作日志
|
||||
#[Log('用户管理-新增记录', Log::TYPE_ADD, '新增用户:{username}')]
|
||||
// 操作日志(TYPE 常量 30 种,描述支持 {param} 占位符)
|
||||
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
|
||||
|
||||
// 权限校验
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
@@ -458,54 +384,50 @@ class SomeController extends BaseController
|
||||
#[DemoAllow]
|
||||
|
||||
// 组合使用
|
||||
#[Log('文章管理-删除记录', Log::TYPE_DELETE, '删除文章ID:{id}')]
|
||||
#[OperationLog('文章管理-删除', OperationLog::TYPE_DELETE, '删除文章ID:{id}')]
|
||||
#[Permission('sys:article:delete', '删除文章')]
|
||||
public function delete(int $id): Json
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
return $this->_delete($this->service, $id);
|
||||
}
|
||||
```
|
||||
|
||||
### 创建自定义注解
|
||||
### 注解结构与读取方式(摘自项目实际代码)
|
||||
|
||||
```php
|
||||
// 1. 定义注解类
|
||||
// app/attribute/Cacheable.php
|
||||
namespace app\attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
// 1. 定义注解类(app/Attributes/Permission.php 实际代码)
|
||||
#[Attribute(Attribute::TARGET_METHOD)]
|
||||
class Cacheable
|
||||
class Permission
|
||||
{
|
||||
/**
|
||||
* @param string $code 权限编码(鉴权唯一依据,如 sys:user:add)
|
||||
* @param string $name 权限名称(用于展示,如"添加用户")
|
||||
* @param string $description 权限描述(可选,补充说明)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key = '',
|
||||
public int $ttl = 3600,
|
||||
public string $code,
|
||||
public string $name = '',
|
||||
public string $description = '',
|
||||
) {}
|
||||
}
|
||||
|
||||
// 2. 在控制器方法上使用
|
||||
#[Cacheable(key: 'article_list', ttl: 1800)]
|
||||
public function list(): Json { ... }
|
||||
#[Permission('sys:user:add', '添加用户')]
|
||||
public function add(UserRequest $request): JsonResponse { ... }
|
||||
|
||||
// 3. 在中间件中读取
|
||||
// 通过 AttributeService 或反射获取注解实例
|
||||
$ref = new \ReflectionMethod($controller, $method);
|
||||
$attrs = $ref->getAttributes(Cacheable::class);
|
||||
if (!empty($attrs)) {
|
||||
$cacheable = $attrs[0]->newInstance();
|
||||
// $cacheable->key, $cacheable->ttl
|
||||
// 3. 在中间件中读取(DemoMiddleware 实际代码同款反射方式)
|
||||
$reflection = new \ReflectionMethod($controllerClass, $method);
|
||||
$attrs = $reflection->getAttributes(DemoAllow::class);
|
||||
if (! empty($attrs)) {
|
||||
return $next($request); // 命中注解即放行
|
||||
}
|
||||
```
|
||||
|
||||
### 注解读取服务
|
||||
新增自定义注解时,按同样结构在 `app/Attributes/` 下定义类,再在中间件中用 `ReflectionMethod::getAttributes()` 读取即可。
|
||||
|
||||
`AttributeService` 提供统一的注解读取接口(带缓存):
|
||||
|
||||
```php
|
||||
AttributeService::getLog($class, $method); // 获取 Log 注解
|
||||
AttributeService::getPermission($class, $method); // 获取 Permission 注解
|
||||
```
|
||||
:::tip 注解读取性能
|
||||
反射读取开销为微秒级,`Authenticate` / `LogOperation` 均直接使用 `ReflectionMethod` 读取(无缓存),避免开发阶段改注解后需等缓存过期。
|
||||
:::
|
||||
|
||||
## 6. 服务层复用
|
||||
|
||||
@@ -513,100 +435,124 @@ AttributeService::getPermission($class, $method); // 获取 Permission 注解
|
||||
|
||||
### 现有服务清单
|
||||
|
||||
| 服务 | 文件 | 能力 |
|
||||
| 服务 | 文件(`app/Services/`) | 能力 |
|
||||
|------|------|------|
|
||||
| `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 生成器(跨库映射) |
|
||||
| `JwtService` | `JwtService.php` | 登录、刷新、解析 Token(Redis 有状态) |
|
||||
| `PasswordService` | `PasswordService.php` | bcrypt 双重加盐加密 |
|
||||
| `DictCacheService` | `DictCacheService.php` | 字典查询(两级缓存) |
|
||||
| `CaptchaService` | `CaptchaService.php` | 图片验证码生成与校验(IP 限流) |
|
||||
| `RequestInfoService` | `RequestInfoService.php` | 客户端 IP、UA、归属地解析 |
|
||||
| `GeneratorService` | `GeneratorService.php` | 代码生成 |
|
||||
| `ExcelService` | `ExcelService.php` | Excel 导入导出 |
|
||||
| `DbMigrateService` | `DbMigrateService.php` | 跨数据库迁移引擎 |
|
||||
| `DbSchemaBuilder` | `DbSchemaBuilder.php` | DDL 生成器(跨库映射、Oracle 序列) |
|
||||
| `ConfigService` | `ConfigService.php` | 系统配置读写与缓存 |
|
||||
| `DictService` | `DictService.php` | 字典/字典项管理 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// 字典服务
|
||||
DictService::getText('gender', 1); // → '男'
|
||||
DictService::getValue('user_status', '启用'); // → 1
|
||||
DictService::getOptions('gender'); // → [['value'=>0,'label'=>'女'], ...]
|
||||
// 字典服务(静态调用,内部两级缓存)
|
||||
DictCacheService::getText('gender', 1); // → '男'
|
||||
DictCacheService::getValue('user_status', '启用'); // → 1
|
||||
|
||||
// 密码服务
|
||||
$encrypted = PasswordService::encrypt('123456'); // → ['password'=>'...', 'salt'=>'...']
|
||||
$default = PasswordService::getDefaultPasswordPlain(); // → '123456'
|
||||
|
||||
// Excel 服务
|
||||
ExcelService::importWithMap($filePath, $headerMap);
|
||||
ExcelService::chunkExport($headers, $callback, '导出.xlsx', 500);
|
||||
ExcelService::export($list, $headers, '导出.xlsx');
|
||||
|
||||
// 请求信息
|
||||
// 请求信息(IP 经 ProxyMiddleware 还原真实值)
|
||||
$requestInfo = RequestInfoService::create(request());
|
||||
$ip = $requestInfo->getIp(); // 客户端 IP(经 ProxyMiddleware 还原真实 IP)
|
||||
$location = $requestInfo->getIpLocation(); // IP 归属地(基于 zoujingli/ip2region)
|
||||
$os = $requestInfo->getOs(); // 操作系统
|
||||
$browser = $requestInfo->getBrowser(); // 浏览器
|
||||
$location = $requestInfo->getIpLocation(); // 基于 zoujingli/ip2region
|
||||
```
|
||||
|
||||
### 创建自定义服务
|
||||
|
||||
```php
|
||||
// app/service/NotificationService.php
|
||||
namespace app\service;
|
||||
服务结构参考(摘自 `app/Services/ParamService.php` 实际代码——无状态静态方法 + 请求级缓存的典型写法):
|
||||
|
||||
class NotificationService
|
||||
```php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Param;
|
||||
|
||||
/**
|
||||
* 系统参数服务
|
||||
*
|
||||
* 从 param 表获取系统参数配置。
|
||||
* 数据量小,不使用持久缓存,仅保留请求级缓存避免同请求内重复查库。
|
||||
*/
|
||||
class ParamService
|
||||
{
|
||||
// 发送站内通知
|
||||
public static function send(int $userId, string $title, string $content): void
|
||||
/**
|
||||
* 请求级缓存:[参数编码 => 参数值]
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected static array $cache = [];
|
||||
|
||||
/**
|
||||
* 获取参数值
|
||||
*
|
||||
* 读取顺序:请求级缓存 → 查库。
|
||||
*/
|
||||
public static function get(string $code): mixed
|
||||
{
|
||||
\app\model\Notice::create([
|
||||
'user_id' => $userId,
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'status' => 0,
|
||||
]);
|
||||
if (array_key_exists($code, static::$cache)) {
|
||||
return static::$cache[$code];
|
||||
}
|
||||
|
||||
$value = /* 查库逻辑 */ null;
|
||||
static::$cache[$code] = $value;
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 批量发送
|
||||
public static function batchSend(array $userIds, string $title, string $content): void
|
||||
/**
|
||||
* 清除缓存(参数变更后调用)
|
||||
*/
|
||||
public static function clearCache(string $code = ''): void
|
||||
{
|
||||
foreach ($userIds as $userId) {
|
||||
self::send($userId, $title, $content);
|
||||
if ($code !== '') {
|
||||
unset(static::$cache[$code]);
|
||||
|
||||
return;
|
||||
}
|
||||
static::$cache = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
NotificationService::send($userId, '系统通知', '您有一条新消息');
|
||||
```
|
||||
|
||||
新增服务时按此结构放在 `app/Services/` 下:无状态、单一职责、静态方法或构造注入均可;跨模块复用的能力才提升为 Service,业务专属逻辑留在业务 Service 中。
|
||||
|
||||
## 扩展性设计原则
|
||||
|
||||
### 开闭原则(OCP)
|
||||
|
||||
| 扩展场景 | 做法 | 不应该做 |
|
||||
|----------|------|----------|
|
||||
| 新增 CRUD 模块 | 创建 Controller/Logic/Model/Validate | 修改基类 |
|
||||
| 新增查询条件 | 设置 `pageLikeFields`/`pageEqFields` | 重写 `buildQuery` |
|
||||
| 新增文件处理 | 设置 `fileFields` | 手动调用 `save_file` |
|
||||
| 新增校验规则 | 创建 Validate + 配置 `validateClass` | 在 Controller 手动 validate |
|
||||
| 新增请求拦截 | 创建中间件 | 修改现有中间件 |
|
||||
| 新增操作日志 | 添加 `#[Log]` 注解 | 在方法内手动记录日志 |
|
||||
| 新增 CRUD 模块 | 创建 Request/Service/Model/Controller | 修改基类 |
|
||||
| 新增查询条件 | 设置 `pageLikeFields` / `pageEqFields` | 重写 `buildQuery` |
|
||||
| 新增文件处理 | `use HasFileProcessing` + 设置 `fileFields` | 手动搬运临时文件 |
|
||||
| 新增校验规则 | 创建 FormRequest + 类型注入 | 在 Controller 手动校验 |
|
||||
| 新增请求拦截 | 创建中间件 + 注册 | 修改现有中间件 |
|
||||
| 新增操作日志 | 添加 `#[OperationLog]` 注解 | 在方法内手动记录日志 |
|
||||
| 新增权限节点 | 添加 `#[Permission]` 注解 | 在方法内手动校验权限 |
|
||||
| 新增通用能力 | 创建 Service | 在 Logic 中写静态方法 |
|
||||
| 新增通用能力 | 创建 Service | 在业务 Service 里写静态方法 |
|
||||
|
||||
### 最小改动原则
|
||||
|
||||
新增一个完整的业务模块,只需要:
|
||||
|
||||
1. 建表(含公共字段)
|
||||
2. 创建 Model(继承 BaseModel,声明表名)
|
||||
3. 创建 Validate(定义规则 + 场景)
|
||||
4. 创建 Logic(继承 BaseLogic/BaseTenantLogic,配置属性)
|
||||
5. 创建 Controller(继承 BaseController,初始化 Logic,CRUD 一行调用)
|
||||
6. 注册路由
|
||||
2. 创建 Model(组合双 Trait,声明表名与 fillable)
|
||||
3. 创建 FormRequest(定义规则与中文消息)
|
||||
4. 创建 Service(继承 BaseService / BaseTenantService,配置属性 + 按需钩子)
|
||||
5. 创建 Controller(构造注入 Service,CRUD 一行调用 + 双注解)
|
||||
6. 注册路由(`routes/api.php` 认证组内)
|
||||
7. 配置菜单权限(可选)
|
||||
8. 前端 API + 页面(或交给代码生成器一次完成)
|
||||
|
||||
**不需要修改的文件:** 基类、中间件、配置文件、服务层。
|
||||
**不需要修改的文件:** 基类、中间件、配置文件、横切服务。
|
||||
(或直接使用 [第 10 章 代码生成器](/zh/generator/overview.html) 一键生成以上全部。)
|
||||
|
||||
Reference in New Issue
Block a user