签入版本
This commit is contained in:
@@ -8,13 +8,13 @@
|
||||
|
||||
| 文件路径 | 说明 | 核心内容 |
|
||||
|----------|------|----------|
|
||||
| `app/model/Example.php` | 数据模型 | 继承 BaseModel,声明表名 `example` |
|
||||
| `app/validate/ExampleValidate.php` | 验证器 | name 字段必填,add/update 场景 |
|
||||
| `app/logic/ExampleLogic.php` | 业务逻辑 | 属性配置 + import/export 方法 |
|
||||
| `app/controller/ExampleController.php` | 控制器 | 9 个接口(page/list/detail/add/update/delete/batchDelete/import/export) |
|
||||
| `route/app.php` | 路由 | 第 371~383 行,9 条路由 |
|
||||
| `app/Models/Example.php` | 数据模型 | `$table='example'` + `HasAuditColumns` / `SoftDeletesByFlag` Trait |
|
||||
| `app/Http/Requests/ExampleRequest.php` | 表单请求验证 | name 必填等格式规则 + 中文错误消息 |
|
||||
| `app/Services/ExampleService.php` | 业务服务 | 属性配置(查询/唯一/字典)+ 4 个 Trait + import/export |
|
||||
| `app/Http/Controllers/Api/ExampleController.php` | 控制器 | 9 个接口(page/list/detail/add/update/delete/batchDelete/export/import) |
|
||||
| `routes/api.php` | 路由 | example 模块 9 条路由(认证中间件组内) |
|
||||
|
||||
## 前端文件清单
|
||||
## 前端文件清单(本仓库 `ui/` 目录)
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|----------|------|
|
||||
@@ -29,16 +29,19 @@
|
||||
|
||||
| 对象 | 说明 |
|
||||
|------|------|
|
||||
| `think_example` 表 | 案例演示数据表 |
|
||||
| `laravel_example` 表 | 案例演示数据表(模型 `$table='example'`,前缀由 `DB_PREFIX` 拼接) |
|
||||
|
||||
## 权限节点
|
||||
|
||||
| 权限码 | 说明 |
|
||||
|--------|------|
|
||||
| `sys:example:page` | 案例演示分页列表 |
|
||||
| `sys:example:list` | 案例演示列表 |
|
||||
| `sys:example:detail` | 案例演示详情 |
|
||||
| `sys:example:add` | 添加案例演示 |
|
||||
| `sys:example:update` | 修改案例演示 |
|
||||
| `sys:example:delete` | 删除案例演示 |
|
||||
| `sys:example:batchDelete` | 批量删除案例演示 |
|
||||
| `sys:example:import` | 导入案例演示 |
|
||||
| `sys:example:export` | 导出案例演示 |
|
||||
|
||||
@@ -46,14 +49,40 @@
|
||||
|
||||
| 字典编码 | 说明 | 字典项 |
|
||||
|---------|------|--------|
|
||||
| `example_type` | 案例类型 | 按实际配置 |
|
||||
| `example_status` | 案例状态 | 0=禁用, 1=启用 |
|
||||
| `example_type` | 案例类型 | 按实际配置(1-4) |
|
||||
| `example_status` | 案例状态 | 1=在用, 2=停用 |
|
||||
|
||||
对应 Service 配置:
|
||||
|
||||
```php
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'example_status', // 读取时补 statusText
|
||||
];
|
||||
```
|
||||
|
||||
## 涉及的基类与 Trait
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `app/Http/Controllers/Controller.php` | 控制器基类(`_page` 等快捷方法 + 响应封装) |
|
||||
| `app/Http/Requests/BaseRequest.php` | 请求基类(统一失败响应) |
|
||||
| `app/Services/BaseService.php` | 服务基类(全部声明式属性 + 占位方法) |
|
||||
| `app/Services/Concerns/HasCrudOperations.php` | CRUD 主流程 + 钩子 + 事务 |
|
||||
| `app/Services/Concerns/HasQueryScopes.php` | 查询构建 + 排序白名单 |
|
||||
| `app/Services/Concerns/HasSerializeMaps.php` | 字典翻译 |
|
||||
| `app/Services/Concerns/HasUniqueCheck.php` | 唯一性校验 |
|
||||
| `app/Services/Concerns/HasConfigValueProcessing.php` | 配置项 value 按类型处理(文件迁移 / valueList) |
|
||||
| `app/Models/Concerns/SoftDeletesByFlag.php` | 软删除 |
|
||||
| `app/Models/Concerns/HasAuditColumns.php` | 审计字段 |
|
||||
| `app/Attributes/Permission.php` / `OperationLog.php` | 权限 / 日志注解 |
|
||||
|
||||
## 代码生成器替代方案
|
||||
|
||||
example 模块本身就是代码生成器的参考模板。如果需要生成类似模块:
|
||||
|
||||
1. 建好数据库表
|
||||
2. 访问代码生成器页面
|
||||
3. 选择表,点击生成
|
||||
4. 自动创建 Controller / Logic / Model / Validate / 前端文件
|
||||
1. 建好数据库表(见 [3.2 数据库建表](/zh/guide/database.html))
|
||||
2. 访问「开发工具 → 代码生成」页面(父菜单 id=189),或执行 `php artisan generate:code {表名}`
|
||||
3. 选择表,配置菜单挂载点与应用名
|
||||
4. 自动创建 Model / FormRequest / Service / Controller / 路由 / 菜单权限 / 前端文件
|
||||
|
||||
详见 [第 10 章 代码生成器](/zh/generator/overview.html)。
|
||||
|
||||
@@ -1,305 +1,280 @@
|
||||
# 3.6 控制器开发
|
||||
|
||||
:::tip 概述
|
||||
控制器是 HTTP 请求的入口,负责参数获取、调用 Logic 层、返回 JSON 响应。通过 `#[Permission]` 和 `#[Log]` 注解实现权限校验和操作日志。
|
||||
控制器是 HTTP 请求的入口,负责参数获取、调用 Service、返回统一 JSON 响应。通过 `#[Permission]` 和 `#[OperationLog]` 注解实现权限校验和操作日志,配合基类快捷方法,单个接口通常只需几行代码。
|
||||
:::
|
||||
|
||||
## 控制器文件位置
|
||||
|
||||
```text
|
||||
app/controller/ExampleController.php
|
||||
app/Http/Controllers/Api/ExampleController.php
|
||||
```
|
||||
|
||||
## 完整代码(来自项目实际代码)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\controller;
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use app\BaseController; // 控制器基类
|
||||
use app\logic\ExampleLogic; // 案例演示业务逻辑
|
||||
use app\attribute\Log; // 操作日志注解
|
||||
use app\attribute\Permission; // 权限校验注解
|
||||
use think\response\Json; // JSON 响应类型
|
||||
use App\Attributes\OperationLog;
|
||||
use App\Attributes\Permission;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ExampleRequest;
|
||||
use App\Services\ExampleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 案例演示管理控制器
|
||||
* 示例管理控制器
|
||||
*
|
||||
* 负责案例演示相关的 HTTP 接口入口,包括:
|
||||
* - 分页列表、列表查询、详情查询;
|
||||
* - 新增、修改、删除、批量删除;
|
||||
* - 导入、导出。
|
||||
* 负责示例相关的接口入口,包含分页、列表、详情、新增、修改、删除、批量删除,
|
||||
* 以及数据导入、导出。
|
||||
* 具体业务逻辑委托给 ExampleService,控制器仅做参数校验、
|
||||
* 权限与日志标注及响应封装。
|
||||
*
|
||||
* 具体业务逻辑委托给 ExampleLogic 处理,
|
||||
* 并通过注解统一实现日志记录与权限校验。
|
||||
* 说明:
|
||||
* - 所有接口通过 #[Permission] 注解声明权限标识与描述,由框架统一鉴权。
|
||||
* - 所有接口通过 #[OperationLog] 注解声明操作日志(类型、模板),由框架统一记录。
|
||||
* - 导入 / 导出接口的日志类型分别为 TYPE_IMPORT、TYPE_EXPORT,与常规 CRUD 区分。
|
||||
* - 常规 CRUD 复用基类的
|
||||
* _page / _list / _detail / _add / _update / _delete / _batchDelete 方法。
|
||||
* - 导入 / 导出走 ExampleService 的 import / export 专用方法。
|
||||
*/
|
||||
class ExampleController extends BaseController
|
||||
class ExampleController extends Controller
|
||||
{
|
||||
/**
|
||||
* 案例演示业务逻辑层
|
||||
* 构造函数
|
||||
*
|
||||
* @var ExampleLogic
|
||||
* 注入示例业务服务,供各接口方法复用。
|
||||
*
|
||||
* @param ExampleService $exampleService 示例业务服务
|
||||
*/
|
||||
protected ExampleLogic $logic;
|
||||
public function __construct(protected ExampleService $exampleService) {}
|
||||
|
||||
/**
|
||||
* 控制器初始化
|
||||
* 查询分页
|
||||
*
|
||||
* 在每次请求进入控制器方法前执行,用于实例化业务逻辑层。
|
||||
* ThinkPHP 会自动调用此方法(在构造函数之后)。
|
||||
* 对应权限:sys:example:page,操作日志类型:查询。
|
||||
* 返回分页结构,适合列表页展示。
|
||||
*
|
||||
* @return void
|
||||
* @return JsonResponse 分页数据
|
||||
*/
|
||||
protected function initialize(): void
|
||||
#[OperationLog('示例管理-查询分页', OperationLog::TYPE_QUERY)]
|
||||
#[Permission('sys:example:page', '示例分页')]
|
||||
public function page(): JsonResponse
|
||||
{
|
||||
$this->logic = new ExampleLogic();
|
||||
return $this->_page($this->exampleService);
|
||||
}
|
||||
|
||||
// ... 以下为各接口方法
|
||||
/**
|
||||
* 查询列表
|
||||
*
|
||||
* 对应权限:sys:example:list,操作日志类型:查询。
|
||||
* 返回不分页的完整列表,适合下拉选择等场景。
|
||||
*
|
||||
* @return JsonResponse 列表数据
|
||||
*/
|
||||
#[OperationLog('示例管理-查询列表', OperationLog::TYPE_QUERY)]
|
||||
#[Permission('sys:example:list', '示例列表')]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
return $this->_list($this->exampleService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询详情
|
||||
*
|
||||
* 对应权限:sys:example:detail,操作日志类型:查询,
|
||||
* 日志模板中包含示例ID:{id}。
|
||||
*
|
||||
* @param int $id 示例ID
|
||||
* @return JsonResponse 示例详情
|
||||
*/
|
||||
#[OperationLog('示例管理-查询详情', OperationLog::TYPE_QUERY, '查询示例ID:{id}')]
|
||||
#[Permission('sys:example:detail', '示例详情')]
|
||||
public function detail(int $id): JsonResponse
|
||||
{
|
||||
return $this->_detail($this->exampleService, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增
|
||||
*
|
||||
* 对应权限:sys:example:add,操作日志类型:新增,
|
||||
* 日志模板中包含示例名称:{name}。
|
||||
*
|
||||
* @param ExampleRequest $request 已通过校验的示例请求
|
||||
* @return JsonResponse 新增结果
|
||||
*/
|
||||
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
|
||||
#[Permission('sys:example:add', '添加示例')]
|
||||
public function add(ExampleRequest $request): JsonResponse
|
||||
{
|
||||
return $this->_add($this->exampleService, $request->validated());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改
|
||||
*
|
||||
* 对应权限:sys:example:update,操作日志类型:修改。
|
||||
* 若请求数据中缺少 id,则直接返回失败响应。
|
||||
*
|
||||
* @param ExampleRequest $request 已通过校验的示例请求
|
||||
* @return JsonResponse 修改结果
|
||||
*/
|
||||
#[OperationLog('示例管理-修改', OperationLog::TYPE_UPDATE, '修改示例ID:{id}')]
|
||||
#[Permission('sys:example:update', '修改示例')]
|
||||
public function update(ExampleRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$id = $data['id'] ?? 0;
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少id参数');
|
||||
}
|
||||
|
||||
return $this->_update($this->exampleService, $id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*
|
||||
* 对应权限:sys:example:delete,操作日志类型:删除,
|
||||
* 日志模板中包含示例ID:{id}。
|
||||
*
|
||||
* @param int $id 示例ID
|
||||
* @return JsonResponse 删除结果
|
||||
*/
|
||||
#[OperationLog('示例管理-删除', OperationLog::TYPE_DELETE, '删除示例ID:{id}')]
|
||||
#[Permission('sys:example:delete', '删除示例')]
|
||||
public function delete(int $id): JsonResponse
|
||||
{
|
||||
return $this->_delete($this->exampleService, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*
|
||||
* 对应权限:sys:example:batchDelete,操作日志类型:删除。
|
||||
* 从 JSON 请求体中读取 ids 数组,未传时按空数组处理。
|
||||
*
|
||||
* @return JsonResponse 批量删除结果
|
||||
*/
|
||||
#[OperationLog('示例管理-批量删除', OperationLog::TYPE_DELETE)]
|
||||
#[Permission('sys:example:batchDelete', '批量删除')]
|
||||
public function batchDelete(): JsonResponse
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
|
||||
return $this->_batchDelete($this->exampleService, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出数据
|
||||
*
|
||||
* 对应权限:sys:example:export,操作日志类型:导出(TYPE_EXPORT)。
|
||||
* 由基类 _export() 统一处理文件生成与响应封装。
|
||||
*
|
||||
* @return JsonResponse 导出结果(data 为文件地址)
|
||||
*/
|
||||
#[OperationLog('示例管理-导出数据', OperationLog::TYPE_EXPORT)]
|
||||
#[Permission('sys:example:export', '导出示例')]
|
||||
public function export(): JsonResponse
|
||||
{
|
||||
return $this->_export($this->exampleService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入数据
|
||||
*
|
||||
* 对应权限:sys:example:import,操作日志类型:导入(TYPE_IMPORT)。
|
||||
* 由基类 _import() 统一处理文件校验(格式/大小/MIME)与响应封装。
|
||||
*
|
||||
* @return JsonResponse 导入结果
|
||||
*/
|
||||
#[OperationLog('示例管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入示例数据')]
|
||||
#[Permission('sys:example:import', '导入示例')]
|
||||
public function import(): JsonResponse
|
||||
{
|
||||
return $this->_import($this->exampleService);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 控制器模式
|
||||
每个控制器遵循固定模式:
|
||||
1. `initialize()` 中实例化 Logic
|
||||
2. 每个方法标注 `#[Log]` 和 `#[Permission]` 注解
|
||||
3. 简单调用 `parent::_index()` / `_detail()` / `_add()` 等通用方法
|
||||
4. 复杂操作(如导入导出)自行处理并 try-catch
|
||||
|
||||
1. **构造函数注入 Service**(Laravel 依赖注入,`protected` 简写属性即成员属性)
|
||||
2. 每个方法标注 `#[OperationLog]` 和 `#[Permission]` 注解
|
||||
3. 简单调用 `$this->_page()` / `_detail()` / `_add()` 等快捷方法
|
||||
4. 入参由 FormRequest 类型注入完成格式校验(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))
|
||||
:::
|
||||
|
||||
```php
|
||||
/**
|
||||
* 案例演示分页列表
|
||||
*
|
||||
* GET /example/page
|
||||
* 对应权限:sys:example:page
|
||||
*
|
||||
* 调用 BaseController::_index(),内部流程:
|
||||
* 1. 合并分页参数(pageNo/pageSize)和普通参数
|
||||
* 2. 调用 ExampleLogic::pageList()
|
||||
* 3. 返回标准分页响应
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-查询分页记录', Log::TYPE_QUERY)]
|
||||
#[Permission('sys:example:page', '案例演示分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
## Controller 基类快捷方法
|
||||
|
||||
/**
|
||||
* 案例演示列表
|
||||
*
|
||||
* GET /example/list
|
||||
* 返回全量数据(不分页),适用于下拉选择等场景。
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-查询列表', Log::TYPE_QUERY)]
|
||||
public function list(): Json
|
||||
{
|
||||
return parent::_list($this->logic);
|
||||
}
|
||||
`app/Http/Controllers/Controller.php` 提供的通用方法:
|
||||
|
||||
/**
|
||||
* 案例演示详情
|
||||
*
|
||||
* GET /example/detail/{id}
|
||||
*
|
||||
* 调用 BaseController::_detail(),内部流程:
|
||||
* 1. 调用 ExampleLogic::detail($id)
|
||||
* 2. 查询为空返回 404
|
||||
* 3. 自动补全文件字段域名、枚举显示名
|
||||
* 4. 触发 afterDetail 钩子
|
||||
*
|
||||
* @param int $id 案例演示ID
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-查询详情', Log::TYPE_QUERY, '查询案例演示ID:{id}')]
|
||||
public function detail(int $id): Json
|
||||
{
|
||||
return parent::_detail($this->logic, $id);
|
||||
}
|
||||
| 方法 | 用途 | 内部调用 | 失败场景 |
|
||||
|------|------|----------|----------|
|
||||
| `_page($service)` | 分页查询 | `Service::pageList()` | - |
|
||||
| `_list($service)` | 全量列表 | `Service::allList()` | - |
|
||||
| `_treeList($service)` | 树形列表 | `Service::treeList()` | - |
|
||||
| `_detail($service, $id)` | 详情查询 | `Service::detail($id)` | 空数据返回 `code=404` |
|
||||
| `_add($service, $data)` | 新增 | `Service::add($data)` | 捕获 `BizException` → `fail(消息, code)` |
|
||||
| `_update($service, $id, $data)` | 修改 | `Service::update($id, $data)` | id 缺失提示;捕获 `BizException` |
|
||||
| `_delete($service, $id)` | 删除 | `Service::delete($id)` | 捕获 `BizException`;其他异常记日志后返回通用提示 |
|
||||
| `_batchDelete($service, $ids)` | 批量删除 | `Service::batchDelete($ids)` | ids 为空提示;同上 |
|
||||
| `_export($service)` | 导出 | `Service::export()` | 捕获 `BizException`(如"无数据可导出") |
|
||||
| `_import($service)` | 导入 | `Service::import()` | 文件格式/大小/MIME 校验 + 通用提示 |
|
||||
|
||||
/**
|
||||
* 添加案例演示
|
||||
*
|
||||
* POST /example/add
|
||||
* 对应权限:sys:example:add
|
||||
*
|
||||
* 调用 BaseController::_add(),内部流程:
|
||||
* 1. 调用 ExampleLogic::add($data)
|
||||
* 2. 触发 beforeAdd/afterAdd 钩子
|
||||
* 3. 返回新记录 ID
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
|
||||
#[Permission('sys:example:add', '添加案例演示')]
|
||||
public function add(): Json
|
||||
{
|
||||
// getJsonBody() 获取前端 POST 提交的 JSON 数据
|
||||
return parent::_add($this->logic, $this->getJsonBody());
|
||||
}
|
||||
响应封装方法:`$this->success($data, $msg)` / `$this->fail($msg, $code)` / `$this->paginate($result)`(委托给 `Support\Result`,见 [5.16 统一响应与异常抛出](/zh/core/exception.html))。
|
||||
|
||||
/**
|
||||
* 更新案例演示
|
||||
*
|
||||
* PUT /example/update
|
||||
* 对应权限:sys:example:update
|
||||
*
|
||||
* 从 JSON 请求体中读取 id,若缺失则直接返回失败。
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-修改记录', Log::TYPE_UPDATE, '修改案例演示ID:{id}')]
|
||||
#[Permission('sys:example:update', '修改案例演示')]
|
||||
public function update(): Json
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['id'] ?? 0;
|
||||
|
||||
// 校验 ID 参数
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少id参数');
|
||||
}
|
||||
|
||||
return parent::_edit($this->logic, $id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除案例演示
|
||||
*
|
||||
* DELETE /example/delete/{id}
|
||||
* 对应权限:sys:example:delete
|
||||
*
|
||||
* 调用 BaseController::_remove(),内部流程:
|
||||
* 1. 调用 ExampleLogic::delete($id)
|
||||
* 2. 触发 beforeDelete/afterDelete 钩子
|
||||
* 3. 设置 is_delete=1(软删除)
|
||||
*
|
||||
* @param int $id 案例演示ID
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-删除记录', Log::TYPE_DELETE, '删除案例演示ID:{id}')]
|
||||
#[Permission('sys:example:delete', '删除案例演示')]
|
||||
public function delete(int $id): Json
|
||||
{
|
||||
return parent::_remove($this->logic, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除案例演示
|
||||
*
|
||||
* DELETE /example/batchDelete
|
||||
* 对应权限:sys:example:delete
|
||||
*
|
||||
* 从 JSON 请求体中读取 ids 数组。
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-批量删除记录', Log::TYPE_DELETE)]
|
||||
#[Permission('sys:example:delete', '删除案例演示')]
|
||||
public function batchDelete(): Json
|
||||
{
|
||||
$ids = $this->getJsonBody()['ids'] ?? [];
|
||||
return parent::_batchRemove($this->logic, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入案例演示
|
||||
*
|
||||
* POST /example/import
|
||||
* 对应权限:sys:example:import
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 从请求中获取上传文件
|
||||
* 2. 校验文件扩展名(仅 xls、xlsx)
|
||||
* 3. 调用 ExampleLogic::import() 解析并导入
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
|
||||
#[Permission('sys:example:import', '导入案例演示')]
|
||||
public function import(): Json
|
||||
{
|
||||
$file = $this->request->file('file');
|
||||
if (!$file) {
|
||||
return $this->fail('请选择文件');
|
||||
}
|
||||
|
||||
$ext = strtolower($file->getOriginalExtension());
|
||||
if (!in_array($ext, ['xls', 'xlsx'])) {
|
||||
return $this->fail('仅支持 xls、xlsx 格式的文件');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->logic->import($file->getRealPath());
|
||||
|
||||
$msg = empty($result['errors'])
|
||||
? "成功导入 {$result['count']} 条数据"
|
||||
: "成功导入 {$result['count']} 条,失败 " . count($result['errors']) . " 条";
|
||||
|
||||
return $this->success($result, $msg);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('导入失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出案例演示
|
||||
*
|
||||
* GET /example/export
|
||||
* 对应权限:sys:example:export
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
|
||||
#[Permission('sys:example:export', '导出案例演示')]
|
||||
public function export(): Json
|
||||
{
|
||||
try {
|
||||
$filePath = $this->logic->export($this->getParams());
|
||||
return $this->success($filePath, '导出成功');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('导出失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
参数获取方法:`getPageParams()`(pageNo/pageSize,限 1~100)、`getJsonBody()`(JSON 请求体)、`getParams()`(query + body 合并)。
|
||||
|
||||
## 注解说明
|
||||
|
||||
### #[Log] 操作日志注解
|
||||
### #[OperationLog] 操作日志注解
|
||||
|
||||
```php
|
||||
#[Log('案例演示-新增记录', Log::TYPE_ADD, '新增案例演示:{name}')]
|
||||
// │ │ │
|
||||
// │ │ └── 描述(支持 {param} 占位符)
|
||||
// │ └── 操作类型
|
||||
// └── 操作标题
|
||||
#[OperationLog('示例管理-新增', OperationLog::TYPE_ADD, '新增示例:{name}')]
|
||||
// │ │ │
|
||||
// │ │ └── 描述模板(支持 {param} 占位符,替换为请求参数值)
|
||||
// │ └── 操作类型常量(TYPE_QUERY/ADD/UPDATE/DELETE/IMPORT/EXPORT 等 30 种)
|
||||
// └── 操作标题
|
||||
```
|
||||
|
||||
- 由 `log.operation` 中间件采集,响应后(terminable 阶段)落库;
|
||||
- 写操作(POST/PUT/DELETE)始终记录,读操作按注解类型选择性记录。
|
||||
|
||||
### #[Permission] 权限注解
|
||||
|
||||
```php
|
||||
#[Permission('sys:example:add', '添加案例演示')]
|
||||
// │ │
|
||||
// │ └── 权限名称
|
||||
// └── 权限编码
|
||||
#[Permission('sys:example:add', '添加示例')]
|
||||
// │ │
|
||||
// │ └── 权限名称(菜单管理中显示)
|
||||
// └── 权限编码(菜单表 permission 字段)
|
||||
```
|
||||
|
||||
## BaseController 通用方法
|
||||
- 由 `auth.jwt` 中间件反射校验:解析注解 → 比对当前用户权限列表 → 无权限返回 `code=403`;
|
||||
- 超级管理员(用户 ID=1)直接放行;
|
||||
- 未标注 `#[Permission]` 的认证接口只需登录即可访问。
|
||||
|
||||
| 方法 | 用途 | 内部调用 |
|
||||
|------|------|----------|
|
||||
| `_index($logic)` | 分页查询 | `$logic->pageList()` |
|
||||
| `_list($logic)` | 全量列表 | `$logic->allList()` |
|
||||
| `_detail($logic, $id)` | 详情查询 | `$logic->detail($id)` |
|
||||
| `_add($logic, $data)` | 新增 | `$logic->add($data)` |
|
||||
| `_edit($logic, $id, $data)` | 修改 | `$logic->update($id, $data)` |
|
||||
| `_remove($logic, $id)` | 删除 | `$logic->delete($id)` |
|
||||
| `_batchRemove($logic, $ids)` | 批量删除 | `$logic->batchDelete($ids)` |
|
||||
## 与手动构造响应的对比
|
||||
|
||||
```php
|
||||
// ✅ 推荐:快捷方法(异常处理、日志、404 兜底全部内置)
|
||||
public function page(): JsonResponse
|
||||
{
|
||||
return $this->_page($this->exampleService);
|
||||
}
|
||||
|
||||
// 特殊场景:手动组织响应
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$data = $this->exampleService->stats();
|
||||
if (! $data) {
|
||||
return $this->fail('暂无统计数据', 404);
|
||||
}
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 3.2 数据库建表
|
||||
|
||||
:::tip 概述
|
||||
建表是模块开发的第一步。所有表必须包含公共字段(id、软删除、审计字段),这是 BaseModel 自动处理的基础。
|
||||
建表是模块开发的第一步。所有表必须包含公共字段(id、软删除、审计字段),这是模型 Trait 自动处理的基础。
|
||||
:::
|
||||
|
||||
## 公共字段规范
|
||||
@@ -12,55 +12,77 @@
|
||||
-- 公共字段(每个表都必须包含)
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
is_delete TINYINT(1) DEFAULT 0 COMMENT '软删除 0=正常 1=已删除',
|
||||
create_user VARCHAR(50) DEFAULT '' COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_user VARCHAR(50) DEFAULT '' COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间'
|
||||
create_user VARCHAR(50) DEFAULT '' COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_user VARCHAR(50) DEFAULT '' COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间'
|
||||
```
|
||||
|
||||
:::warning 为什么必须包含这些字段?
|
||||
- `is_delete`:BaseModel 的全局查询范围 `soft_delete` 会自动过滤 `is_delete=1` 的数据
|
||||
- `create_user` / `create_time`:BaseModel 的 `onBeforeInsert` 事件自动填充
|
||||
- `update_user` / `update_time`:BaseModel 的 `onBeforeUpdate` 事件自动填充
|
||||
- `is_delete`:`SoftDeleteFlagScope` 全局查询范围会自动过滤 `is_delete=1` 的数据(`SoftDeletesByFlag` Trait 注册)
|
||||
- `create_user` / `create_time`:`HasAuditColumns` Trait 的 `creating` 事件自动填充
|
||||
- `update_user` / `update_time`:`HasAuditColumns` Trait 的 `updating` 事件自动填充
|
||||
- 表结构**不含** Laravel 默认的 `created_at` / `updated_at` 列,统一使用本项目的审计字段命名
|
||||
:::
|
||||
|
||||
## 建表 SQL 示例(example 模块)
|
||||
## 建表 SQL 示例(example 模块,摘自 `document/mysql/` 实际脚本)
|
||||
|
||||
```sql
|
||||
CREATE TABLE think_example (
|
||||
CREATE TABLE laravel_example (
|
||||
-- 主键
|
||||
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
id INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
|
||||
-- 业务字段
|
||||
name VARCHAR(200) NOT NULL COMMENT '案例名称',
|
||||
type TINYINT(1) DEFAULT 0 COMMENT '案例类型(字典 example_type)',
|
||||
status TINYINT(1) DEFAULT 0 COMMENT '案例状态(字典 example_status)',
|
||||
sort INT DEFAULT 0 COMMENT '排序值(越小越靠前)',
|
||||
avatar VARCHAR(500) DEFAULT '' COMMENT '头像(文件路径)',
|
||||
name VARCHAR(255) NOT NULL COMMENT '案例名称',
|
||||
avatar VARCHAR(255) NULL DEFAULT NULL COMMENT '案例图片',
|
||||
type INT NULL DEFAULT 0 COMMENT '案例类型:1-类型1 2-类型2 3-类型3 4-类型4',
|
||||
status INT NULL DEFAULT 0 COMMENT '案例状态:1-在用 2-停用',
|
||||
sort INT NULL DEFAULT 0 COMMENT '案例排序',
|
||||
|
||||
-- 公共字段(必须)
|
||||
is_delete TINYINT(1) DEFAULT 0 COMMENT '软删除 0=正常 1=已删除',
|
||||
create_user VARCHAR(50) DEFAULT '' COMMENT '创建人',
|
||||
create_time DATETIME COMMENT '创建时间',
|
||||
update_user VARCHAR(50) DEFAULT '' COMMENT '更新人',
|
||||
update_time DATETIME COMMENT '更新时间',
|
||||
create_user VARCHAR(50) NULL DEFAULT NULL COMMENT '创建人',
|
||||
create_time DATETIME NULL DEFAULT (now()) COMMENT '创建时间',
|
||||
update_user VARCHAR(50) NULL DEFAULT NULL COMMENT '更新人',
|
||||
update_time DATETIME NULL DEFAULT (now()) COMMENT '更新时间',
|
||||
is_delete INT NULL DEFAULT 0 COMMENT '删除标识:0-正常 1-已删除',
|
||||
|
||||
-- 索引
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_sort (sort)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='案例演示表';
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_think_level_is_delete (is_delete),
|
||||
INDEX idx_think_level_name (name),
|
||||
INDEX idx_think_level_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='案例演示表';
|
||||
```
|
||||
|
||||
## 字段与 Logic 属性对应关系
|
||||
:::tip 枚举注释写法
|
||||
枚举字段的注释直接写成 `1-在用 2-停用` 格式,代码生成器据此自动解析选项(见 [10.4 表结构解析引擎](/zh/generator/parser.html))。
|
||||
:::
|
||||
|
||||
| 数据库字段 | 类型 | Logic 属性 | 说明 |
|
||||
:::tip 表前缀
|
||||
建表 SQL 中的表名**含** `laravel_` 前缀(与 `.env` 中 `DB_PREFIX` 一致);模型层的 `$table` 只写不含前缀的 `example`,查询时由 Laravel 自动拼接前缀。
|
||||
:::
|
||||
|
||||
## 字段与 Service 属性对应关系
|
||||
|
||||
(对照 `app/Services/ExampleService.php` 实际声明)
|
||||
|
||||
| 数据库字段 | 类型 | Service 属性 | 说明 |
|
||||
|-----------|------|-----------|------|
|
||||
| `name` | VARCHAR(200) | `pageLikeFields` | LIKE 模糊搜索 |
|
||||
| `type` | TINYINT(1) | `pageEqFields` + `serializeMaps` | 精确匹配 + 枚举翻译 |
|
||||
| `status` | TINYINT(1) | `pageEqFields` + `serializeMaps` | 精确匹配 + 枚举翻译 |
|
||||
| `sort` | INT | `pageOrderBy` | 默认排序字段 |
|
||||
| `avatar` | VARCHAR(500) | `fileFields` | 文件字段,自动处理 |
|
||||
| `name` | VARCHAR(255) | `pageLikeFields` + `uniqueFields` | LIKE 模糊搜索 + 名称唯一校验 |
|
||||
| `status` | INT | `pageEqFields` + `serializeMaps` | 精确匹配 + 字典翻译(`example_status` → 补 `statusText`) |
|
||||
| `type` | INT | —(普通表单/列表字段) | 注释含选项,前端按 `columns.ts` 渲染 |
|
||||
| `sort` | INT | —(默认排序用基类 `pageOrderBy`) | 排序值 |
|
||||
| `avatar` | VARCHAR(255) | —(前端 `UploadImg` 直传) | 图片路径字段 |
|
||||
|
||||
## 设计检查清单
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 公共字段齐全 | `id` / `is_delete` / 四个审计字段 |
|
||||
| 字段小写下划线 | `category_id`、`create_time` |
|
||||
| 枚举字段配字典 | 字段注释写明字典编码,便于生成器提取 |
|
||||
| 模糊搜索字段建索引 | LIKE 查询字段建议加索引 |
|
||||
| 需要唯一性的字段 | 在 Service 配置 `uniqueFields`(不是数据库唯一索引,软删除场景更安全) |
|
||||
|
||||
## 常见字段类型参考
|
||||
|
||||
@@ -70,7 +92,11 @@ CREATE TABLE think_example (
|
||||
| 外键 | `INT DEFAULT 0` | `category_id INT DEFAULT 0` |
|
||||
| 短文本 | `VARCHAR(n)` | `name VARCHAR(200)` |
|
||||
| 长文本 | `TEXT` | `content TEXT` |
|
||||
| 布尔值 | `TINYINT(1)` | `status TINYINT(1) DEFAULT 0` |
|
||||
| 布尔值 | `TINYINT(1)` | `status TINYINT(1) DEFAULT 1` |
|
||||
| 整数 | `INT` | `sort INT DEFAULT 0` |
|
||||
| 日期时间 | `DATETIME` | `create_time DATETIME` |
|
||||
| 文件路径 | `VARCHAR(500)` | `avatar VARCHAR(500)` |
|
||||
| 文件路径 | `VARCHAR(500)` | `avatar VARCHAR(500)` |
|
||||
|
||||
:::tip
|
||||
本项目 `database/migrations/` 为空,建表后**直接执行 SQL**(或在已有数据库上 `ALTER TABLE`),无需迁移。多数据库脚本见 [2.5 数据库初始化](/zh/quickstart/database-init.html)。
|
||||
:::
|
||||
|
||||
@@ -5,67 +5,77 @@
|
||||
### Q: 新增接口返回 401
|
||||
|
||||
A: 检查以下几点:
|
||||
|
||||
1. 请求头是否携带 `Authorization: Bearer <token>`
|
||||
2. Token 是否过期(默认 2 小时)
|
||||
3. 路由是否在 `route/app.php` 的需认证组中
|
||||
2. Token 是否过期(access_token 默认 2 小时,可调 `/api/refresh` 续签)
|
||||
3. 路由是否在 `routes/api.php` 的认证中间件组内(组外为公开接口)
|
||||
|
||||
### Q: 新增接口返回 403
|
||||
|
||||
A: 权限不足,检查:
|
||||
1. 是否在 Controller 方法上添加了 `#[Permission]` 注解
|
||||
2. 权限码是否在菜单管理中配置
|
||||
3. 用户角色是否包含该权限节点
|
||||
|
||||
### Q: 枚举显示名(typeText/statusText)为空
|
||||
1. 是否在 Controller 方法上添加了 `#[Permission]` 注解
|
||||
2. 权限码是否在菜单管理中配置(`permission` 字段与注解完全一致)
|
||||
3. 用户角色是否包含该权限节点(角色管理 → 勾选菜单/按钮)
|
||||
|
||||
### Q: 报 `Table 'xxx.example' doesn't exist`
|
||||
|
||||
A: `DB_PREFIX` 未配置。建表 SQL 使用 `laravel_` 前缀,`.env` 中必须保留 `DB_PREFIX=laravel_`,改后执行 `php artisan config:clear`。
|
||||
|
||||
### Q: 枚举显示名(statusText)为空
|
||||
|
||||
A: 检查:
|
||||
1. 字典表中是否存在对应的字典编码(如 `example_type`)
|
||||
2. 字典项的 value 值是否与数据中的值匹配
|
||||
3. Logic 中 `serializeMaps` 配置是否正确
|
||||
|
||||
1. 字典表中是否存在对应的字典编码(如 `example_status`)
|
||||
2. 字典项的 value 是否与数据中的值匹配
|
||||
3. Service 中 `serializeMaps` 配置是否正确
|
||||
4. 字典缓存是否为旧数据:`php artisan dict:clear-cache`
|
||||
|
||||
```php
|
||||
// ExampleLogic 中的配置
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
// ExampleService 中的配置
|
||||
protected array $serializeMaps = [
|
||||
'type' => 'example_type',
|
||||
'status' => 'example_status',
|
||||
];
|
||||
```
|
||||
|
||||
### Q: 文件上传后 avatar 路径为空
|
||||
### Q: 唯一性校验不生效 / 误报重复
|
||||
|
||||
A: 检查:
|
||||
1. `.env` 中 `FILE.UPLOAD_DIR` 是否配置
|
||||
2. 上传目录是否存在且有写入权限
|
||||
3. Logic 中是否配置了 `fileFields`
|
||||
|
||||
```php
|
||||
// ExampleLogic 中的配置
|
||||
protected array $fileFields = ['avatar'];
|
||||
protected string $fileSaveDir = 'example';
|
||||
```
|
||||
1. Service 是否 `use HasUniqueCheck`(未启用时 `uniqueFields` 不生效)
|
||||
2. `uniqueFields` 字段名是否与入参键名一致(snake_case)
|
||||
3. 修改时是否正确排除自身(`checkUnique($data, $excludeId)` 由基类传入 id)
|
||||
4. 软删除的旧记录仍占唯一规则——这正是不用数据库唯一索引的原因,如需彻底放开需先清理历史数据
|
||||
|
||||
### Q: 排序不生效
|
||||
|
||||
A: 检查 Logic 中 `pageOrderBy` 配置:
|
||||
A: 检查 Service 中 `pageOrderBy` 配置:
|
||||
|
||||
```php
|
||||
// ExampleLogic 中的配置
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
```
|
||||
|
||||
前端可通过 `orderField` 和 `orderType` 参数覆盖默认排序。
|
||||
前端可通过 `orderField` / `orderType` 参数覆盖默认排序,但字段必须命中 `pageOrderBy` 默认字段与 `orderFieldAllow` 的**并集**(白名单防注入),否则被忽略。
|
||||
|
||||
### Q: 新增/修改时报"参数验证失败",data 里没有字段明细
|
||||
|
||||
A: 两种校验路径的响应结构不同:
|
||||
|
||||
- FormRequest 注入失败:`data` 为 `{字段: [错误数组]}`(code=1)
|
||||
- Service 业务异常:`data` 为 null(code=1,看 msg)
|
||||
|
||||
确认控制器方法是否类型注入了 FormRequest,以及是否误把格式规则写进了 Service。
|
||||
|
||||
### Q: 审计字段(create_user)为空
|
||||
|
||||
A: `create_user` 取自当前登录用户(`Auth::username()`),需经 `auth.jwt` 中间件(登录态)调用;命令行/队列等无登录态场景需手动赋值。
|
||||
|
||||
## 前端问题
|
||||
|
||||
### Q: 菜单不显示
|
||||
|
||||
A: 检查:
|
||||
|
||||
1. 后端菜单管理中是否添加了对应菜单
|
||||
2. 菜单的组件路径是否正确(如 `tool/example/index`)
|
||||
3. 前端 `views` 目录下是否有对应的 `.vue` 文件
|
||||
@@ -74,20 +84,43 @@ A: 检查:
|
||||
### Q: API 请求 404
|
||||
|
||||
A: 检查:
|
||||
1. `route/app.php` 中是否注册了对应路由
|
||||
|
||||
1. `routes/api.php` 中是否注册了对应路由
|
||||
2. 路由路径是否与前端 API 模块中的 `url` 一致
|
||||
3. HTTP 方法是否正确(GET/POST/PUT/DELETE)
|
||||
4. 批量删除是 **DELETE** `batchDelete`(请求体 `ids` 数组——方法为 DELETE 但仍携带 body)
|
||||
|
||||
### Q: 表格数据不显示
|
||||
|
||||
A: 检查:
|
||||
1. API 返回的数据结构是否正确(应包含 `records` 和 `total`)
|
||||
2. 前端是否正确绑定了 `tableData`
|
||||
|
||||
1. API 返回的数据结构是否正确(`data` 应包含 `records` 和 `total`)
|
||||
2. 前端是否正确绑定了 `tableData`(`res.records`)
|
||||
3. 浏览器控制台是否有报错
|
||||
|
||||
### Q: 导入失败
|
||||
|
||||
A: 检查:
|
||||
1. 文件格式是否为 `.xls` 或 `.xlsx`
|
||||
|
||||
1. 文件格式是否为 `.xls` 或 `.xlsx`(基类 `_import` 校验扩展名 + MIME + 10MB 上限)
|
||||
2. 表头是否与 `headerMap` 中定义的中文名一致
|
||||
3. 枚举字段的值是否在字典中存在
|
||||
3. 枚举字段的值是否在字典中存在(文本值需能反查到字典)
|
||||
|
||||
### Q: 接口返回的键名大小写与预期不符
|
||||
|
||||
A: 项目开启了驼峰/下划线自动转换(`API_CAMEL_SNAKE_CONVERT=true`):
|
||||
|
||||
- 响应:`create_time` → `createTime`(数据库字段 → 前端字段)
|
||||
- 请求:前端传 camelCase,Service 写入入口(doAdd/doUpdate)统一转为 snake_case
|
||||
|
||||
如需原样返回,使用 `Result::success($data, $msg, true)`(`$raw=true`)。
|
||||
|
||||
## 生成器问题
|
||||
|
||||
### Q: 用生成器生成的模块菜单没有出现
|
||||
|
||||
A: 检查生成配置中的父菜单 ID(`.env` 中 `GENERATOR_PARENT_MENU_ID`)是否存在,以及当前角色是否被分配了新菜单;生成后重新登录或刷新获取最新菜单。
|
||||
|
||||
### Q: 生成的路由 404
|
||||
|
||||
A: 确认生成时是否勾选路由注册;手动检查 `routes/api.php` 中是否已有对应路由(重复注册会报错),改完执行 `php artisan route:list` 核对。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 3.9 前端联调指引
|
||||
|
||||
:::tip 概述
|
||||
后端接口开发完成后,需要在前端创建对应的 API 模块和页面组件。本节以 example 模块为例,提供简要指引,详细内容参考[第 9 章](/zh/frontend/overview.html)。
|
||||
后端接口开发完成后,需要在前端创建对应的 API 模块和页面组件。本节以 example 模块为例,提供简要指引,详细内容参考 [第 9 章](/zh/frontend/overview.html)。
|
||||
:::
|
||||
|
||||
## 联调流程
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## 步骤一:创建 API 模块
|
||||
|
||||
在 `ui/src/api/tool/` 下创建 `example.ts`(来自项目实际代码):
|
||||
在 `ui/src/api/tool/` 下创建 `example.ts`:
|
||||
|
||||
```typescript
|
||||
// ui/src/api/tool/example.ts
|
||||
@@ -31,7 +31,7 @@ import { http } from '@/utils/http/axios';
|
||||
* 获取案例演示分页列表
|
||||
*
|
||||
* @param params 查询参数,包含 pageNo、pageSize 及业务筛选条件
|
||||
* @returns 分页结果,含 records、total、size、current、pages
|
||||
* @returns 分页结果,含 records、total、current、size
|
||||
*/
|
||||
export function getExamplePage(params?: Record<string, any>) {
|
||||
return http.request({
|
||||
@@ -43,9 +43,6 @@ export function getExamplePage(params?: Record<string, any>) {
|
||||
|
||||
/**
|
||||
* 获取全部案例演示列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 案例演示列表
|
||||
*/
|
||||
export function getExampleList(params?: Record<string, any>) {
|
||||
return http.request({
|
||||
@@ -57,11 +54,8 @@ export function getExampleList(params?: Record<string, any>) {
|
||||
|
||||
/**
|
||||
* 根据 ID 获取案例演示详情
|
||||
*
|
||||
* @param id 案例演示ID
|
||||
* @returns 案例演示详情数据
|
||||
*/
|
||||
export function getExampleDetail(id) {
|
||||
export function getExampleDetail(id: number) {
|
||||
return http.request({
|
||||
url: '/example/detail/' + id,
|
||||
method: 'get',
|
||||
@@ -69,10 +63,7 @@ export function getExampleDetail(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加案例演示
|
||||
*
|
||||
* @param data 案例演示数据
|
||||
* @returns 新记录的 ID
|
||||
* 添加案例演示(入参 camelCase,后端 FormRequest 自动转换)
|
||||
*/
|
||||
export function exampleAdd(data: any) {
|
||||
return http.request({
|
||||
@@ -83,10 +74,7 @@ export function exampleAdd(data: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新案例演示
|
||||
*
|
||||
* @param data 案例演示数据,需包含主键 id
|
||||
* @returns 操作结果
|
||||
* 更新案例演示(需包含主键 id)
|
||||
*/
|
||||
export function exampleUpdate(data: any) {
|
||||
return http.request({
|
||||
@@ -98,11 +86,8 @@ export function exampleUpdate(data: any) {
|
||||
|
||||
/**
|
||||
* 删除案例演示
|
||||
*
|
||||
* @param id 案例演示ID
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function exampleDelete(id) {
|
||||
export function exampleDelete(id: number) {
|
||||
return http.request({
|
||||
url: '/example/delete/' + id,
|
||||
method: 'DELETE',
|
||||
@@ -110,10 +95,9 @@ export function exampleDelete(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除案例演示
|
||||
* 批量删除案例演示(形如 { ids: [1, 2, 3] })
|
||||
*
|
||||
* @param data 含 ids 数组的对象,形如 { ids: [1, 2, 3] }
|
||||
* @returns 操作结果
|
||||
* 注意:批量删除后端路由为 DELETE(请求体仍传 ids 数组)。
|
||||
*/
|
||||
export function exampleBatchDelete(data: any) {
|
||||
return http.request({
|
||||
@@ -124,12 +108,9 @@ export function exampleBatchDelete(data: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入案例演示
|
||||
*
|
||||
* @param data 文件数据(FormData)
|
||||
* @returns 导入结果,含成功数量与失败明细
|
||||
* 导入案例演示(FormData 文件)
|
||||
*/
|
||||
export function exampleImport(data) {
|
||||
export function exampleImport(data: any) {
|
||||
return http.request({
|
||||
url: '/example/import',
|
||||
method: 'POST',
|
||||
@@ -138,12 +119,9 @@ export function exampleImport(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出案例演示
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 导出文件的 URL
|
||||
* 导出案例演示(返回文件 URL)
|
||||
*/
|
||||
export function exampleExport(params) {
|
||||
export function exampleExport(params?: Record<string, any>) {
|
||||
return http.request({
|
||||
url: '/example/export',
|
||||
method: 'GET',
|
||||
@@ -152,9 +130,13 @@ export function exampleExport(params) {
|
||||
}
|
||||
```
|
||||
|
||||
:::tip
|
||||
HTTP 封装(Token 携带、401 处理、统一错误提示、camel/snake 转换)由 `http` 实例统一完成,API 模块只描述"哪个地址、什么方法"。见 [9.4 HTTP 请求封装](/zh/frontend/http.html)。
|
||||
:::
|
||||
|
||||
## 步骤二:创建页面组件
|
||||
|
||||
在 `ui/src/views/tool/example/` 下创建以下 5 个文件:
|
||||
在 `ui/src/views/tool/example/` 下创建以下 5 个文件(与代码生成器输出结构一致):
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
@@ -165,7 +147,7 @@ export function exampleExport(params) {
|
||||
| `querySchemas.ts` | 搜索表单字段定义 |
|
||||
|
||||
:::tip 关于以下示例
|
||||
以下代码使用原生 `el-table` / `el-form` 简化展示核心逻辑。实际项目中应使用封装好的 `BasicTable` + `useForm` 等组合式组件,详见[第 9 章组件文档](/zh/frontend/overview.html)。
|
||||
以下代码使用原生 `el-table` / `el-form` 简化展示核心逻辑。实际项目中应使用封装好的 `BasicTable` + `useForm` 等组合式组件,详见 [第 9 章组件文档](/zh/frontend/overview.html)。
|
||||
:::
|
||||
|
||||
`index.vue`(列表页)示例:
|
||||
@@ -182,7 +164,7 @@ export function exampleExport(params) {
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
<el-option label="停用" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@@ -191,30 +173,29 @@ export function exampleExport(params) {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-button type="primary" @click="handleAdd">新增</el-button>
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<!-- 操作按钮(v-perm 控制显示,与后端权限码一致) -->
|
||||
<el-button v-perm="['sys:example:add']" type="primary" @click="handleAdd">新增</el-button>
|
||||
<el-button v-perm="['sys:example:batchDelete']" type="danger"
|
||||
:disabled="!selectedIds.length" @click="handleBatchDelete">批量删除</el-button>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="typeText" label="类型" width="100" />
|
||||
<el-table-column prop="statusText" label="状态" width="100" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
<el-button v-perm="['sys:example:update']" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-perm="['sys:example:delete']" size="small" type="danger"
|
||||
@click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<!-- 分页(后端固定返回 records / total / current / size / pages) -->
|
||||
<el-pagination
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
@@ -263,13 +244,25 @@ onMounted(() => fetchData());
|
||||
tool/example/index
|
||||
```
|
||||
|
||||
前端路由系统会自动将此路径映射到 `ui/src/views/tool/example/index.vue`。
|
||||
前端路由系统会自动将此路径映射到 `ui/src/views/tool/example/index.vue`(动态路由由后端菜单数据驱动)。
|
||||
|
||||
## 步骤四:测试联调
|
||||
|
||||
1. 确保后端服务运行:`php think run`
|
||||
2. 确保前端服务运行:`pnpm dev`
|
||||
3. 刷新前端页面,侧边栏应出现"案例演示"菜单
|
||||
4. 点击菜单,测试增删改查功能
|
||||
1. 确保后端服务运行:`php artisan serve`(或 `composer dev`)
|
||||
2. 确保前端服务运行:`cd ui && pnpm dev`(开发端口 8001)
|
||||
3. 为测试角色分配新菜单权限(见 [3.8 权限节点配置](/zh/guide/permission.html))
|
||||
4. 刷新前端页面,侧边栏应出现"案例演示"菜单
|
||||
5. 点击菜单,测试增删改查功能
|
||||
|
||||
详细前端开发内容请参考[第 9 章](/zh/frontend/overview.html)。
|
||||
### 联调自查顺序
|
||||
|
||||
```text
|
||||
接口 404 → 路由未注册 / URL、HTTP 方法与 routes/api.php 不一致
|
||||
接口 401 → Token 未携带或过期(看 http 封装是否注入 Authorization)
|
||||
接口 403 → 权限码未配置 / 角色未分配(见 3.8)
|
||||
接口 code=1 → 看 msg 与 data(FormRequest 字段错误明细)
|
||||
表格不显示 → 响应结构应为 data.records / data.total,检查绑定字段
|
||||
菜单不显示 → 组件路径错误 / 角色未分配菜单 / 前端 views 下无对应 .vue
|
||||
```
|
||||
|
||||
详细前端开发内容请参考 [第 9 章](/zh/frontend/overview.html)。
|
||||
|
||||
@@ -1,319 +1,330 @@
|
||||
# 3.5 逻辑层开发
|
||||
# 3.5 Service 层开发
|
||||
|
||||
:::tip 概述
|
||||
逻辑层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
|
||||
Service 层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
|
||||
:::
|
||||
|
||||
## 逻辑层文件位置
|
||||
## Service 文件位置
|
||||
|
||||
```text
|
||||
app/logic/ExampleLogic.php
|
||||
app/Services/ExampleService.php
|
||||
```
|
||||
|
||||
## 完整代码(来自项目实际代码)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\logic;
|
||||
namespace App\Services;
|
||||
|
||||
use app\BaseLogic; // 系统共享模块基类
|
||||
use app\model\Example; // 案例演示模型
|
||||
use App\Models\Example;
|
||||
use App\Services\Concerns\HasCrudOperations;
|
||||
use App\Services\Concerns\HasQueryScopes;
|
||||
use App\Services\Concerns\HasSerializeMaps;
|
||||
use App\Services\Concerns\HasUniqueCheck;
|
||||
|
||||
/**
|
||||
* 案例演示业务逻辑类
|
||||
* 案例演示业务逻辑服务
|
||||
*
|
||||
* 继承 BaseLogic,通过属性配置声明式地定义案例演示模块的通用行为,
|
||||
* 并按需生成导入、导出方法。
|
||||
* 继承 BaseService,案例作为示例数据,不参与多租户隔离。
|
||||
*
|
||||
* 配置覆盖:文件字段、查询条件、排序、唯一性校验、枚举显示名映射。
|
||||
* 说明:
|
||||
* - 组合的 Trait 职责:
|
||||
* - HasCrudOperations:add/update/delete/batchDelete 主流程;
|
||||
* - HasQueryScopes:列表查询条件、排序白名单;
|
||||
* - HasSerializeMaps:为 status 补 statusText(字典编码为 status);
|
||||
* - HasUniqueCheck:新增/修改时校验 name 唯一;
|
||||
* - 未 use HasDataScope,因此不做数据权限与租户隔离;
|
||||
* - 除标准 CRUD 外,提供 export / import,供 ExampleController 调用;
|
||||
* - 导入逐条调用 add(),单条失败不中断,错误明细汇总返回。
|
||||
*/
|
||||
class ExampleLogic extends BaseLogic
|
||||
class ExampleService extends BaseService
|
||||
{
|
||||
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
|
||||
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* 指定该逻辑层操作的数据模型为 Example。
|
||||
* BaseLogic 的所有 CRUD 方法都基于此模型。
|
||||
*
|
||||
* @var string
|
||||
* 绑定的模型类
|
||||
*/
|
||||
protected string $modelClass = Example::class;
|
||||
|
||||
/**
|
||||
* 验证器类名
|
||||
*
|
||||
* 设置后,add/update 时自动调用对应验证器的 add/update 场景进行参数校验。
|
||||
* 校验失败抛出 ValidateException,由 ExceptionHandle 统一返回。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $validateClass = \app\validate\ExampleValidate::class;
|
||||
|
||||
/**
|
||||
* 文件字段
|
||||
*
|
||||
* 新增/修改时迁移临时文件并去掉域名,
|
||||
* 查询时自动补全完整访问 URL。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $fileFields = ['avatar'];
|
||||
|
||||
/**
|
||||
* 多文件字段(逗号分隔的多个URL)
|
||||
*
|
||||
* 与 fileFields 不同,这些字段的值是逗号分隔的多个文件URL,
|
||||
* 新增/修改时逐个迁移临时文件并去掉域名,
|
||||
* 查询时逐个补全完整访问 URL。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $multiFileFields = ['images'];
|
||||
|
||||
/**
|
||||
* 文件保存子目录
|
||||
*
|
||||
* 文件字段上传后保存到该子目录下。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $fileSaveDir = 'example';
|
||||
|
||||
/**
|
||||
* LIKE 模糊匹配字段
|
||||
*
|
||||
* 分页查询时,前端传入的 name 参数会自动使用 LIKE 查询。
|
||||
* 例如:GET /api/example/page?name=测试
|
||||
* 生成 SQL:WHERE name LIKE '%测试%'
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $pageLikeFields = ['name'];
|
||||
|
||||
/**
|
||||
* 精确匹配字段
|
||||
*
|
||||
* 分页查询时,前端传入的 type 和 status 参数
|
||||
* 会自动使用 = 查询。
|
||||
* 例如:GET /api/example/page?type=1&status=1
|
||||
* 生成 SQL:WHERE type = 1 AND status = 1
|
||||
* status 用于按状态筛选。
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $pageEqFields = ['type', 'status'];
|
||||
protected array $pageEqFields = ['status'];
|
||||
|
||||
/**
|
||||
* 默认排序规则
|
||||
* 唯一性校验字段
|
||||
*
|
||||
* 支持两种格式:
|
||||
* - 单字段:['field' => 'sort', 'type' => 'asc']
|
||||
* - 多字段:[['field' => 'sort', 'type' => 'asc'], ['field' => 'id', 'type' => 'desc']]
|
||||
*
|
||||
* 前端可通过 orderField/orderType 参数覆盖。
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
protected array $uniqueFields = ['name'];
|
||||
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
* 字典翻译配置:[字段 => 字典编码]
|
||||
*
|
||||
* 查询数据时自动根据字典编码翻译值为描述文本,补 {字段名}Text 字段。
|
||||
* 例如:type=1 时,typeText='类型一'(从字典 example_type 读取)。
|
||||
* status=1 时,statusText='启用'(从字典 example_status 读取)。
|
||||
* 读取时为 status 补 statusText。
|
||||
* 字典编码 "status" 需在字典表中存在(code = 'status'),
|
||||
* 否则 statusText 会补空字符串。
|
||||
*
|
||||
* @var array
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'type' => 'example_type',
|
||||
'status' => 'example_status',
|
||||
];
|
||||
// ... 其他属性配置
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 属性配置即全部
|
||||
子类只需声明以上属性,无需编写任何 CRUD 代码,即可获得完整的分页查询、详情、新增、修改、删除能力。这是 BaseLogic 模板方法模式的核心优势。
|
||||
子类只声明以上属性,**分页、详情、新增、修改、删除方法全部由 Trait 提供,无需手写**——这就是声明式 CRUD。`add` 内部还会自动完成:唯一性校验 → 钩子 → 事务 → 审计字段 → 字典翻译。
|
||||
:::
|
||||
|
||||
## 扩展方法(导入导出示例)
|
||||
## Trait 组合机制(BaseService + 6 个 Concerns)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 导入案例演示数据
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 按表单字段生成"中文表头 => 字段名"的映射,跳过图片与富文本字段;
|
||||
* 2. 调用 ExcelService::importWithMap 解析文件为关联数组;
|
||||
* 3. 枚举字段反向映射:把文字通过 DictService 还原为数值;
|
||||
* 4. 逐行调用 add 方法写入,成功计数,失败收集行号与原因;
|
||||
* 5. 返回成功数与错误列表。
|
||||
*
|
||||
* @param string $filePath 文件路径
|
||||
* @return array ['count' => 成功数, 'errors' => [失败行信息]]
|
||||
* @throws \Exception 文件解析失败时抛出
|
||||
*/
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
// 定义表头映射:Excel 表头名 → 数据库字段名
|
||||
$headerMap = [
|
||||
'案例名称' => 'name',
|
||||
'案例类型' => 'type',
|
||||
'案例状态' => 'status',
|
||||
'案例排序' => 'sort',
|
||||
];
|
||||
所有通用属性集中在 `BaseService` 声明,6 个 Trait **只放方法**:
|
||||
|
||||
// 解析 Excel 文件为关联数组
|
||||
$data = \app\service\ExcelService::importWithMap($filePath, $headerMap);
|
||||
| Trait | 职责 | example 是否启用 |
|
||||
|------|------|------|
|
||||
| `HasCrudOperations` | add / update / delete / batchDelete 主流程 + 生命周期钩子 + `DB::transaction` 事务包裹 | ✅ |
|
||||
| `HasQueryScopes` | `buildQuery` 查询构建(LIKE/精确匹配)、排序白名单、`orderField` 动态排序 | ✅ |
|
||||
| `HasSerializeMaps` | 字典翻译:读取时按 `serializeMaps` 补 `{字段}Text`(DictCacheService 批量优化) | ✅ |
|
||||
| `HasUniqueCheck` | 按 `uniqueFields` 做唯一性校验,重复时抛 `BizException` | ✅ |
|
||||
| `HasFileProcessing` | 文件/多文件/富文本字段的保存迁移与读取域名补全 | ❌ 本例未配置文件字段 |
|
||||
| `HasDataScope` | 数据权限(本部门/仅本人)+ 租户隔离(`tenant_id` 过滤与填充) | ❌ 本例不做隔离 |
|
||||
|
||||
// 枚举字段反向映射:文字转数字
|
||||
// 例如 Excel 中"启用" → 数据库中 1
|
||||
foreach ($data as &$row) {
|
||||
if (!empty($row['type']) && !is_numeric($row['type'])) {
|
||||
$row['type'] = \app\service\DictService::getValue('example_type', $row['type']);
|
||||
}
|
||||
if (!empty($row['status']) && !is_numeric($row['status'])) {
|
||||
$row['status'] = \app\service\DictService::getValue('example_status', $row['status']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
:::tip 占位覆盖机制
|
||||
`BaseService` 底部为每个 Trait 方法提供**占位实现**(如 `processFileFieldsOnSave()` 直接 `return $data`)。子类 `use` 对应 Trait 后,PHP Trait 机制自动以真实实现覆盖占位——未启用的 Trait 方法保持空操作,避免调用未定义方法。
|
||||
:::
|
||||
|
||||
// 逐行导入
|
||||
$count = 0;
|
||||
$errors = [];
|
||||
foreach ($data as $index => $row) {
|
||||
try {
|
||||
$this->add($row); // 调用 BaseLogic::add,触发 beforeAdd/afterAdd 钩子
|
||||
$count++;
|
||||
} catch (\Exception $e) {
|
||||
// 单行失败不影响其它行,收集错误信息
|
||||
$errors[] = [
|
||||
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $count, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出案例演示数据
|
||||
*
|
||||
* 处理流程:
|
||||
* 1. 按列表字段生成导出表头,跳过图片与富文本字段;
|
||||
* 带选项的字段使用 {字段名}Text,以导出显示名而非原始值;
|
||||
* 2. 追加创建时间列;
|
||||
* 3. 调用 ExcelService::chunkExport 分批导出,每批 500 条,
|
||||
* 避免一次性加载全部数据导致内存溢出。
|
||||
*
|
||||
* @param array $params 查询参数
|
||||
* @return string 导出文件的 URL
|
||||
* @throws \Exception 导出失败时抛出
|
||||
*/
|
||||
public function export(array $params = []): string
|
||||
{
|
||||
// 定义导出表头:字段名 → Excel 表头名
|
||||
// 带 Text 后缀的字段取枚举显示名(如 typeText='类型一')
|
||||
$headers = [
|
||||
'name' => '案例名称',
|
||||
'typeText' => '案例类型', // 枚举显示名
|
||||
'statusText' => '案例状态', // 枚举显示名
|
||||
'sort' => '案例排序',
|
||||
'create_time' => '创建时间',
|
||||
];
|
||||
|
||||
// 分批导出,每批 500 条
|
||||
return \app\service\ExcelService::chunkExport($headers, function (int $page) use ($params) {
|
||||
$chunk = [];
|
||||
// chunkList 是 BaseLogic 提供的分批查询方法
|
||||
$this->chunkList($params, function (array $data) use (&$chunk) {
|
||||
$chunk = $data;
|
||||
}, 500);
|
||||
return $chunk;
|
||||
}, '案例演示数据.xlsx', 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 属性配置速查
|
||||
## 属性配置速查(BaseService 全部声明式属性)
|
||||
|
||||
| 属性 | 类型 | 说明 | 本例配置 |
|
||||
|------|------|------|----------|
|
||||
| `modelClass` | string | 关联模型类名(必须) | `Example::class` |
|
||||
| `validateClass` | string | 验证器类名(自动参数校验) | `ExampleValidate::class` |
|
||||
| `fileFields` | array | 单文件字段列表 | `['avatar']` |
|
||||
| `multiFileFields` | array | 多文件字段列表(逗号分隔的多个URL) | `['images']` |
|
||||
| `fileSaveDir` | string | 文件保存子目录 | `'example'` |
|
||||
| `modelClass` | string | 绑定的模型类(必须) | `Example::class` |
|
||||
| `defaultWith` | array | 查询默认预加载的关联 | `[]` |
|
||||
| `pageLikeFields` | array | LIKE 模糊匹配字段 | `['name']` |
|
||||
| `pageEqFields` | array | 精确匹配字段 | `['type', 'status']` |
|
||||
| `pageOrderBy` | array | 默认排序规则 | `['field'=>'sort','type'=>'asc']` |
|
||||
| `uniqueFields` | array | 唯一性校验字段 | `[]`(本例未配置) |
|
||||
| `serializeMaps` | array | 枚举显示名映射 | `['type'=>'example_type', 'status'=>'example_status']` |
|
||||
| `contentFields` | array | 富文本字段 | `[]`(本例未配置) |
|
||||
| `pageEqFields` | array | 精确匹配字段 | `['status']` |
|
||||
| `pageOrderBy` | array | 默认排序(单字段或多字段数组) | 继承默认 `['field'=>'id','type'=>'desc']` |
|
||||
| `orderFieldAllow` | array | 允许前端动态排序的字段白名单(与默认字段取并集) | `[]` |
|
||||
| `treeParentField` | string | 树形结构父级字段 | `'parent_id'`(treeList 用) |
|
||||
| `treeLikeField` | string | 树形层级 LIKE 字段(按前缀查子树) | `''` |
|
||||
| `fileFields` | array | 单文件字段(需 `use HasFileProcessing`) | 未配置 |
|
||||
| `multiFileFields` | array | 多文件字段(逗号分隔的多个 URL) | 未配置 |
|
||||
| `contentFields` | array | 富文本字段(占位符还原) | 未配置 |
|
||||
| `fileSaveDir` | string | 文件保存子目录 | 未配置 |
|
||||
| `dataScopeUserField` | string | 数据权限-本人字段(需 `use HasDataScope`) | 未配置 |
|
||||
| `dataScopeDeptField` | string | 数据权限-部门字段(需 `use HasDataScope`) | 未配置 |
|
||||
| `tenantScopeField` | string | 租户隔离字段(需 `use HasDataScope`) | 未配置 |
|
||||
| `serializeMaps` | array | 字典翻译:`[字段 => 字典编码]` | `['status'=>'example_status']` |
|
||||
| `uniqueFields` | array | 唯一性校验字段/规则 | `['name']` |
|
||||
|
||||
动态排序:前端可通过 `orderField` / `orderType` 参数覆盖默认排序(字段做 camel→snake 转换后,须命中 `pageOrderBy` 默认字段与 `orderFieldAllow` 的并集,否则忽略——防注入白名单)。
|
||||
|
||||
## 生命周期钩子
|
||||
|
||||
`HasCrudOperations` 提供 10 个钩子,按需重写:
|
||||
|
||||
| 钩子 | 时机 | 用途 |
|
||||
|------|------|------|
|
||||
| `beforeAdd($data)` | 新增前 | 修改数据、校验、抛异常拦截 |
|
||||
| `afterAdd($id, $data)` | 新增后 | 保存关联表、发送通知 |
|
||||
| `beforeUpdate($id, $data)` | 修改前 | 修改数据、校验 |
|
||||
| `afterUpdate($id, $data)` | 修改后 | 更新关联表 |
|
||||
| `beforeDelete($id)` | 删除前 | 检查是否有子级、抛异常拦截 |
|
||||
| `afterDelete($id)` | 删除后 | 清理关联数据 |
|
||||
| `afterDetail($id, &$data)` | 详情查询后 | 补充关联数据、格式化输出 |
|
||||
| `afterPageList(&$records, $params)` | 列表查询后 | 批量补充关联数据(分页和全量列表均触发) |
|
||||
| `beforeAdd(array $data): array` | 新增前 | 修改数据、校验、抛异常拦截(可改写入数据) |
|
||||
| `afterAdd(int $id, array $data): void` | 新增后 | 保存关联表、发送通知 |
|
||||
| `beforeUpdate(int $id, array $data): array` | 修改前 | 修改数据、校验 |
|
||||
| `afterUpdate(int $id, array $data): void` | 修改后 | 更新关联表 |
|
||||
| `beforeDelete(int $id): void` | 删除前 | 检查是否有子级、抛异常拦截 |
|
||||
| `afterDelete(int $id): void` | 删除后 | 清理关联数据 |
|
||||
| `beforeBatchDelete(array $ids): void` | 批量删除前 | 批量前置校验 |
|
||||
| `afterBatchDelete(array $ids): void` | 批量删除后 | 批量后置清理 |
|
||||
| `afterDetail(int $id, array &$data): void` | 详情查询后 | 补充关联数据、格式化输出 |
|
||||
| `afterPageList(array &$records, array $params): void` | 列表查询后 | 批量补充关联数据(分页和全量列表均触发) |
|
||||
|
||||
### afterPageList 示例
|
||||
### afterPageList 示例(摘自 `app/Services/UserService.php`)
|
||||
|
||||
当列表需要补充关联统计数据时,`afterPageList` 比逐条 `afterDetail` 更高效,只需一次 SQL 查询:
|
||||
当列表需要补充关联数据时,`afterPageList` 比逐条 `afterDetail` 更高效,批量查询避免 N+1:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 列表查询后处理:批量补充评论数
|
||||
*
|
||||
* @param array &$records 记录列表(引用传递)
|
||||
* @param array $params 查询参数
|
||||
* @return void
|
||||
* 列表查询后处理:批量补充角色与名称映射
|
||||
*/
|
||||
protected function afterPageList(array &$records, array $params): void
|
||||
{
|
||||
if (empty($records)) return;
|
||||
$userIds = array_column($records, 'id');
|
||||
if (empty($userIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 一次查询获取所有记录的评论数,避免 N+1
|
||||
$ids = array_column($records, 'id');
|
||||
$counts = Comment::whereIn('article_id', $ids)
|
||||
->group('article_id')
|
||||
->column('count(*)', 'article_id');
|
||||
// 批量获取用户角色(仅查所需字段,减少数据传输)
|
||||
$userRoles = [];
|
||||
$roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get();
|
||||
foreach ($roleData as $rd) {
|
||||
$userRoles[$rd->user_id][] = $rd->role_id;
|
||||
}
|
||||
|
||||
foreach ($records as &$record) {
|
||||
$record['commentCount'] = $counts[$record['id']] ?? 0;
|
||||
// 预加载名称映射(一次查询,避免逐条查)
|
||||
$roleMap = Role::getNameMap();
|
||||
$levelMap = Level::getNameMap();
|
||||
$positionMap = Position::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['levelName'] = $levelMap[$item['level_id'] ?? 0] ?? '';
|
||||
$item['positionName'] = $positionMap[$item['position_id'] ?? 0] ?? '';
|
||||
$item['deptName'] = $deptMap[$item['dept_id'] ?? 0] ?? '';
|
||||
unset($item['password'], $item['salt']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 与 afterDetail 的区别
|
||||
- `afterDetail`:单条详情查询后触发,适合补充单条记录的关联数据
|
||||
- `afterPageList`:列表查询后触发(分页和全量均适用),适合批量补充关联数据,避免 N+1 查询
|
||||
- `afterDetail`:单条详情查询后触发,适合补充单条记录的关联数据;
|
||||
- `afterPageList`:列表查询后触发(分页和全量均适用),适合批量补充关联数据,避免 N+1 查询。
|
||||
:::
|
||||
|
||||
## 扩展方法(导入导出,来自项目实际代码)
|
||||
|
||||
标准 CRUD 之外的专用方法直接写在 Service 中(`ExampleService::export / import`):
|
||||
|
||||
```php
|
||||
/**
|
||||
* 导出
|
||||
*
|
||||
* 按查询参数取全量列表后交给 ExcelService 生成文件。
|
||||
*
|
||||
* @param array $params 查询参数
|
||||
* @return string 导出文件路径
|
||||
*/
|
||||
public function export(array $params = []): string
|
||||
{
|
||||
$list = $this->allList($params);
|
||||
|
||||
$headers = [
|
||||
'name' => '名称',
|
||||
'statusText' => '状态',
|
||||
'sort' => '排序',
|
||||
'create_user' => '创建人',
|
||||
'create_time' => '创建时间',
|
||||
];
|
||||
|
||||
return ExcelService::export($list, $headers, '示例数据.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入
|
||||
*
|
||||
* 流程:按表头映射解析 Excel → 状态文本转值 → 逐条 add()。
|
||||
* 单条失败不中断整体,失败明细汇总到 errors 返回。
|
||||
*
|
||||
* @param string $filePath 上传文件路径
|
||||
* @return array{count: int, errors: array} 导入结果(成功条数 + 失败明细)
|
||||
*/
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$headerMap = [
|
||||
'名称' => 'name',
|
||||
'状态' => 'status',
|
||||
'排序' => 'sort',
|
||||
];
|
||||
|
||||
$data = ExcelService::importWithMap($filePath, $headerMap);
|
||||
|
||||
// 状态列若为文本(如"在用"),反向查字典转成值
|
||||
foreach ($data as &$row) {
|
||||
if (! empty($row['status']) && ! is_numeric($row['status'])) {
|
||||
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$count = 0;
|
||||
$errors = [];
|
||||
foreach ($data as $index => $row) {
|
||||
try {
|
||||
$this->add($row);
|
||||
$count++;
|
||||
} catch (\Exception $e) {
|
||||
// 行号按 Excel 实际行计(表头占第 1 行)
|
||||
$errors[] = [
|
||||
'row' => $index + 2,
|
||||
'name' => $row['name'] ?? '',
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $count, 'errors' => $errors];
|
||||
}
|
||||
```
|
||||
|
||||
## 事务处理
|
||||
|
||||
`add` / `update` / `delete` / `batchDelete` 主流程已由 `HasCrudOperations` 内部以 **`DB::transaction` 包裹**(钩子抛异常自动回滚),无需手动管理。编写自定义组合方法时自行包裹:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 新增并保存关联数据(自定义组合操作)
|
||||
*/
|
||||
public function addWithRelated(array $data): int
|
||||
{
|
||||
$this->startTrans(); // 开启事务
|
||||
try {
|
||||
$id = $this->add($data);
|
||||
return DB::transaction(function () use ($data) {
|
||||
$id = $this->add($data); // 内部又有事务 → 嵌套事务(Laravel 保存点)
|
||||
|
||||
// 保存关联数据...
|
||||
$this->commit(); // 提交事务
|
||||
return $id;
|
||||
} catch (\Exception $e) {
|
||||
$this->rollback(); // 回滚事务
|
||||
throw $e;
|
||||
}
|
||||
// RoleMenu::create([...]);
|
||||
|
||||
return $id; // 抛异常时整体回滚
|
||||
});
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
:::tip 业务异常也回滚
|
||||
钩子或唯一性校验中 `throw new BizException(...)` 会中断 `DB::transaction` 闭包并回滚,同时由全局 Handler 转为 `code=1` 失败响应——异常即控制流,无需返回值判断。见 [5.16 统一响应与异常抛出](/zh/core/exception.html)。
|
||||
:::
|
||||
|
||||
## 租户版 Service(摘自 `app/Services/ArticleService.php` 实际代码)
|
||||
|
||||
多租户模块继承 `BaseTenantService`(自动 `tenant_id` 查询过滤与写入填充),文件字段、查询条件、字典翻译照常声明:
|
||||
|
||||
```php
|
||||
class ArticleService extends BaseTenantService
|
||||
{
|
||||
use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps;
|
||||
|
||||
protected string $modelClass = Article::class;
|
||||
|
||||
// 文件字段
|
||||
protected array $fileFields = ['cover']; // 单文件:封面
|
||||
protected array $multiFileFields = ['images']; // 多文件:图片集
|
||||
protected string $fileSaveDir = 'article';
|
||||
|
||||
// 查询条件
|
||||
protected array $pageLikeFields = ['title', 'author'];
|
||||
protected array $pageEqFields = ['category_id', 'status'];
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'tenant_id', 'type' => 'asc'],
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
];
|
||||
|
||||
// 字典翻译
|
||||
protected array $serializeMaps = ['status' => 'article_status'];
|
||||
|
||||
// 数据权限:仅本人(data_scope=3 按创建人过滤)
|
||||
protected string $dataScopeUserField = 'create_user';
|
||||
}
|
||||
```
|
||||
|
||||
详见 [5.8 多租户隔离](/zh/core/tenant.html) 与 [5.9 数据权限](/zh/core/data-scope.html)。
|
||||
|
||||
@@ -1,103 +1,150 @@
|
||||
# 3.3 模型层开发
|
||||
|
||||
:::tip 概述
|
||||
模型层负责定义数据库表的映射关系,继承 BaseModel 后自动获得软删除和审计字段能力。
|
||||
模型层负责定义数据库表的映射关系。通过组合两个模型 Trait(`SoftDeletesByFlag`、`HasAuditColumns`),自动获得软删除和审计字段能力。
|
||||
:::
|
||||
|
||||
## 模型文件位置
|
||||
|
||||
```text
|
||||
app/model/Example.php
|
||||
app/Models/Example.php
|
||||
```
|
||||
|
||||
## 完整代码(来自项目实际代码)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\model;
|
||||
namespace App\Models;
|
||||
|
||||
use app\BaseModel;
|
||||
use App\Models\Concerns\HasAuditColumns;
|
||||
use App\Models\Concerns\SoftDeletesByFlag;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 案例演示模型
|
||||
*
|
||||
* 对应数据表 example,继承 BaseModel 获得通用模型能力
|
||||
* (软删除与审计字段自动写入)。
|
||||
* 对应数据表 example,继承 Eloquent Model 获得通用模型能力。
|
||||
*
|
||||
* 说明:本模型只有表映射,未定义关联或辅助方法;
|
||||
* 案例演示的查询过滤、排序、唯一校验等均由 ExampleLogic 配置。
|
||||
* 说明:
|
||||
* - 通过 HasAuditColumns 自动维护审计字段(如 created_by、updated_by 等)。
|
||||
* - 通过 SoftDeletesByFlag 实现基于标志位的软删除(如 is_deleted 字段)。
|
||||
* - type 类型:1=类型1,2=类型2,3=类型3,4=类型4。
|
||||
* - status 状态:1=在用,2=停用。
|
||||
* - 本模型通常作为开发示例/模板使用,不承载具体业务逻辑。
|
||||
*/
|
||||
class Example extends BaseModel
|
||||
class Example extends Model
|
||||
{
|
||||
use HasAuditColumns, SoftDeletesByFlag;
|
||||
|
||||
/**
|
||||
* 数据表名
|
||||
*
|
||||
* 显式指定表名,覆盖默认的类名推导规则。
|
||||
* ThinkPHP 默认使用类名的小写下划线形式作为表名,
|
||||
* Example → think_example。如果表名与类名不对应,
|
||||
* 可通过 $name 属性显式指定。
|
||||
* 显式指定表名为 example,覆盖默认的类名推导规则。
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'example';
|
||||
protected $table = 'example';
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段列表
|
||||
*
|
||||
* 白名单中的字段可通过 create()、update()、fill() 等方式批量赋值。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name', // 名称
|
||||
'avatar', // 头像/图片地址
|
||||
'type', // 类型:1=类型1,2=类型2,3=类型3,4=类型4
|
||||
'status', // 状态:1=在用,2=停用
|
||||
'sort', // 排序值,数值越小越靠前(视业务而定)
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## 继承 BaseModel 后获得的能力
|
||||
:::tip 表名与前缀
|
||||
`$table = 'example'` 不含前缀,实际查询时由 `DB_PREFIX=laravel_` 自动拼接为 `laravel_example`。
|
||||
:::
|
||||
|
||||
| 能力 | 说明 | 代码示例 |
|
||||
## 组合 Trait 后获得的能力
|
||||
|
||||
| Trait | 能力 | 代码示例 |
|
||||
|------|------|----------|
|
||||
| 软删除 | 全局查询自动过滤 `is_delete=1` | `$list = Example::select();` 自动排除已删除 |
|
||||
| 软删除操作 | 设置 `is_delete=1` | `$model->softDelete();` |
|
||||
| 批量软删除 | 批量设置 `is_delete=1` | `Example::batchSoftDelete([1,2,3]);` |
|
||||
| 审计字段-创建 | 自动填充 `create_user` 和 `create_time` | `Example::create([...])` 时自动填充 |
|
||||
| 审计字段-更新 | 自动填充 `update_user` 和 `update_time` | `$model->save([...])` 时自动填充 |
|
||||
| `SoftDeletesByFlag` | 注册 `SoftDeleteFlagScope` 全局范围,查询自动过滤 `is_delete=1` | `Example::query()` 自动排除已删除 |
|
||||
| `SoftDeletesByFlag` | 标记删除单条 | `$model->softDelete();`(置 `is_delete=1`) |
|
||||
| `SoftDeletesByFlag` | 批量标记删除 | `Example::batchSoftDelete([1,2,3]);` |
|
||||
| `HasAuditColumns` | `creating` 事件自动填充 `create_user`(当前登录人)和 `create_time` | `Example::create([...])` 时自动填充 |
|
||||
| `HasAuditColumns` | `updating` 事件自动填充 `update_user`、`update_time`(每次强制覆盖) | `$model->update([...])` 时自动填充 |
|
||||
|
||||
:::tip 审计字段说明
|
||||
`create_user` 取自 `Auth::username()`(当前登录用户);`create_*` 采用 `??` 逻辑——已显式赋值则保留原值,`update_*` 每次强制覆盖。表结构不含 Laravel 默认的 `created_at` / `updated_at`。
|
||||
:::
|
||||
|
||||
## 查询已删除数据
|
||||
|
||||
```php
|
||||
// 查询单条(包括已删除)
|
||||
$example = Example::withoutGlobalScope(['soft_delete'])->find($id);
|
||||
// 查询单条(包括已删除)—— 移除全局软删除范围
|
||||
$example = Example::withoutGlobalScope(SoftDeleteFlagScope::class)->find($id);
|
||||
|
||||
// 查询所有(包括已删除)
|
||||
$all = Example::withoutGlobalScope(['soft_delete'])->select();
|
||||
$all = Example::withoutGlobalScope(SoftDeleteFlagScope::class)->get();
|
||||
|
||||
// 恢复(软删除字段归零)
|
||||
$example->update(['is_delete' => 0]);
|
||||
```
|
||||
|
||||
## 简单模型 vs 复杂模型
|
||||
|
||||
:::tip 模型层职责
|
||||
模型层只负责表映射和关联关系,不包含业务逻辑。所有查询条件、排序、唯一性校验等都由 Logic 层配置。
|
||||
模型层只负责表映射和关联关系,不包含业务逻辑。所有查询条件、排序、唯一性校验等都由 Service 层配置。
|
||||
:::
|
||||
|
||||
### 简单模型(如 Example)
|
||||
|
||||
只需声明表名,所有逻辑由 Logic 层配置:
|
||||
只需声明表名与批量赋值白名单,所有逻辑由 Service 层配置:
|
||||
|
||||
```php
|
||||
class Example extends BaseModel
|
||||
class Example extends Model
|
||||
{
|
||||
protected $name = 'example';
|
||||
use HasAuditColumns, SoftDeletesByFlag;
|
||||
|
||||
protected $table = 'example';
|
||||
protected $fillable = ['name', 'avatar', 'type', 'status', 'sort'];
|
||||
}
|
||||
```
|
||||
|
||||
### 复杂模型(如 Role)
|
||||
|
||||
包含关联关系:
|
||||
包含关联关系(以项目实际代码为例):
|
||||
|
||||
```php
|
||||
class Role extends BaseModel
|
||||
class Role extends Model
|
||||
{
|
||||
protected $name = 'role';
|
||||
use HasAuditColumns, SoftDeletesByFlag;
|
||||
|
||||
protected $table = 'role';
|
||||
protected $fillable = [...];
|
||||
|
||||
/**
|
||||
* 关联菜单(多对多)
|
||||
* 通过 role_menu 中间表关联
|
||||
* 角色关联的菜单(多对多,经 role_menu 中间表)
|
||||
*/
|
||||
public function menus()
|
||||
public function menus(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Menu::class, 'role_menu', 'menu_id', 'role_id');
|
||||
return $this->belongsToMany(Menu::class, 'role_menu', 'role_id', 'menu_id')
|
||||
->wherePivot('is_delete', 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
## 模型相关文件
|
||||
|
||||
```text
|
||||
app/Models/
|
||||
├── Concerns/
|
||||
│ ├── SoftDeletesByFlag.php # is_delete 软删除(注册全局 Scope + softDelete 方法)
|
||||
│ ├── SoftDeleteFlagScope.php # 全局查询范围(WHERE is_delete = 0)
|
||||
│ └── HasAuditColumns.php # 审计字段(creating/updating 事件填充)
|
||||
├── Example.php # 案例模型
|
||||
└── ...
|
||||
```
|
||||
|
||||
@@ -13,27 +13,27 @@
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 2. Model │ 创建数据模型(继承 BaseModel)
|
||||
│ 2. Model │ 创建数据模型(模型 Trait 复用)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 3. Validate│ 创建验证器 + Logic 配置 validateClass
|
||||
│ 3. FormRequest │ 创建表单请求验证(BaseRequest)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 4. Logic │ 创建逻辑层(配置属性 + 钩子)
|
||||
│ 4. Service│ 创建业务服务(属性配置 + 钩子)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 5. Controller│ 创建控制器(注解 + 异常处理)
|
||||
│ 5. Controller│ 创建控制器(注解 + 快捷方法)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 6. Route │ 注册路由
|
||||
│ 6. Route │ 注册路由(routes/api.php)
|
||||
└────┬─────┘
|
||||
│
|
||||
▼
|
||||
@@ -51,38 +51,47 @@
|
||||
|
||||
| 约定 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 表名 | `think_` 前缀 + 小写下划线 | `think_example` |
|
||||
| 表名 | 不含前缀,前缀由 `DB_PREFIX` 决定(默认 `laravel_`) | `example` → 实际 `laravel_example` |
|
||||
| 字段名 | 小写下划线 | `create_time`、`user_id` |
|
||||
| 主键 | 统一使用 `id`,INT 自增 | `id INT PRIMARY KEY AUTO_INCREMENT` |
|
||||
| Model | 继承 `BaseModel`,类名与表名对应 | `class Example extends BaseModel` |
|
||||
| Logic | 继承 `BaseLogic` 或 `BaseTenantLogic` | `class ExampleLogic extends BaseLogic` |
|
||||
| Controller | 继承 `BaseController` | `class ExampleController extends BaseController` |
|
||||
| Model | 位于 `app/Models/`,`$table` 指定表名 | `protected $table = 'example'` |
|
||||
| Service | 继承 `BaseService` 或 `BaseTenantService` | `class ExampleService extends BaseService` |
|
||||
| Request | 继承 `BaseRequest`,`rules()` 声明规则 | `class ExampleRequest extends BaseRequest` |
|
||||
| Controller | 位于 `app/Http/Controllers/Api/`,继承 `Controller` | `class ExampleController extends Controller` |
|
||||
| 权限码 | `sys:模块名:操作` | `sys:example:add` |
|
||||
| 路由 | RESTful 风格 | `GET /api/example/page` |
|
||||
| 路由 | `routes/api.php`,RESTful 风格 | `GET /api/example/page` |
|
||||
|
||||
## 本章示例
|
||||
|
||||
本章以项目内置的 **example(案例演示)** 模块为例,完整演示从建表到联调的全过程。该模块是代码生成器的参考模板,涵盖了标准 CRUD 的所有典型场景:
|
||||
|
||||
- 分页列表(支持名称模糊搜索、类型和状态精确过滤)
|
||||
- 分页列表(支持名称模糊搜索、状态精确过滤)
|
||||
- 全量列表
|
||||
- 详情查询
|
||||
- 新增(含头像上传)
|
||||
- 修改
|
||||
- 删除 / 批量删除
|
||||
- Excel 导入导出
|
||||
- 权限控制(#[Permission] 注解)
|
||||
- 操作日志(#[Log] 注解)
|
||||
- 枚举显示名翻译(type / status 字典映射)
|
||||
- 权限控制(`#[Permission]` 注解)
|
||||
- 操作日志(`#[OperationLog]` 注解)
|
||||
- 枚举显示名翻译(status 字典映射)
|
||||
- 唯一性校验(`uniqueFields` 属性声明)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
```text
|
||||
app/model/Example.php # 数据模型
|
||||
app/validate/ExampleValidate.php # 验证器
|
||||
app/logic/ExampleLogic.php # 业务逻辑
|
||||
app/controller/ExampleController.php # 控制器
|
||||
route/app.php # 路由(第371行)
|
||||
ui/src/api/tool/example.ts # 前端 API 模块
|
||||
ui/src/views/tool/example/ # 前端页面
|
||||
```
|
||||
app/Models/Example.php # 数据模型
|
||||
app/Http/Requests/ExampleRequest.php # 表单请求验证
|
||||
app/Services/ExampleService.php # 业务服务
|
||||
app/Http/Controllers/Api/ExampleController.php # 控制器
|
||||
routes/api.php # 路由(example 模块 9 条)
|
||||
# 前端(独立 EleVue 仓库):
|
||||
# ui/src/api/tool/example.ts # 前端 API 模块
|
||||
# ui/src/views/tool/example/ # 前端页面
|
||||
```
|
||||
|
||||
## 学习建议
|
||||
|
||||
1. 先通读 3.2~3.8 按顺序跟随 example 模块走一遍;
|
||||
2. 重点理解 **声明式配置**:Service 只声明属性,CRUD 由 `BaseService` + 6 个 Trait 完成;
|
||||
3. 掌握后动手:可直接使用 [第 10 章 代码生成器](/zh/generator/overview.html) 一键生成同等结构的模块。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 3.8 权限节点配置
|
||||
|
||||
:::tip 概述
|
||||
权限节点用于控制用户能访问哪些接口。通过菜单管理界面配置,格式为 `sys:{模块名}:{操作}`。
|
||||
权限节点用于控制用户能访问哪些接口。**菜单即权限**:菜单表的 `permission` 字段即权限编码,通过菜单管理界面配置,格式为 `sys:{模块名}:{操作}`;后端 `#[Permission]` 注解声明,`auth.jwt` 中间件统一校验。
|
||||
:::
|
||||
|
||||
## Example 模块权限码
|
||||
@@ -9,68 +9,93 @@
|
||||
| 权限码 | 说明 | 对应接口 |
|
||||
|--------|------|----------|
|
||||
| `sys:example:page` | 案例演示分页列表 | `GET /api/example/page` |
|
||||
| `sys:example:list` | 案例演示列表 | `GET /api/example/list` |
|
||||
| `sys:example:detail` | 案例演示详情 | `GET /api/example/detail/{id}` |
|
||||
| `sys:example:add` | 添加案例演示 | `POST /api/example/add` |
|
||||
| `sys:example:update` | 修改案例演示 | `PUT /api/example/update` |
|
||||
| `sys:example:delete` | 删除案例演示 | `DELETE /api/example/delete/:id` |
|
||||
| `sys:example:delete` | 删除案例演示 | `DELETE /api/example/delete/{id}` |
|
||||
| `sys:example:batchDelete` | 批量删除案例演示 | `DELETE /api/example/batchDelete` |
|
||||
| `sys:example:import` | 导入案例演示 | `POST /api/example/import` |
|
||||
| `sys:example:export` | 导出案例演示 | `GET /api/example/export` |
|
||||
|
||||
:::tip
|
||||
控制器注解中声明的权限码(`#[Permission('sys:example:page', '示例分页')]`)必须与菜单管理中配置的 `permission` 字段**完全一致**,否则鉴权不通过。
|
||||
:::
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 添加目录节点
|
||||
|
||||
在系统菜单管理中添加一级目录:
|
||||
在系统菜单管理中添加一级目录(项目已内置「开发工具」目录,id=189,可直接复用):
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 菜单名称 | 工具管理 |
|
||||
| 菜单名称 | 开发工具 |
|
||||
| 菜单类型 | 目录(type=0) |
|
||||
| 路由地址 | /tool |
|
||||
| 排序 | 60 |
|
||||
| 排序 | 50 |
|
||||
|
||||
### 2. 添加菜单节点
|
||||
### 2. 添加菜单节点(页面)
|
||||
|
||||
在目录下添加二级菜单:
|
||||
在目录下添加二级菜单(可导航页面,代码生成器创建的路径即为此格式):
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 菜单名称 | 案例演示 |
|
||||
| 菜单类型 | 菜单(type=1) |
|
||||
| 路由地址 | /example |
|
||||
| 菜单类型 | 菜单/页面(type=0) |
|
||||
| 路由地址 | /tool/example |
|
||||
| 组件路径 | tool/example/index |
|
||||
| 权限标识 | sys:example:page |
|
||||
| 排序 | 1 |
|
||||
|
||||
### 3. 添加按钮权限
|
||||
### 3. 添加权限节点
|
||||
|
||||
在菜单下添加三级按钮权限:
|
||||
在菜单下添加三级权限节点(type=1,`permission` 必填非空):
|
||||
|
||||
| 字段 | 值 | 权限标识 |
|
||||
|------|-----|----------|
|
||||
| 添加案例演示 | type=2 | sys:example:add |
|
||||
| 修改案例演示 | type=2 | sys:example:update |
|
||||
| 删除案例演示 | type=2 | sys:example:delete |
|
||||
| 导入案例演示 | type=2 | sys:example:import |
|
||||
| 导出案例演示 | type=2 | sys:example:export |
|
||||
| 查看案例演示 | type=1 | sys:example:page |
|
||||
| 添加案例演示 | type=1 | sys:example:add |
|
||||
| 修改案例演示 | type=1 | sys:example:update |
|
||||
| 删除案例演示 | type=1 | sys:example:delete |
|
||||
| 导入案例演示 | type=1 | sys:example:import |
|
||||
| 导出案例演示 | type=1 | sys:example:export |
|
||||
|
||||
### 4. 分配角色权限
|
||||
|
||||
在角色管理中,为角色勾选新增的权限节点。
|
||||
在角色管理中,为角色勾选新增的权限节点(角色 → 菜单多对多,存于 `role_menu` 表)。
|
||||
|
||||
## 菜单类型说明
|
||||
|
||||
| type | 说明 | 前端行为 |
|
||||
|------|------|----------|
|
||||
| 0 | 目录 | 渲染为侧边栏分组 |
|
||||
| 1 | 菜单 | 渲染为可点击的菜单项,对应一个页面 |
|
||||
| 2 | 按钮 | 不渲染,仅用于权限校验 |
|
||||
| 0 | 菜单/目录(可导航) | 目录渲染为侧边栏分组,页面渲染为菜单项(需填 `component`) |
|
||||
| 1 | 权限节点 | 不渲染,仅用于接口鉴权(`permission` 必填非空) |
|
||||
|
||||
## 权限校验流程
|
||||
|
||||
```text
|
||||
1. 用户登录 → 获取用户角色
|
||||
2. 角色关联菜单 → 获取权限节点列表
|
||||
3. 请求接口 → AuthMiddleware 读取 #[Permission] 注解
|
||||
1. 用户登录 → 获取用户角色(user_role 表)
|
||||
2. 角色关联菜单 → 获取权限节点列表(role_menu 表,缓存于 Redis)
|
||||
3. 请求接口 → auth.jwt 中间件读取控制器方法的 #[Permission] 注解(反射)
|
||||
4. 检查权限码是否在用户权限列表中
|
||||
5. 超级管理员(uid=1)跳过校验,直接放行
|
||||
```
|
||||
├─ 在 → 放行,进入 tenant / demo / log.operation 中间件
|
||||
├─ 不在 → 返回 {"code":403,"ok":false,"msg":"无访问权限"}
|
||||
└─ 超级管理员(用户 ID=1)→ 跳过校验,直接放行
|
||||
5. 菜单/角色菜单数据变更时,Observer 自动清除相关权限缓存
|
||||
```
|
||||
|
||||
:::tip 两级校验
|
||||
- **后端**:`#[Permission]` 注解 + `auth.jwt` 中间件(接口级,兜底);
|
||||
- **前端**:`v-perm` 按钮指令(体验层,隐藏无权按钮)。
|
||||
|
||||
前端隐藏不等于安全——后端注解校验才是权限的最终防线。详见 [5.4 权限管理(RBAC)](/zh/core/rbac.html)。
|
||||
:::
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| 超管正常、普通角色 403 | 角色未分配该权限节点 | 角色管理勾选对应菜单/按钮 |
|
||||
| 改了权限不生效 | 权限缓存未失效 | 重新保存角色菜单(`RoleMenuObserver` 自动清除权限缓存),或重新登录 |
|
||||
| 注解写了仍 403 | 权限码与菜单 `permission` 不一致 | 逐字符比对(注意大小写与冒号) |
|
||||
| 无 `#[Permission]` 的接口被拦截 | 接口放错位置 | 确认是否应为公开路由,移至中间件组外 |
|
||||
|
||||
@@ -1,92 +1,127 @@
|
||||
# 3.7 路由注册
|
||||
|
||||
:::tip 概述
|
||||
路由定义在 `route/app.php` 中,按模块分组注册。所有需认证的接口统一绑定四个中间件。
|
||||
全部 API 路由定义在 `routes/api.php` 中。**公开接口(无需认证)放在中间件组外面**,认证接口统一绑定 `auth.jwt + tenant + demo + log.operation` 中间件组。
|
||||
:::
|
||||
|
||||
## 路由文件位置
|
||||
|
||||
```text
|
||||
route/app.php
|
||||
routes/api.php
|
||||
```
|
||||
|
||||
## 文件结构(来自项目实际代码)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 公开接口放在 middleware 组外面,天然排除认证。
|
||||
| 认证接口统一使用 auth.jwt + tenant + demo + log.operation 中间件。
|
||||
| demo 位于 log.operation 之前:演示模式下被拦截的请求不写操作日志。
|
||||
|
|
||||
*/
|
||||
|
||||
// ===== 公开接口(无需认证)=====
|
||||
Route::post('login', [Api\AuthController::class, 'login']);
|
||||
Route::post('refresh', [Api\AuthController::class, 'refresh']);
|
||||
Route::get('captcha', [Api\CaptchaController::class, 'generate']);
|
||||
Route::post('oauth2/token', [Api\AuthController::class, 'token']);
|
||||
Route::get('oauth2/check/token', [Api\AuthController::class, 'checkToken']);
|
||||
|
||||
// ===== 认证接口 =====
|
||||
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])->group(function () {
|
||||
|
||||
// --- 登出 ---
|
||||
Route::post('logout', [Api\AuthController::class, 'logout']);
|
||||
|
||||
// --- 个人中心 ---
|
||||
Route::get('index/getUser', [Api\IndexController::class, 'getUser']);
|
||||
|
||||
// ... 其他业务路由
|
||||
});
|
||||
```
|
||||
|
||||
## Example 模块路由(来自项目实际代码)
|
||||
|
||||
```php
|
||||
// route/app.php 第 370~383 行
|
||||
// routes/api.php(认证中间件组内)
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 自动生成 - example
|
||||
// ----------------------------------------------------------
|
||||
Route::group('example', function () {
|
||||
Route::get('page', 'ExampleController/page'); // 分页列表
|
||||
Route::get('list', 'ExampleController/list'); // 全量列表
|
||||
Route::get('detail/:id', 'ExampleController/detail'); // 详情(:id 为路径参数)
|
||||
Route::post('add', 'ExampleController/add'); // 新增
|
||||
Route::put('update', 'ExampleController/update'); // 修改
|
||||
Route::delete('delete/:id', 'ExampleController/delete'); // 删除
|
||||
Route::delete('batchDelete', 'ExampleController/batchDelete'); // 批量删除
|
||||
Route::post('import', 'ExampleController/import'); // 导入
|
||||
Route::get('export', 'ExampleController/export'); // 导出
|
||||
});
|
||||
// --- 案例演示 ---
|
||||
Route::get('example/page', [Api\ExampleController::class, 'page']);
|
||||
Route::get('example/list', [Api\ExampleController::class, 'list']);
|
||||
Route::get('example/detail/{id}', [Api\ExampleController::class, 'detail']);
|
||||
Route::post('example/add', [Api\ExampleController::class, 'add']);
|
||||
Route::put('example/update', [Api\ExampleController::class, 'update']);
|
||||
Route::delete('example/delete/{id}', [Api\ExampleController::class, 'delete']);
|
||||
Route::delete('example/batchDelete', [Api\ExampleController::class, 'batchDelete']);
|
||||
Route::get('example/export', [Api\ExampleController::class, 'export']);
|
||||
Route::post('example/import', [Api\ExampleController::class, 'import']);
|
||||
```
|
||||
|
||||
## 路由格式说明
|
||||
|
||||
```php
|
||||
Route::HTTP方法('路径', '控制器/方法');
|
||||
Route::HTTP方法('路径', [控制器类::class, '方法']);
|
||||
```
|
||||
|
||||
| HTTP 方法 | 用途 | 路径格式 | 示例 |
|
||||
|-----------|------|----------|------|
|
||||
| GET | 查询 | `/module/page`、`/module/detail/:id` | `GET /api/example/page` |
|
||||
| POST | 新增/导入 | `/module/add`、`/module/import` | `POST /api/example/add` |
|
||||
| PUT | 修改 | `/module/update` | `PUT /api/example/update` |
|
||||
| DELETE | 删除 | `/module/delete/:id`、`/module/batchDelete` | `DELETE /api/example/delete/1` |
|
||||
| GET | 查询 | `{模块}/page`、`{模块}/detail/{id}` | `GET /api/example/page` |
|
||||
| POST | 新增/导入 | `{模块}/add`、`{模块}/import` | `POST /api/example/add` |
|
||||
| DELETE | 批量删除 | `{模块}/batchDelete` | `DELETE /api/example/batchDelete` |
|
||||
| PUT | 修改 | `{模块}/update` | `PUT /api/example/update` |
|
||||
| DELETE | 删除 | `{模块}/delete/{id}` | `DELETE /api/example/delete/1` |
|
||||
|
||||
:::tip 路径参数风格
|
||||
本项目统一使用 Laravel 标准 `{id}` 路径参数;批量删除使用 `batchDelete` 路径且为 **DELETE** 方法(请求体仍传 `ids` 数组——本项目 DELETE 接口携带 body)。前缀 `/api` 由 `bootstrap/app.php` 的 `withRouting(api: ...)` 默认前缀统一添加。
|
||||
:::
|
||||
|
||||
## 标准路由模板
|
||||
|
||||
每个业务模块通常包含以下路由:
|
||||
每个业务模块通常包含以下 9 条路由(全部放在认证中间件组内):
|
||||
|
||||
```php
|
||||
Route::group('模块名', function () {
|
||||
Route::get('page', 'XxxController/page'); // 分页列表
|
||||
Route::get('list', 'XxxController/list'); // 全量列表(可选)
|
||||
Route::get('detail/:id', 'XxxController/detail'); // 详情
|
||||
Route::post('add', 'XxxController/add'); // 新增
|
||||
Route::put('update', 'XxxController/update'); // 修改
|
||||
Route::delete('delete/:id', 'XxxController/delete'); // 删除
|
||||
Route::delete('batchDelete', 'XxxController/batchDelete'); // 批量删除
|
||||
Route::post('import', 'XxxController/import'); // 导入(可选)
|
||||
Route::get('export', 'XxxController/export'); // 导出(可选)
|
||||
});
|
||||
// --- 模块名 ---
|
||||
Route::get('{模块}/page', [Api\XxxController::class, 'page']); // 分页列表
|
||||
Route::get('{模块}/list', [Api\XxxController::class, 'list']); // 全量列表(可选)
|
||||
Route::get('{模块}/detail/{id}', [Api\XxxController::class, 'detail']); // 详情
|
||||
Route::post('{模块}/add', [Api\XxxController::class, 'add']); // 新增
|
||||
Route::put('{模块}/update', [Api\XxxController::class, 'update']); // 修改
|
||||
Route::delete('{模块}/delete/{id}', [Api\XxxController::class, 'delete']); // 删除
|
||||
Route::delete('{模块}/batchDelete', [Api\XxxController::class, 'batchDelete']); // 批量删除
|
||||
Route::get('{模块}/export', [Api\XxxController::class, 'export']); // 导出(可选)
|
||||
Route::post('{模块}/import', [Api\XxxController::class, 'import']); // 导入(可选)
|
||||
```
|
||||
|
||||
:::tip 路由命名规范
|
||||
- 路径使用小写英文(如 `example`、`user`)
|
||||
- 详情/删除使用 `:id` 参数(如 `detail/:id`)
|
||||
- 批量删除使用 `batchDelete` 路径
|
||||
## 中间件组说明
|
||||
|
||||
| 别名 | 类 | 职责 |
|
||||
|------|-----|------|
|
||||
| `auth.jwt` | `Authenticate` | JWT 解析、`#[Permission]` 反射鉴权、用户信息注入 |
|
||||
| `tenant` | `ResolveTenant` | 租户上下文解析与注入 |
|
||||
| `demo` | `DemoMiddleware` | 演示模式拦截写操作(`#[DemoAllow]` 放行) |
|
||||
| `log.operation` | `LogOperation` | 操作日志采集(响应后落库) |
|
||||
|
||||
跨域 `Cors`、强制 JSON `ForceJsonResponse`、真实 IP `ProxyMiddleware` 前置于 **API 中间件组**(`bootstrap/app.php` 的 `$middleware->api(prepend:)`),对全部 `/api` 请求生效,无需在路由上声明。演示模式 `DemoMiddleware`(别名 `demo`)位于认证路由组内(tenant 与 log.operation 之间)。详见 [附录 F 中间件执行顺序](/zh/appendix/middleware.html)。
|
||||
|
||||
:::warning 公开接口放组外
|
||||
`login` / `refresh` / `captcha` / `oauth2` 等公开接口**必须放在中间件组外面**(如上例),不要在中间件内部做排除——这是项目的路由约定。
|
||||
:::
|
||||
|
||||
## 中间件绑定
|
||||
|
||||
所有需认证的接口在 `route/app.php` 末尾统一绑定中间件:
|
||||
|
||||
```php
|
||||
})->middleware([
|
||||
\app\middleware\AuthMiddleware::class, // JWT认证 + 权限校验
|
||||
\app\middleware\TenantMiddleware::class, // 租户上下文注入
|
||||
\app\middleware\DemoMiddleware::class, // 演示环境拦截
|
||||
\app\middleware\LogMiddleware::class, // 操作日志记录
|
||||
]);
|
||||
```
|
||||
|
||||
## 验证路由注册
|
||||
|
||||
```bash
|
||||
# 查看所有已注册路由
|
||||
php think route:list
|
||||
# 查看所有已注册路由(含中间件与方法名)
|
||||
php artisan route:list
|
||||
|
||||
# 测试路由是否正确
|
||||
# 测试路由是否正确(需先登录获取 token,见 2.7 第一个接口调试)
|
||||
curl http://localhost:8000/api/example/page -H "Authorization: Bearer <token>"
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,174 +1,153 @@
|
||||
# 3.4 验证器开发
|
||||
# 3.4 FormRequest 验证器
|
||||
|
||||
:::tip 概述
|
||||
验证器用于校验请求参数的合法性,继承 ThinkPHP 的 Validate 类。通过在 Logic 层配置 `$validateClass` 属性,`add`/`update` 时自动触发校验,无需手动调用。
|
||||
参数格式验证由 **FormRequest** 完成(Laravel 标准机制):控制器方法类型注入请求类,未通过校验时框架自动返回统一 JSON 错误响应。所有请求类继承本项目的 `BaseRequest`,子类只需声明 `rules()` 与 `messages()`。
|
||||
:::
|
||||
|
||||
## 验证器文件位置
|
||||
## 请求类文件位置
|
||||
|
||||
```text
|
||||
app/validate/ExampleValidate.php
|
||||
app/Http/Requests/ExampleRequest.php
|
||||
```
|
||||
|
||||
## 完整代码(来自项目实际代码)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\validate;
|
||||
|
||||
use think\Validate;
|
||||
namespace App\Http\Requests;
|
||||
|
||||
/**
|
||||
* 案例演示验证器
|
||||
* 案例表单请求
|
||||
*
|
||||
* 负责案例演示模块的入参校验,继承 ThinkPHP 的 Validate。
|
||||
* 负责案例演示新增/修改接口的参数校验。
|
||||
*
|
||||
* 结构说明:
|
||||
* - $rule:字段校验规则,由必填字段自动推导;
|
||||
* - $message:自定义错误提示,按"字段.规则"生成中文提示;
|
||||
* - $scene:场景化校验,add 与 update 使用相同的字段集。
|
||||
*
|
||||
* 说明:唯一性校验未写入本验证器,
|
||||
* 由 ExampleLogic 的 uniqueFields 在逻辑层完成。
|
||||
* 说明:
|
||||
* - 继承 BaseRequest,基础校验能力(失败响应统一处理)由基类提供。
|
||||
* - 本类只声明案例特有的字段规则与自定义错误消息。
|
||||
* - 新增与修改共用本请求类,修改时由基类或控制器负责校验 id。
|
||||
* - 前端传参使用 camelCase,数据库字段为 snake_case;
|
||||
* - type 类型:1=类型1,2=类型2,3=类型3,4=类型4,
|
||||
* 与 Example 模型的约定一致。
|
||||
* - status 状态:1=在用,2=停用。
|
||||
*/
|
||||
class ExampleValidate extends Validate
|
||||
class ExampleRequest extends BaseRequest
|
||||
{
|
||||
/**
|
||||
* 校验规则
|
||||
*
|
||||
* 由生成器按字段的必填属性自动推导,
|
||||
* 通常形如 '字段名' => 'require'。
|
||||
*
|
||||
* @var array
|
||||
* @return array<string, string> 字段规则
|
||||
*/
|
||||
protected $rule = [
|
||||
'name' => 'require',
|
||||
];
|
||||
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', // 排序值:可空,整数
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义错误提示
|
||||
* 自定义错误消息
|
||||
*
|
||||
* 键为"字段名.规则名",值为中文提示语,
|
||||
* 覆盖框架默认提示,提升用户可读性。
|
||||
* 为需要友好提示的规则提供中文消息,覆盖框架默认英文提示。
|
||||
*
|
||||
* @var array
|
||||
* @return array<string, string> 字段规则 => 错误消息
|
||||
*/
|
||||
protected $message = [
|
||||
'name.require' => '请输入案例名称',
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验场景
|
||||
*
|
||||
* add 与 update 使用相同的字段集,
|
||||
* 与项目其它验证器的统一模式保持一致。
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $scene = [
|
||||
'add' => ['name'], // 新增场景
|
||||
'update' => ['name'], // 修改场景
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## 在 Logic 中配置自动验证(推荐)
|
||||
|
||||
在 Logic 子类中配置 `$validateClass` 属性,`add`/`update` 时自动调用对应验证器:
|
||||
|
||||
```php
|
||||
// app/logic/ExampleLogic.php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $modelClass = Example::class;
|
||||
|
||||
/**
|
||||
* 参数验证器类
|
||||
*
|
||||
* 配置后,add/update 自动校验
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $validateClass = \app\validate\ExampleValidate::class;
|
||||
|
||||
// ... 其他属性
|
||||
}
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
|
||||
1. `add()` 流程:`beforeAdd` → `validateData($data, 'add')` → 后续入库
|
||||
2. `update()` 流程:`beforeUpdate` → `validateData($data, 'update', $id)` → 后续入库
|
||||
3. 验证器按场景(`add`/`update`)校验,场景不存在时使用默认规则
|
||||
4. 校验失败抛出 `ValidateException`,由 `ExceptionHandle` 统一返回错误响应
|
||||
|
||||
**覆盖范围:** 无论是 Controller 通过 `_add`/`_edit` 调用,还是 `import` 等内部调用都会生效。
|
||||
|
||||
## 在 Controller 中手动调用(特殊场景)
|
||||
|
||||
对于不走 `BaseLogic.add()`/`update()` 的特殊接口(如登录),可在 Controller 中手动调用:
|
||||
|
||||
```php
|
||||
// app/controller/LoginController.php
|
||||
public function login(): Json
|
||||
{
|
||||
$params = $this->getParams();
|
||||
try {
|
||||
$this->validate($params, LoginValidate::class . '.login');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage(), 422);
|
||||
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' => '排序值格式不正确',
|
||||
];
|
||||
}
|
||||
// ... 登录逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 控制器中的使用
|
||||
|
||||
```php
|
||||
// 类型注入:请求到达方法前自动完成校验
|
||||
public function add(ExampleRequest $request): JsonResponse
|
||||
{
|
||||
// validated() → 已校验数据,键名转换由 Service 写入入口统一完成
|
||||
return $this->_add($this->exampleService, $request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
:::tip validated() 是白名单
|
||||
`validated()` 只返回 `rules()` 中声明过的键,未声明的字段在进入控制器前即被丢弃。业务需要传递给 Service 的字段(如修改接口的 `id`、用户表单的 `city` / `roles`)都应在规则中声明,否则即使前端传了也到不了 Service。
|
||||
:::
|
||||
|
||||
## BaseRequest 基类提供的能力
|
||||
|
||||
`app/Http/Requests/BaseRequest.php`:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| `authorize()` 统一返回 true | 鉴权由路由中间件与 `#[Permission]` 注解负责,请求类不参与授权 |
|
||||
| `failedValidation()` 统一处理 | 校验失败返回固定 JSON:`{"code":1,"ok":false,"msg":"参数验证失败","data":{字段=>错误数组}}`(code=1 与 `Controller::fail()` 约定一致) |
|
||||
|
||||
:::warning 两条校验失败路径
|
||||
- **FormRequest 注入**(本章方式)→ 失败响应 `code=1`;
|
||||
- **代码内手动** `Validator::validate()` 抛出的 `ValidationException` → 由全局 Handler 渲染为 `code=422`。
|
||||
|
||||
前端两种都应兼容(以 `code !== 0` 判失败)。详见 [5.16 统一响应与异常抛出](/zh/core/exception.html)。
|
||||
:::
|
||||
|
||||
## 常用验证规则
|
||||
|
||||
| 规则 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `require` | 必填 | `'name' => 'require'` |
|
||||
| `required` | 必填 | `'name' => 'required'` |
|
||||
| `string` | 字符串 | `'name' => 'string'` |
|
||||
| `max:n` | 最大长度 | `'title' => 'max:200'` |
|
||||
| `min:n` | 最小长度 | `'password' => 'min:6'` |
|
||||
| `number` | 数字 | `'sort' => 'number'` |
|
||||
| `integer` | 整数 | `'age' => 'integer'` |
|
||||
| `in:a,b,c` | 枚举值 | `'status' => 'in:0,1'` |
|
||||
| `integer` | 整数 | `'sort' => 'integer'` |
|
||||
| `in:a,b,c` | 枚举值 | `'status' => 'in:1,2'` |
|
||||
| `nullable` | 可空(不传则跳过其余规则) | `'avatar' => 'nullable\|string'` |
|
||||
| `email` | 邮箱格式 | `'email' => 'email'` |
|
||||
| `mobile` | 手机号格式 | `'mobile' => 'mobile'` |
|
||||
| `unique:table` | 唯一性 | `'username' => 'unique:user'` |
|
||||
| `date` | 日期格式 | `'start_time' => 'date'` |
|
||||
| `regex:/.../` | 正则 | `'code' => 'regex:/^[A-Za-z]+$/'` |
|
||||
|
||||
## 场景(Scene)用法
|
||||
## 分层职责:格式 vs 业务
|
||||
|
||||
场景允许在不同接口使用不同的验证规则。项目统一使用 `$scene` 属性定义:
|
||||
| 层 | 职责 | 示例 |
|
||||
|----|------|------|
|
||||
| **FormRequest(本章)** | 参数**格式**:必填、类型、长度、枚举 | `name` 必填、`type` 只能 1-4 |
|
||||
| **Service 层** | 参数**业务规则**:唯一性、状态依赖、权限 | `name` 不能与已有记录重复 |
|
||||
|
||||
:::tip 唯一性为什么不放 FormRequest?
|
||||
`uniqueFields` 校验在 Service 层完成(`HasUniqueCheck` Trait),因为软删除场景下数据库唯一索引会误伤(`is_delete=1` 的旧记录仍占索引),且需要排除自身(update 场景)。见 [3.5 Service 层开发](/zh/guide/logic.html)。
|
||||
:::
|
||||
|
||||
## 场景拆分(新增 vs 修改字段差异大时)
|
||||
|
||||
FormRequest 可按方法区分规则:
|
||||
|
||||
```php
|
||||
class UserValidate extends Validate
|
||||
class UserRequest extends BaseRequest
|
||||
{
|
||||
protected $rule = [
|
||||
'username' => 'require|unique:user',
|
||||
'realname' => 'require',
|
||||
'mobile' => 'mobile',
|
||||
'email' => 'email',
|
||||
];
|
||||
public function rules(): array
|
||||
{
|
||||
// 通过 $this->isMethod('post') 或自定义场景标识区分
|
||||
$rules = [
|
||||
'realname' => 'required|string|max:50',
|
||||
'mobile' => 'nullable|max:20',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'username.require' => '用户名不能为空',
|
||||
'username.unique' => '用户名已存在',
|
||||
'realname.require' => '姓名不能为空',
|
||||
];
|
||||
if ($this->isMethod('post')) {
|
||||
// 新增时才要求用户名
|
||||
$rules['username'] = 'required|string|min:3|max:50';
|
||||
}
|
||||
|
||||
// 场景属性:指定每个场景校验哪些字段
|
||||
protected $scene = [
|
||||
'add' => ['username', 'realname', 'mobile', 'email'],
|
||||
'update' => ['realname', 'mobile', 'email'], // 修改时不校验 username
|
||||
];
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**自动场景匹配:** Logic 配置了 `$validateClass` 后,`add()` 自动使用 `add` 场景,`update()` 自动使用 `update` 场景,无需手动指定。
|
||||
Reference in New Issue
Block a user