签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -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)。
:::
+67 -34
View File
@@ -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)。
+240 -229
View File
@@ -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)。
+84 -37
View File
@@ -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]` 的接口被拦截 | 接口放错位置 | 确认是否应为公开路由,移至中间件组外 |
+90 -55
View File
@@ -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` 场景,无需手动指定。