476 lines
17 KiB
Markdown
476 lines
17 KiB
Markdown
# 4.7 横切机制
|
||
|
||
:::tip 概述
|
||
横切机制是 `BaseService` / 模型 Trait 内置的自动处理能力,开发者通过属性配置启用,无需手动编写处理代码。项目共有 9 种横切机制,覆盖数据转换、文件处理、校验、权限、隔离等维度;参数格式校验则由 HTTP 入口的 FormRequest 承担。
|
||
:::
|
||
|
||
## 横切机制总览
|
||
|
||
| 机制 | 配置位置 | 配置属性 | 触发时机 |
|
||
|------|---------|---------|---------|
|
||
| 驼峰/下划线转换 | `config/api.php` | `camel_snake_convert` | 查询参数入、响应出 |
|
||
| 文件字段处理 | Service(需 `use HasFileProcessing`) | `fileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
|
||
| 多文件字段处理 | Service(需 `use HasFileProcessing`) | `multiFileFields` + `fileSaveDir` | add/update 写入,detail/page 读取 |
|
||
| 富文本占位符 | Service(需 `use HasFileProcessing`) | `contentFields` | add/update 写入,detail/page 读取 |
|
||
| 配置值类型化处理 | Service(需 `use HasConfigValueProcessing`) | 记录的 `type` + `fileSaveDir` | add/update 写入,detail/webIndex 读取 |
|
||
| 字典显示名翻译 | Service(需 `use HasSerializeMaps`) | `serializeMaps` | detail/page 查询后 |
|
||
| 唯一性校验 | Service(需 `use HasUniqueCheck`) | `uniqueFields` | add/update 时 |
|
||
| 数据权限过滤 | Service(需 `use HasDataScope`) | `dataScopeUserField` / `dataScopeDeptField` | 查询时 |
|
||
| 租户隔离 | `BaseTenantService`(自动 use HasDataScope) | `tenantScopeField` | 查询时 + 新增时 |
|
||
| 审计字段 + 软删除 | 模型 Trait | 自动生效 | 所有 CRUD |
|
||
|
||
:::tip 参数格式校验在哪里?
|
||
不在上表——Laravel 将其前置到 HTTP 入口:控制器方法**类型注入 FormRequest** 即自动校验(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。Service 只做业务规则(唯一性、状态依赖)。
|
||
:::
|
||
|
||
## 1. 驼峰/下划线自动转换
|
||
|
||
前端使用 camelCase,数据库使用 snake_case,框架自动完成双向转换。
|
||
|
||
### 转换流程
|
||
|
||
```text
|
||
请求方向(前端 → 数据库):
|
||
前端传参:{ userName: "admin", createTime: "2026-01-01" }
|
||
│
|
||
▼ HasQueryScopes::convertQueryParams()
|
||
│ camel_to_snake() 逐键转换(排除分页/排序参数)
|
||
▼
|
||
数据库操作:WHERE user_name = 'admin' AND create_time = '2026-01-01'
|
||
|
||
表单入参(add/update 写入):
|
||
$request->validated() ← 格式校验,原样传递(camelCase 键名)
|
||
│
|
||
▼ HasCrudOperations::doAdd / doUpdate 入口
|
||
│ array_camel_to_snake() 统一转换
|
||
▼
|
||
入库数据:snake_case 键名(非表字段由 filterTableFields 过滤)
|
||
|
||
响应方向(数据库 → 前端):
|
||
数据库数据:{ user_name: "admin", create_time: "2026-01-01" }
|
||
│
|
||
▼ Result::success() / page()
|
||
│ convert_to_camel() / convert_list_to_camel()
|
||
▼
|
||
前端接收:{ userName: "admin", createTime: "2026-01-01" }
|
||
```
|
||
|
||
### 排除项(不参与请求方向转换)
|
||
|
||
以下参数保持 camelCase,不转为 snake_case(`config('api.convert_except_params')`):
|
||
|
||
- `pageNo`、`pageSize` — 分页参数
|
||
- `orderField`、`orderType` — 排序参数
|
||
|
||
### 配置开关
|
||
|
||
```php
|
||
// config/api.php
|
||
'camel_snake_convert' => env('API_CAMEL_SNAKE_CONVERT', true),
|
||
```
|
||
|
||
```ini
|
||
# .env
|
||
API_CAMEL_SNAKE_CONVERT=true # 开启(默认)
|
||
API_CAMEL_SNAKE_CONVERT=false # 关闭(请求保持驼峰,响应保持下划线)
|
||
```
|
||
|
||
:::tip 开发者无需手动处理
|
||
驼峰/下划线转换完全自动,开发者只需在前端用 camelCase,在数据库用 snake_case,框架自动处理双向转换。需要原样返回时用 `Result::success($data, $msg, true)`(`$raw=true`)。
|
||
:::
|
||
|
||
## 2. 文件字段自动处理
|
||
|
||
**配置方式**:Service 中 `use HasFileProcessing` 并设置 `fileFields` 和 `fileSaveDir`
|
||
|
||
```php
|
||
class ArticleService extends BaseTenantService
|
||
{
|
||
use HasFileProcessing, /* 其它 Trait */;
|
||
|
||
protected array $fileFields = ['cover', 'attachment'];
|
||
protected string $fileSaveDir = 'article';
|
||
}
|
||
```
|
||
|
||
### 写入时(add/update)
|
||
|
||
```text
|
||
前端传入:cover = "https://temp.example.com/tmp/abc123.jpg"
|
||
│
|
||
▼ processFileFieldsOnSave()
|
||
│ 1. 检测字段值是否为临时文件 URL
|
||
│ 2. 将临时文件迁移到正式目录(fileSaveDir)
|
||
│ 3. 去掉域名,只存储相对路径
|
||
▼
|
||
入库存储:cover = "article/20261002/abc123.jpg"
|
||
```
|
||
|
||
### 读取时(detail/pageList/allList)
|
||
|
||
```text
|
||
数据库读取:cover = "article/20261002/abc123.jpg"
|
||
│
|
||
▼ processFileFieldsOnRead()
|
||
│ 补全完整访问 URL(config/file.php 的 domain_url)
|
||
▼
|
||
前端接收:cover = "https://cdn.example.com/uploads/article/20261002/abc123.jpg"
|
||
```
|
||
|
||
### 多个单文件字段
|
||
|
||
```php
|
||
protected array $fileFields = ['cover', 'avatar', 'attachment'];
|
||
// 三个字段都会自动处理
|
||
```
|
||
|
||
:::warning 未 use Trait 时为空操作
|
||
`BaseService` 中对应方法是**占位实现**(直接 `return $data`)。未 `use HasFileProcessing` 时文件字段不会被处理——这是刻意设计(见 [4.6 核心基类体系](/zh/architecture/base-classes.html)「占位覆盖机制」)。
|
||
:::
|
||
|
||
## 3. 多文件字段自动处理
|
||
|
||
当一个字段存储多个文件 URL(逗号分隔)时,使用 `multiFileFields`:
|
||
|
||
**配置方式**:Service 中设置 `multiFileFields`(复用 `fileSaveDir`)
|
||
|
||
```php
|
||
protected array $multiFileFields = ['images', 'attachments'];
|
||
protected string $fileSaveDir = 'article';
|
||
```
|
||
|
||
**写入时(add/update)**
|
||
|
||
```text
|
||
前端传入:images = "http://example.com/temp/a.png,http://example.com/temp/b.png"
|
||
│
|
||
▼ processMultiFileFieldsOnSave()
|
||
│ 1. 按逗号拆分为数组
|
||
│ 2. 逐个迁移临时文件并去掉域名
|
||
│ 3. 重新用逗号拼接
|
||
▼
|
||
入库存储:images = "article/20261002/a.png,article/20261002/b.png"
|
||
```
|
||
|
||
**读取时(detail/pageList/allList)**
|
||
|
||
```text
|
||
数据库读取:images = "article/20261002/a.png,article/20261002/b.png"
|
||
│
|
||
▼ processMultiFileFieldsOnRead()
|
||
│ 按逗号拆分 → 逐个补全域名 → 重新拼接
|
||
▼
|
||
前端接收:images = "https://cdn.example.com/.../a.png,https://cdn.example.com/.../b.png"
|
||
```
|
||
|
||
## 4. 富文本占位符机制
|
||
|
||
**配置方式**:Service 中设置 `contentFields`
|
||
|
||
```php
|
||
protected array $contentFields = ['content'];
|
||
```
|
||
|
||
### 写入时
|
||
|
||
```text
|
||
前端传入:content = '<p>文章内容</p><img src="https://temp.example.com/tmp/img001.jpg">'
|
||
│
|
||
▼ processContentFieldsOnSave()
|
||
│ 1. 解析 HTML 中的 <img>/<video>/<a> 标签
|
||
│ 2. 将临时文件迁移到正式目录
|
||
│ 3. 替换为 [IMG_URL]/相对路径 占位符
|
||
▼
|
||
入库存储:content = '<p>文章内容</p><img src="[IMG_URL]/uploads/article/img001.jpg">'
|
||
```
|
||
|
||
### 读取时
|
||
|
||
```text
|
||
数据库读取:content 中含 [IMG_URL] 占位符
|
||
│
|
||
▼ processContentFieldsOnRead()
|
||
│ 将 [IMG_URL] 替换为配置的实际域名
|
||
▼
|
||
前端接收:content 中 img src 为完整域名 URL
|
||
```
|
||
|
||
### 好处
|
||
|
||
域名变更时只需修改 `.env` 中的 `FILE_DOMAIN_URL`,已存储的内容无需逐条更新。
|
||
|
||
## 5. 字典显示名翻译
|
||
|
||
**配置方式**:Service 中 `use HasSerializeMaps` 并设置 `serializeMaps`
|
||
|
||
```php
|
||
/**
|
||
* 字典翻译配置:[字段 => 字典编码]
|
||
*
|
||
* @var array<string, string>
|
||
*/
|
||
protected array $serializeMaps = [
|
||
'status' => 'article_status',
|
||
'type' => 'article_type',
|
||
];
|
||
```
|
||
|
||
### 工作原理
|
||
|
||
```text
|
||
数据库查询:{ status: 1, type: 0 }
|
||
│
|
||
▼ processSerializeMapsList()
|
||
│ DictCacheService 批量取词(两级缓存,同一请求只查一次)
|
||
│ getText('article_status', 1) → '已发布'
|
||
│ getText('article_type', 0) → '原创'
|
||
▼
|
||
前端接收:{ status: 1, statusText: '已发布', type: 0, typeText: '原创' }
|
||
```
|
||
|
||
### 字段名映射
|
||
|
||
当字典编码与字段名不同时,直接在映射中指定:
|
||
|
||
```php
|
||
protected array $serializeMaps = [
|
||
'gender' => 'gender',
|
||
'status' => 'user_status',
|
||
];
|
||
```
|
||
|
||
字典数据变更时 `DictItemObserver` 自动清除对应缓存,保证翻译不过期。
|
||
|
||
## 6. 唯一性校验
|
||
|
||
**配置方式**:Service 中 `use HasUniqueCheck` 并设置 `uniqueFields`
|
||
|
||
```php
|
||
// 全局唯一
|
||
protected array $uniqueFields = ['username', 'mobile'];
|
||
```
|
||
|
||
### 两种规则
|
||
|
||
```php
|
||
// 字符串 — 全局唯一
|
||
protected array $uniqueFields = ['username', 'mobile'];
|
||
|
||
// 数组 — 分组内唯一:[唯一字段, 分组字段]
|
||
protected array $uniqueFields = [
|
||
'username', // username 全局唯一
|
||
['code', 'pid'], // code 在同一 pid 下唯一
|
||
];
|
||
```
|
||
|
||
### 工作原理
|
||
|
||
```text
|
||
add() 时:
|
||
checkUnique($data)
|
||
→ SELECT * FROM laravel_user WHERE username = 'admin'
|
||
→ 存在 → throw new BizException('用户名已存在')
|
||
|
||
update() 时:
|
||
checkUnique($data, $excludeId)
|
||
→ SELECT * FROM laravel_user WHERE username = 'admin' AND id <> 42
|
||
→ 存在 → throw new BizException('用户名已存在')
|
||
```
|
||
|
||
- 值为空时跳过(FormRequest 的 `required` 规则兜底)
|
||
- `BizException` 中断 `DB::transaction` 事务并回滚,由 Handler 转为 `code=1` 响应
|
||
|
||
### 错误提示自定义
|
||
|
||
默认提示格式:"字段中文名 + 已存在"。中文名查找顺序:`$fieldLabels` 属性 → Trait 内置映射 → 字段名本身:
|
||
|
||
```php
|
||
class RoleService extends BaseTenantService
|
||
{
|
||
// 自定义字段中文名映射
|
||
protected array $fieldLabels = [
|
||
'code' => '角色编码',
|
||
'name' => '角色名称',
|
||
];
|
||
}
|
||
```
|
||
|
||
## 7. 数据权限过滤
|
||
|
||
**配置方式**:Service 中设置 `dataScopeUserField` 和/或 `dataScopeDeptField`(需 `use HasDataScope`)
|
||
|
||
```php
|
||
protected string $dataScopeUserField = 'create_user'; // 按创建人过滤
|
||
protected string $dataScopeDeptField = 'dept_id'; // 按部门过滤
|
||
```
|
||
|
||
### 三种权限范围
|
||
|
||
数据范围由 `HasDataScope::getRoleDataScope()` 按角色解析。当前 `laravel_role` 表不含 `data_scope` 字段,读取缺省为 1——所有角色按全部数据处理:
|
||
|
||
```text
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ data_scope = 1 → 全部数据(不过滤) │
|
||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
|
||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||
├─────────────────────────────────────────────────────────┤
|
||
│ data_scope = 2 → 本部门数据 │
|
||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
|
||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||
├─────────────────────────────────────────────────────────┤
|
||
│ data_scope = 3 → 仅本人数据(无角色用户默认) │
|
||
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
|
||
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
|
||
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 工作原理
|
||
|
||
```text
|
||
查询时 applyDataScope():
|
||
1. 获取当前用户角色 ID(无角色 → 按 3 仅本人)
|
||
2. 读取角色数据范围(当前无该字段 → 按 1 全部)
|
||
3. scope=1 → 不过滤
|
||
4. scope=2 → WHERE dept_id = 当前用户部门ID(需声明 dataScopeDeptField)
|
||
5. scope=3 → WHERE create_user = 当前用户名(需声明 dataScopeUserField)
|
||
```
|
||
|
||
:::warning 无角色用户
|
||
无角色时默认按"仅本人"(scope=3)处理。
|
||
:::
|
||
|
||
## 8. 租户隔离
|
||
|
||
**配置方式**:继承 `BaseTenantService`(自动 `use HasDataScope` 并设置 `tenantScopeField = 'tenant_id'`)
|
||
|
||
```php
|
||
class ArticleService extends BaseTenantService
|
||
{
|
||
// tenantScopeField 基类默认即为 'tenant_id',无需再声明
|
||
}
|
||
```
|
||
|
||
### 隔离行为
|
||
|
||
| 场景 | 行为 | 实现方法 |
|
||
|------|------|---------|
|
||
| **查询时** | 自动叠加 `WHERE tenant_id = 当前租户ID` | `applyTenantScope()` |
|
||
| **新增时** | 自动填充 `tenant_id` 字段 | `processTenantId()` |
|
||
| **平台用户**(tenantId=0) | 查询不过滤,可跨租户查看 | 条件判断跳过 |
|
||
| **平台用户新增** | 自动代入默认租户(DEFAULT_TENANT_ID) | 读取 `config/tenant.php` |
|
||
|
||
### 租户上下文来源
|
||
|
||
`ResolveTenant` 中间件解析并注入 `request->attributes['tenantId']`,并校验租户状态与有效期(详见 [4.2 请求处理链路](/zh/architecture/request-flow.html))。
|
||
|
||
### 适用范围
|
||
|
||
- 需要租户隔离的模块:继承 `BaseTenantService`(User、Article、Dept、Level 等)
|
||
- 系统共享数据:继承 `BaseService`(Menu、Dict、Config 等)
|
||
|
||
## 9. 审计字段 + 软删除(模型 Trait)
|
||
|
||
**配置方式**:模型 `use HasAuditColumns, SoftDeletesByFlag` 即自动生效,无需额外配置。
|
||
|
||
### 软删除
|
||
|
||
```text
|
||
全局查询范围 SoftDeleteFlagScope:
|
||
所有查询自动叠加 WHERE is_delete = 0
|
||
→ 已删除数据默认不可见
|
||
|
||
软删除操作:
|
||
$model->softDelete() → UPDATE SET is_delete = 1
|
||
Model::batchSoftDelete($ids) → 批量软删除
|
||
|
||
查询已删除数据:
|
||
Model::withoutGlobalScope(SoftDeleteFlagScope::class)->...
|
||
```
|
||
|
||
### 审计字段自动写入
|
||
|
||
| 事件 | 自动填充字段 | 数据来源 |
|
||
|------|------------|---------|
|
||
| `creating`(创建前) | `create_user` | `Auth::username()`(当前登录用户) |
|
||
| `creating`(创建前) | `create_time` | `now()`(已赋值则保留) |
|
||
| `updating`(更新前) | `update_user` | `Auth::username()` |
|
||
| `updating`(更新前) | `update_time` | `now()`(始终覆盖) |
|
||
|
||
:::tip 无用户场景
|
||
控制台任务、定时任务等无登录用户的场景下,用户字段保留空值(可显式赋值跳过),时间字段仍写入。
|
||
:::
|
||
|
||
### 数据库公共字段
|
||
|
||
所有业务表**必须**包含以下字段:
|
||
|
||
```sql
|
||
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 '更新时间'
|
||
```
|
||
|
||
## 横切机制的组合使用
|
||
|
||
一个模块可以同时启用多种横切机制,它们互不干扰:
|
||
|
||
```php
|
||
class ArticleService extends BaseTenantService
|
||
{
|
||
// 租户隔离(继承自动启用:use HasDataScope + tenantScopeField)
|
||
use HasCrudOperations, HasQueryScopes, HasFileProcessing, HasSerializeMaps, HasUniqueCheck;
|
||
|
||
protected string $modelClass = Article::class;
|
||
|
||
// 文件字段处理(需 HasFileProcessing)
|
||
protected array $fileFields = ['cover'];
|
||
protected string $fileSaveDir = 'article';
|
||
|
||
// 多文件字段处理
|
||
protected array $multiFileFields = ['images'];
|
||
|
||
// 富文本占位符
|
||
protected array $contentFields = ['content'];
|
||
|
||
// 字典显示名翻译(需 HasSerializeMaps)
|
||
protected array $serializeMaps = ['status' => 'article_status'];
|
||
|
||
// 唯一性校验(需 HasUniqueCheck)
|
||
protected array $uniqueFields = ['title'];
|
||
|
||
// 数据权限(需 HasDataScope,租户版基类已 use)
|
||
protected string $dataScopeUserField = 'create_user';
|
||
|
||
// 审计字段 + 软删除(模型 Trait 自动生效)
|
||
}
|
||
```
|
||
|
||
执行顺序(add 流程):
|
||
|
||
```text
|
||
beforeAdd ← 钩子
|
||
▼
|
||
(入口) FormRequest ← 参数格式校验(rules)
|
||
▼
|
||
checkUnique ← 唯一性校验(BizException 拦截)
|
||
▼
|
||
DB::transaction {
|
||
processTenantId ← 租户ID填充(HasDataScope 启用时)
|
||
processFileFields ← 文件字段处理(单值)
|
||
processMultiFileFields ← 多文件字段处理(逗号分隔多值)
|
||
processContentFields ← 富文本处理
|
||
Model::create ← 审计字段自动填充(creating 事件)
|
||
afterAdd ← 钩子
|
||
}
|
||
```
|
||
|
||
:::tip 设计价值
|
||
开发者只需声明"这个字段是文件"(`fileFields`)、"这个字段要唯一"(`uniqueFields`),全部处理逻辑由基类与 Trait 统一完成——改动一处,全模块受益。
|
||
:::
|