# 3.5 Service 层开发 :::tip 概述 Service 层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。 ::: ## Service 文件位置 ```text app/Services/ExampleService.php ``` ## 完整代码(来自项目实际代码) ```php */ protected array $pageLikeFields = ['name']; /** * 精确匹配字段 * * status 用于按状态筛选。 * * @var array */ protected array $pageEqFields = ['status']; /** * 唯一性校验字段 * * @var array */ protected array $uniqueFields = ['name']; /** * 字典翻译配置:[字段 => 字典编码] * * 读取时为 status 补 statusText。 * 字典编码 "status" 需在字典表中存在(code = 'status'), * 否则 statusText 会补空字符串。 * * @var array */ 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)。