Files
2026-10-08 17:50:29 +08:00

476 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 统一完成——改动一处,全模块受益。
:::