Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/guide/logic.md
T
2026-10-08 17:50:29 +08:00

331 lines
12 KiB
Markdown
Raw 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.
# 3.5 Service 层开发
:::tip 概述
Service 层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
:::
## Service 文件位置
```text
app/Services/ExampleService.php
```
## 完整代码(来自项目实际代码)
```php
<?php
namespace App\Services;
use App\Models\Example;
use App\Services\Concerns\HasCrudOperations;
use App\Services\Concerns\HasQueryScopes;
use App\Services\Concerns\HasSerializeMaps;
use App\Services\Concerns\HasUniqueCheck;
/**
* 案例演示业务逻辑服务
*
* 继承 BaseService,案例作为示例数据,不参与多租户隔离。
*
* 说明:
* - 组合的 Trait 职责:
* - HasCrudOperations:add/update/delete/batchDelete 主流程;
* - HasQueryScopes:列表查询条件、排序白名单;
* - HasSerializeMaps:为 status 补 statusText(字典编码为 status);
* - HasUniqueCheck:新增/修改时校验 name 唯一;
* - 未 use HasDataScope,因此不做数据权限与租户隔离;
* - 除标准 CRUD 外,提供 export / import,供 ExampleController 调用;
* - 导入逐条调用 add(),单条失败不中断,错误明细汇总返回。
*/
class ExampleService extends BaseService
{
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
/**
* 绑定的模型类
*/
protected string $modelClass = Example::class;
/**
* LIKE 模糊匹配字段
*
* @var array<int, string>
*/
protected array $pageLikeFields = ['name'];
/**
* 精确匹配字段
*
* status 用于按状态筛选。
*
* @var array<int, string>
*/
protected array $pageEqFields = ['status'];
/**
* 唯一性校验字段
*
* @var array<int, string>
*/
protected array $uniqueFields = ['name'];
/**
* 字典翻译配置:[字段 => 字典编码]
*
* 读取时为 status 补 statusText。
* 字典编码 "status" 需在字典表中存在(code = 'status'),
* 否则 statusText 会补空字符串。
*
* @var array<string, string>
*/
protected array $serializeMaps = [
'status' => 'example_status',
];
}
```
:::tip 属性配置即全部
子类只声明以上属性,**分页、详情、新增、修改、删除方法全部由 Trait 提供,无需手写**——这就是声明式 CRUD。`add` 内部还会自动完成:唯一性校验 → 钩子 → 事务 → 审计字段 → 字典翻译。
:::
## Trait 组合机制(BaseService + 6 个 Concerns)
所有通用属性集中在 `BaseService` 声明,6 个 Trait **只放方法**:
| Trait | 职责 | example 是否启用 |
|------|------|------|
| `HasCrudOperations` | add / update / delete / batchDelete 主流程 + 生命周期钩子 + `DB::transaction` 事务包裹 | ✅ |
| `HasQueryScopes` | `buildQuery` 查询构建(LIKE/精确匹配)、排序白名单、`orderField` 动态排序 | ✅ |
| `HasSerializeMaps` | 字典翻译:读取时按 `serializeMaps` 补 `{字段}Text`(DictCacheService 批量优化) | ✅ |
| `HasUniqueCheck` | 按 `uniqueFields` 做唯一性校验,重复时抛 `BizException` | ✅ |
| `HasFileProcessing` | 文件/多文件/富文本字段的保存迁移与读取域名补全 | ❌ 本例未配置文件字段 |
| `HasDataScope` | 数据权限(本部门/仅本人)+ 租户隔离(`tenant_id` 过滤与填充) | ❌ 本例不做隔离 |
:::tip 占位覆盖机制
`BaseService` 底部为每个 Trait 方法提供**占位实现**(如 `processFileFieldsOnSave()` 直接 `return $data`)。子类 `use` 对应 Trait 后,PHP Trait 机制自动以真实实现覆盖占位——未启用的 Trait 方法保持空操作,避免调用未定义方法。
:::
## 属性配置速查(BaseService 全部声明式属性)
| 属性 | 类型 | 说明 | 本例配置 |
|------|------|------|----------|
| `modelClass` | string | 绑定的模型类(必须) | `Example::class` |
| `defaultWith` | array | 查询默认预加载的关联 | `[]` |
| `pageLikeFields` | array | LIKE 模糊匹配字段 | `['name']` |
| `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(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 示例(摘自 `app/Services/UserService.php`)
当列表需要补充关联数据时,`afterPageList` 比逐条 `afterDetail` 更高效,批量查询避免 N+1:
```php
/**
* 列表查询后处理:批量补充角色与名称映射
*/
protected function afterPageList(array &$records, array $params): void
{
$userIds = array_column($records, 'id');
if (empty($userIds)) {
return;
}
// 批量获取用户角色(仅查所需字段,减少数据传输)
$userRoles = [];
$roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get();
foreach ($roleData as $rd) {
$userRoles[$rd->user_id][] = $rd->role_id;
}
// 预加载名称映射(一次查询,避免逐条查)
$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 查询。
:::
## 扩展方法(导入导出,来自项目实际代码)
标准 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
{
return DB::transaction(function () use ($data) {
$id = $this->add($data); // 内部又有事务 → 嵌套事务(Laravel 保存点)
// 保存关联数据...
// 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)。