Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/architecture/cross-cutting.md
T
2026-10-08 17:50:29 +08:00

17 KiB
Raw Blame History

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 验证器)。Service 只做业务规则(唯一性、状态依赖)。 :::

1. 驼峰/下划线自动转换

前端使用 camelCase,数据库使用 snake_case,框架自动完成双向转换。

转换流程

请求方向(前端 → 数据库):
  前端传参:{ 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 — 排序参数

配置开关

// config/api.php
'camel_snake_convert' => env('API_CAMEL_SNAKE_CONVERT', true),
# .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

class ArticleService extends BaseTenantService
{
    use HasFileProcessing, /* 其它 Trait */;

    protected array $fileFields = ['cover', 'attachment'];
    protected string $fileSaveDir = 'article';
}

写入时(add/update)

前端传入:cover = "https://temp.example.com/tmp/abc123.jpg"
    │
    ▼ processFileFieldsOnSave()
    │ 1. 检测字段值是否为临时文件 URL
    │ 2. 将临时文件迁移到正式目录(fileSaveDir)
    │ 3. 去掉域名,只存储相对路径
    ▼
入库存储:cover = "article/20261002/abc123.jpg"

读取时(detail/pageList/allList)

数据库读取:cover = "article/20261002/abc123.jpg"
    │
    ▼ processFileFieldsOnRead()
    │ 补全完整访问 URL(config/file.php 的 domain_url)
    ▼
前端接收:cover = "https://cdn.example.com/uploads/article/20261002/abc123.jpg"

多个单文件字段

protected array $fileFields = ['cover', 'avatar', 'attachment'];
// 三个字段都会自动处理

:::warning 未 use Trait 时为空操作 BaseService 中对应方法是占位实现(直接 return $data)。未 use HasFileProcessing 时文件字段不会被处理——这是刻意设计(见 4.6 核心基类体系「占位覆盖机制」)。 :::

3. 多文件字段自动处理

当一个字段存储多个文件 URL(逗号分隔)时,使用 multiFileFields:

配置方式:Service 中设置 multiFileFields(复用 fileSaveDir)

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. 逐个迁移临时文件并去掉域名
    │ 3. 重新用逗号拼接
    ▼
入库存储:images = "article/20261002/a.png,article/20261002/b.png"

读取时(detail/pageList/allList)

数据库读取: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

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 中含 [IMG_URL] 占位符
    │
    ▼ processContentFieldsOnRead()
    │ 将 [IMG_URL] 替换为配置的实际域名
    ▼
前端接收:content 中 img src 为完整域名 URL

好处

域名变更时只需修改 .env 中的 FILE_DOMAIN_URL,已存储的内容无需逐条更新。

5. 字典显示名翻译

配置方式:Service 中 use HasSerializeMaps 并设置 serializeMaps

/**
 * 字典翻译配置:[字段 => 字典编码]
 *
 * @var array<string, string>
 */
protected array $serializeMaps = [
    'status' => 'article_status',
    'type'   => 'article_type',
];

工作原理

数据库查询:{ status: 1, type: 0 }
    │
    ▼ processSerializeMapsList()
    │ DictCacheService 批量取词(两级缓存,同一请求只查一次)
    │   getText('article_status', 1) → '已发布'
    │   getText('article_type', 0)   → '原创'
    ▼
前端接收:{ status: 1, statusText: '已发布', type: 0, typeText: '原创' }

字段名映射

当字典编码与字段名不同时,直接在映射中指定:

protected array $serializeMaps = [
    'gender' => 'gender',
    'status' => 'user_status',
];

字典数据变更时 DictItemObserver 自动清除对应缓存,保证翻译不过期。

6. 唯一性校验

配置方式:Service 中 use HasUniqueCheck 并设置 uniqueFields

// 全局唯一
protected array $uniqueFields = ['username', 'mobile'];

两种规则

// 字符串 — 全局唯一
protected array $uniqueFields = ['username', 'mobile'];

// 数组 — 分组内唯一:[唯一字段, 分组字段]
protected array $uniqueFields = [
    'username',           // username 全局唯一
    ['code', 'pid'],      // code 在同一 pid 下唯一
];

工作原理

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 内置映射 → 字段名本身:

class RoleService extends BaseTenantService
{
    // 自定义字段中文名映射
    protected array $fieldLabels = [
        'code' => '角色编码',
        'name' => '角色名称',
    ];
}

7. 数据权限过滤

配置方式:Service 中设置 dataScopeUserField 和/或 dataScopeDeptField(需 use HasDataScope)

protected string $dataScopeUserField = 'create_user';   // 按创建人过滤
protected string $dataScopeDeptField = 'dept_id';       // 按部门过滤

三种权限范围

数据范围由 HasDataScope::getRoleDataScope() 按角色解析。当前 laravel_role 表不含 data_scope 字段,读取缺省为 1——所有角色按全部数据处理:

┌─────────────────────────────────────────────────────────┐
│  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(无角色 → 按 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')

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 请求处理链路)。

适用范围

  • 需要租户隔离的模块:继承 BaseTenantService(User、Article、Dept、Level 等)
  • 系统共享数据:继承 BaseService(Menu、Dict、Config 等)

9. 审计字段 + 软删除(模型 Trait)

配置方式:模型 use HasAuditColumns, SoftDeletesByFlag 即自动生效,无需额外配置。

软删除

全局查询范围 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 无用户场景 控制台任务、定时任务等无登录用户的场景下,用户字段保留空值(可显式赋值跳过),时间字段仍写入。 :::

数据库公共字段

所有业务表必须包含以下字段:

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 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 流程):

beforeAdd              ← 钩子
    ▼
(入口) FormRequest     ← 参数格式校验(rules)
    ▼
checkUnique            ← 唯一性校验(BizException 拦截)
    ▼
DB::transaction {
    processTenantId        ← 租户ID填充(HasDataScope 启用时)
    processFileFields      ← 文件字段处理(单值)
    processMultiFileFields ← 多文件字段处理(逗号分隔多值)
    processContentFields   ← 富文本处理
    Model::create          ← 审计字段自动填充(creating 事件)
    afterAdd               ← 钩子
}

:::tip 设计价值 开发者只需声明"这个字段是文件"(fileFields)、"这个字段要唯一"(uniqueFields),全部处理逻辑由基类与 Trait 统一完成——改动一处,全模块受益。 :::