Become a sponsor

概述
横切机制是 BaseLogic/BaseModel 内置的自动处理能力,开发者通过属性配置启用,无需手动编写处理代码。项目共有 9 种横切机制,覆盖数据转换、文件处理、校验、权限、隔离等维度。
| 机制 | 配置位置 | 配置属性 | 触发时机 |
|---|---|---|---|
| 驼峰/下划线转换 | config/api.php | camel_snake_convert | 请求入、响应出 |
| 文件字段处理 | Logic | fileFields + fileSaveDir | add/update 写入,detail/page 读取 |
| 多文件字段处理 | Logic | multiFileFields + fileSaveDir | add/update 写入,detail/page 读取 |
| 富文本占位符 | Logic | contentFields | add/update 写入,detail/page 读取 |
| 枚举显示名翻译 | Logic | serializeMaps | detail/page 查询后 |
| 自动参数校验 | Logic | validateClass | add/update 前 |
| 唯一性校验 | Logic | uniqueFields | add/update 时 |
| 数据权限过滤 | Logic | dataScopeUserField / dataScopeDeptField | 查询时 |
| 租户隔离 | Logic(继承 BaseTenantLogic) | tenantScopeField | 查询时 + 新增时 |
| 审计字段 + 软删除 | BaseModel | 自动生效 | 所有 CRUD |
前端使用 camelCase,数据库使用 snake_case,框架自动完成双向转换。
请求方向(前端 → 数据库):
前端传参:{ userName: "admin", createTime: "2026-01-01" }
│
▼ BaseLogic::convertQueryParams()
│ array_camel_to_snake($params, $except)
▼
数据库操作:WHERE user_name = 'admin' AND create_time = '2026-01-01'
响应方向(数据库 → 前端):
数据库数据:{ user_name: "admin", create_time: "2026-01-01" }
│
▼ Result::convertData()
│ array_snake_to_camel($data)
▼
前端接收:{ userName: "admin", createTime: "2026-01-01" }以下参数保持 camelCase,不转为 snake_case:
pageNo、pageSize — 分页参数orderField、orderType — 排序参数fields — 字段过滤参数// config/api.php
'camel_snake_convert' => env('api.camel_snake_convert', true),# .env
API.CAMEL_SNAKE_CONVERT = true # 开启(默认)
API.CAMEL_SNAKE_CONVERT = false # 关闭(请求保持驼峰,响应保持下划线)开发者无需手动处理
驼峰/下划线转换完全自动,开发者只需在前端用 camelCase,在数据库用 snake_case,框架自动处理双向转换。
配置方式:Logic 中设置 fileFields 和 fileSaveDir
class ArticleLogic extends BaseTenantLogic
{
protected array $fileFields = ['cover', 'attachment'];
protected string $fileSaveDir = 'article';
}前端传入:cover = "https://temp.example.com/tmp/abc123.jpg"
│
▼ processFileFieldsOnSave()
│ 1. 检测字段值是否为临时文件 URL
│ 2. 调用 save_file() 将临时文件迁移到正式目录
│ 3. 去掉域名,只存储相对路径
▼
入库存储:cover = "/uploads/article/20260922/abc123.jpg"数据库读取:cover = "/uploads/article/20260922/abc123.jpg"
│
▼ processFileFieldsOnRead()
│ 调用 get_file_url() 补全域名
▼
前端接收:cover = "https://cdn.example.com/uploads/article/20260922/abc123.jpg"protected array $fileFields = ['cover', 'avatar', 'attachment'];
// 三个字段都会自动处理当一个字段存储多个文件URL(逗号分隔)时,使用 multiFileFields:
配置方式:Logic 中设置 multiFileFields(复用 fileSaveDir)
class ArticleLogic extends BaseTenantLogic
{
protected array $multiFileFields = ['images', 'attachments'];
protected string $fileSaveDir = 'article';
}写入时(add/update)
前端传入:images = "http://example.com/temp/a.png,http://example.com/temp/b.png"
│
▼ processMultiFileFieldsOnSave()
│ 1. 按逗号拆分为数组
│ 2. 逐个调用 save_file() 迁移临时文件并去掉域名
│ 3. 重新用逗号拼接
▼
入库存储:images = "article/20260922/a.png,article/20260922/b.png"读取时(detail/pageList/allList)
数据库读取:images = "article/20260922/a.png,article/20260922/b.png"
│
▼ processMultiFileFieldsOnRead()
│ 1. 按逗号拆分为数组
│ 2. 逐个调用 get_file_url() 补全域名
│ 3. 重新用逗号拼接
▼
前端接收:images = "http://cdn.example.com/article/20260922/a.png,http://cdn.example.com/article/20260922/b.png"配置方式:Logic 中设置 contentFields
class ArticleLogic extends BaseTenantLogic
{
protected array $contentFields = ['content'];
}前端传入: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">'数据库读取:content = '<p>文章内容</p><img src="[IMG_URL]/uploads/article/img001.jpg">'
│
▼ processContentFieldsOnRead()
│ 将 [IMG_URL] 替换为配置的实际域名
▼
前端接收:content = '<p>文章内容</p><img src="https://cdn.example.com/uploads/article/img001.jpg">'域名变更时只需修改 config/file.php 中的 domain_url,已存储的内容无需逐条更新。
配置方式:Logic 中设置 serializeMaps
class ArticleLogic extends BaseTenantLogic
{
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'status' => 'article_status',
'type' => 'article_type',
];
}数据库查询:{ status: 1, type: 0 }
│
▼ processSerializeMaps()
│ DictService::getText('article_status', 1) → '已发布'
│ DictService::getText('article_type', 0) → '原创'
▼
前端接收:{ status: 1, statusText: '已发布', type: 0, typeText: '原创' }当字典编码与字段名不同时,可指定映射:
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'gender' => 'gender',
'status' => 'user_status',
];配置方式:Logic 中设置 validateClass
class ArticleLogic extends BaseTenantLogic
{
protected string $validateClass = \app\validate\ArticleValidate::class;
}add() 流程:
beforeAdd($data) → validateData($data, 'add') → 后续入库
update() 流程:
beforeUpdate($id, $data) → validateData($data, 'update', $id) → 后续入库class ArticleValidate extends Validate
{
protected $rule = [
'title' => 'require|max:200',
];
protected $message = [
'title.require' => '文章标题不能为空',
];
protected $scene = [
'add' => ['title'],
'update' => ['title'],
];
}校验失败抛出 ValidateException,由 ExceptionHandle 统一返回 {"code":1, "msg":"参数验证失败", "data":"文章标题不能为空"}。
无论是 Controller 通过 _add/_edit 调用,还是 import 等内部调用都会触发校验。
配置方式:Logic 中设置 uniqueFields
class UserLogic extends BaseTenantLogic
{
// 全局唯一
protected array $uniqueFields = ['username', 'mobile'];
}// 字符串 — 全局唯一
protected array $uniqueFields = ['username', 'mobile'];
// 数组 — 分组内唯一
protected array $uniqueFields = [
'username', // username 全局唯一
['code', 'pid'], // code 在同一 pid 下唯一
];add() 时:
checkUnique($data)
→ SELECT * FROM think_user WHERE username = 'admin'
→ 存在 → throw new \Exception('用户名已存在')
update() 时:
checkUnique($data, $excludeId)
→ SELECT * FROM think_user WHERE username = 'admin' AND id <> 42
→ 存在 → throw new \Exception('用户名已存在')默认提示格式:"字段中文名 + 已存在"。可通过重写 getFieldLabel() 自定义:
protected function getFieldLabel(string $field): string
{
$map = [
'username' => '用户名',
'mobile' => '手机号',
'code' => '编码',
];
return $map[$field] ?? $field;
}配置方式:Logic 中设置 dataScopeUserField 和/或 dataScopeDeptField
class ArticleLogic extends BaseTenantLogic
{
/**
* 数据权限:归属字段名(用于按创建人过滤)
*
* @var string
*/
protected string $dataScopeUserField = 'create_user';
/**
* 数据权限:部门关联字段名(用于按部门过滤)
*
* @var string
*/
protected string $dataScopeDeptField = 'dept_id';
}由角色表 think_role 的 data_scope 字段决定:
┌─────────────────────────────────────────────────────────┐
│ data_scope = 1 → 全部数据(不过滤) │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │ C │ │ D │ │ E │ ← 所有部门所有人的数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────────────────────────┤
│ data_scope = 2 → 本部门数据 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到本部门数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────────────────────────┤
│ data_scope = 3 → 仅本人数据 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ A │ │ B │ │███│ │ D │ │ E │ ← 只看到自己创建的数据 │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
└─────────────────────────────────────────────────────────┘查询时 applyDataScope():
1. 获取当前用户角色 ID
2. 查询角色的 data_scope
3. scope=1 → 不过滤
4. scope=2 → WHERE dept_id = 当前用户部门ID
5. scope=3 → WHERE create_user = 当前用户名无角色用户
无角色时默认按"仅本人"(scope=3)处理。
配置方式:继承 BaseTenantLogic(自动设置 tenantScopeField = 'tenant_id')
class ArticleLogic extends BaseTenantLogic // 继承 BaseTenantLogic 即启用
{
// tenantScopeField 已由基类设为 'tenant_id',无需再声明
}| 场景 | 行为 | 实现方法 |
|---|---|---|
| 查询时 | 自动叠加 WHERE tenant_id = 当前租户ID | applyTenantScope() |
| 新增时 | 自动填充 tenant_id 字段 | processTenantId() |
| 超级管理员(tenantId=0) | 查询不过滤,可跨租户查看 | 条件判断跳过 |
| 超级管理员新增 | 自动代入默认租户(tenant_id=1) | 读取配置 tenant.default_tenant_id |
TenantMiddleware 解析 JWT 中的租户信息,注入 $request->tenantId。
BaseTenantLogic(User、Article、Dept、Level 等)BaseLogic(Menu、Role、Dict、Config 等)配置方式:继承 BaseModel 即自动生效,无需额外配置。
全局查询范围 soft_delete:
所有查询自动叠加 WHERE is_delete = 0
→ 已删除数据默认不可见
软删除操作:
$model->softDelete() → UPDATE SET is_delete = 1
BaseModel::batchSoftDelete($ids) → 批量软删除
查询已删除数据:
Model::withoutGlobalScope(['soft_delete'])->...| 事件 | 自动填充字段 | 数据来源 |
|---|---|---|
onBeforeInsert(创建前) | create_user | $request->userInfo->username |
onBeforeInsert(创建前) | create_time | date('Y-m-d H:i:s') |
onBeforeUpdate(更新前) | update_user | $request->userInfo->username |
onBeforeUpdate(更新前) | update_time | date('Y-m-d H:i:s')(始终覆盖) |
无用户场景
控制台任务、定时任务等无登录用户的场景下,跳过用户字段,时间字段仍写入。
所有业务表必须包含以下字段:
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 '更新时间'一个模块可以同时启用多种横切机制,它们互不干扰:
class ArticleLogic extends BaseTenantLogic
{
protected string $modelClass = Article::class;
// 自动参数校验
protected string $validateClass = ArticleValidate::class;
// 文件字段处理
protected array $fileFields = ['cover'];
protected string $fileSaveDir = 'article';
// 多文件字段处理
protected array $multiFileFields = ['images'];
// 富文本占位符
protected array $contentFields = ['content'];
// 枚举显示名翻译
protected array $serializeMaps = ['status' => 'article_status'];
// 唯一性校验
protected array $uniqueFields = ['title'];
// 数据权限
protected string $dataScopeUserField = 'create_user';
// 租户隔离(继承 BaseTenantLogic 自动启用)
// 审计字段 + 软删除(继承 BaseModel 自动生效)
}执行顺序(add 流程):
beforeAdd ← 钩子
▼
validateData ← 自动参数校验
▼
camel_to_snake ← 驼峰转下划线
▼
processTenantId ← 租户ID填充
▼
processFileFields ← 文件字段处理(单值)
▼
processMultiFileFields ← 多文件字段处理(逗号分隔多值)
▼
processContentFields ← 富文本处理
▼
filterTableFields ← 过滤非数据库字段
▼
checkUnique ← 唯一性校验
▼
Model::create ← 审计字段自动填充
▼
afterAdd ← 钩子