签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
+240 -229
View File
@@ -1,319 +1,330 @@
# 3.5 逻辑层开发
# 3.5 Service 层开发
:::tip 概述
逻辑层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
Service 层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。
:::
## 逻辑层文件位置
## Service 文件位置
```text
app/logic/ExampleLogic.php
app/Services/ExampleService.php
```
## 完整代码(来自项目实际代码)
```php
<?php
declare(strict_types=1);
namespace app\logic;
namespace App\Services;
use app\BaseLogic; // 系统共享模块基类
use app\model\Example; // 案例演示模型
use App\Models\Example;
use App\Services\Concerns\HasCrudOperations;
use App\Services\Concerns\HasQueryScopes;
use App\Services\Concerns\HasSerializeMaps;
use App\Services\Concerns\HasUniqueCheck;
/**
* 案例演示业务逻辑类
* 案例演示业务逻辑服务
*
* 继承 BaseLogic,通过属性配置声明式地定义案例演示模块的通用行为,
* 并按需生成导入、导出方法。
* 继承 BaseService,案例作为示例数据,不参与多租户隔离。
*
* 配置覆盖:文件字段、查询条件、排序、唯一性校验、枚举显示名映射。
* 说明:
* - 组合的 Trait 职责:
* - HasCrudOperations:add/update/delete/batchDelete 主流程;
* - HasQueryScopes:列表查询条件、排序白名单;
* - HasSerializeMaps:为 status 补 statusText(字典编码为 status);
* - HasUniqueCheck:新增/修改时校验 name 唯一;
* - 未 use HasDataScope,因此不做数据权限与租户隔离;
* - 除标准 CRUD 外,提供 export / import,供 ExampleController 调用;
* - 导入逐条调用 add(),单条失败不中断,错误明细汇总返回。
*/
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
/**
* 关联的模型类
*
* 指定该逻辑层操作的数据模型为 Example。
* BaseLogic 的所有 CRUD 方法都基于此模型。
*
* @var string
* 绑定的模型类
*/
protected string $modelClass = Example::class;
/**
* 验证器类名
*
* 设置后,add/update 时自动调用对应验证器的 add/update 场景进行参数校验。
* 校验失败抛出 ValidateException,由 ExceptionHandle 统一返回。
*
* @var string
*/
protected string $validateClass = \app\validate\ExampleValidate::class;
/**
* 文件字段
*
* 新增/修改时迁移临时文件并去掉域名,
* 查询时自动补全完整访问 URL。
*
* @var array
*/
protected array $fileFields = ['avatar'];
/**
* 多文件字段(逗号分隔的多个URL)
*
* 与 fileFields 不同,这些字段的值是逗号分隔的多个文件URL,
* 新增/修改时逐个迁移临时文件并去掉域名,
* 查询时逐个补全完整访问 URL。
*
* @var array
*/
protected array $multiFileFields = ['images'];
/**
* 文件保存子目录
*
* 文件字段上传后保存到该子目录下。
*
* @var string
*/
protected string $fileSaveDir = 'example';
/**
* LIKE 模糊匹配字段
*
* 分页查询时,前端传入的 name 参数会自动使用 LIKE 查询。
* 例如:GET /api/example/page?name=测试
* 生成 SQL:WHERE name LIKE '%测试%'
*
* @var array
* @var array<int, string>
*/
protected array $pageLikeFields = ['name'];
/**
* 精确匹配字段
*
* 分页查询时,前端传入的 type 和 status 参数
* 会自动使用 = 查询。
* 例如:GET /api/example/page?type=1&status=1
* 生成 SQL:WHERE type = 1 AND status = 1
* status 用于按状态筛选。
*
* @var array
* @var array<int, string>
*/
protected array $pageEqFields = ['type', 'status'];
protected array $pageEqFields = ['status'];
/**
* 默认排序规则
* 唯一性校验字段
*
* 支持两种格式:
* - 单字段:['field' => 'sort', 'type' => 'asc']
* - 多字段:[['field' => 'sort', 'type' => 'asc'], ['field' => 'id', 'type' => 'desc']]
*
* 前端可通过 orderField/orderType 参数覆盖。
*
* @var array
* @var array<int, string>
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
protected array $uniqueFields = ['name'];
/**
* 枚举显示名映射
* 字典翻译配置:[字段 => 字典编码]
*
* 查询数据时自动根据字典编码翻译值为描述文本,补 {字段名}Text 字段。
* 例如:type=1 时,typeText='类型一'(从字典 example_type 读取)。
* status=1 时,statusText='启用'(从字典 example_status 读取)。
* 读取时为 status 补 statusText。
* 字典编码 "status" 需在字典表中存在(code = 'status'),
* 否则 statusText 会补空字符串。
*
* @var array
* @var array<string, string>
*/
protected array $serializeMaps = [
'type' => 'example_type',
'status' => 'example_status',
];
// ... 其他属性配置
}
```
:::tip 属性配置即全部
子类只需声明以上属性,无需编写任何 CRUD 代码,即可获得完整的分页查询、详情、新增、修改、删除能力。这是 BaseLogic 模板方法模式的核心优势。
子类只声明以上属性,**分页、详情、新增、修改、删除方法全部由 Trait 提供,无需手写**——这就是声明式 CRUD。`add` 内部还会自动完成:唯一性校验 → 钩子 → 事务 → 审计字段 → 字典翻译。
:::
## 扩展方法(导入导出示例)
## Trait 组合机制(BaseService + 6 个 Concerns)
```php
/**
* 导入案例演示数据
*
* 处理流程:
* 1. 按表单字段生成"中文表头 => 字段名"的映射,跳过图片与富文本字段;
* 2. 调用 ExcelService::importWithMap 解析文件为关联数组;
* 3. 枚举字段反向映射:把文字通过 DictService 还原为数值;
* 4. 逐行调用 add 方法写入,成功计数,失败收集行号与原因;
* 5. 返回成功数与错误列表。
*
* @param string $filePath 文件路径
* @return array ['count' => 成功数, 'errors' => [失败行信息]]
* @throws \Exception 文件解析失败时抛出
*/
public function import(string $filePath): array
{
// 定义表头映射:Excel 表头名 → 数据库字段名
$headerMap = [
'案例名称' => 'name',
'案例类型' => 'type',
'案例状态' => 'status',
'案例排序' => 'sort',
];
所有通用属性集中在 `BaseService` 声明,6 个 Trait **只放方法**:
// 解析 Excel 文件为关联数组
$data = \app\service\ExcelService::importWithMap($filePath, $headerMap);
| Trait | 职责 | example 是否启用 |
|------|------|------|
| `HasCrudOperations` | add / update / delete / batchDelete 主流程 + 生命周期钩子 + `DB::transaction` 事务包裹 | ✅ |
| `HasQueryScopes` | `buildQuery` 查询构建(LIKE/精确匹配)、排序白名单、`orderField` 动态排序 | ✅ |
| `HasSerializeMaps` | 字典翻译:读取时按 `serializeMaps` 补 `{字段}Text`(DictCacheService 批量优化) | ✅ |
| `HasUniqueCheck` | 按 `uniqueFields` 做唯一性校验,重复时抛 `BizException` | ✅ |
| `HasFileProcessing` | 文件/多文件/富文本字段的保存迁移与读取域名补全 | ❌ 本例未配置文件字段 |
| `HasDataScope` | 数据权限(本部门/仅本人)+ 租户隔离(`tenant_id` 过滤与填充) | ❌ 本例不做隔离 |
// 枚举字段反向映射:文字转数字
// 例如 Excel 中"启用" → 数据库中 1
foreach ($data as &$row) {
if (!empty($row['type']) && !is_numeric($row['type'])) {
$row['type'] = \app\service\DictService::getValue('example_type', $row['type']);
}
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = \app\service\DictService::getValue('example_status', $row['status']);
}
}
unset($row);
:::tip 占位覆盖机制
`BaseService` 底部为每个 Trait 方法提供**占位实现**(如 `processFileFieldsOnSave()` 直接 `return $data`)。子类 `use` 对应 Trait 后,PHP Trait 机制自动以真实实现覆盖占位——未启用的 Trait 方法保持空操作,避免调用未定义方法。
:::
// 逐行导入
$count = 0;
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row); // 调用 BaseLogic::add,触发 beforeAdd/afterAdd 钩子
$count++;
} catch (\Exception $e) {
// 单行失败不影响其它行,收集错误信息
$errors[] = [
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
'reason' => $e->getMessage(),
];
}
}
return ['count' => $count, 'errors' => $errors];
}
/**
* 导出案例演示数据
*
* 处理流程:
* 1. 按列表字段生成导出表头,跳过图片与富文本字段;
* 带选项的字段使用 {字段名}Text,以导出显示名而非原始值;
* 2. 追加创建时间列;
* 3. 调用 ExcelService::chunkExport 分批导出,每批 500 条,
* 避免一次性加载全部数据导致内存溢出。
*
* @param array $params 查询参数
* @return string 导出文件的 URL
* @throws \Exception 导出失败时抛出
*/
public function export(array $params = []): string
{
// 定义导出表头:字段名 → Excel 表头名
// 带 Text 后缀的字段取枚举显示名(如 typeText='类型一')
$headers = [
'name' => '案例名称',
'typeText' => '案例类型', // 枚举显示名
'statusText' => '案例状态', // 枚举显示名
'sort' => '案例排序',
'create_time' => '创建时间',
];
// 分批导出,每批 500 条
return \app\service\ExcelService::chunkExport($headers, function (int $page) use ($params) {
$chunk = [];
// chunkList 是 BaseLogic 提供的分批查询方法
$this->chunkList($params, function (array $data) use (&$chunk) {
$chunk = $data;
}, 500);
return $chunk;
}, '案例演示数据.xlsx', 500);
}
}
```
## 属性配置速查
## 属性配置速查(BaseService 全部声明式属性)
| 属性 | 类型 | 说明 | 本例配置 |
|------|------|------|----------|
| `modelClass` | string | 关联模型类名(必须) | `Example::class` |
| `validateClass` | string | 验证器类名(自动参数校验) | `ExampleValidate::class` |
| `fileFields` | array | 单文件字段列表 | `['avatar']` |
| `multiFileFields` | array | 多文件字段列表(逗号分隔的多个URL) | `['images']` |
| `fileSaveDir` | string | 文件保存子目录 | `'example'` |
| `modelClass` | string | 绑定的模型类(必须) | `Example::class` |
| `defaultWith` | array | 查询默认预加载的关联 | `[]` |
| `pageLikeFields` | array | LIKE 模糊匹配字段 | `['name']` |
| `pageEqFields` | array | 精确匹配字段 | `['type', 'status']` |
| `pageOrderBy` | array | 默认排序规则 | `['field'=>'sort','type'=>'asc']` |
| `uniqueFields` | array | 唯一性校验字段 | `[]`(本例未配置) |
| `serializeMaps` | array | 枚举显示名映射 | `['type'=>'example_type', 'status'=>'example_status']` |
| `contentFields` | array | 富文本字段 | `[]`(本例未配置) |
| `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($data)` | 新增前 | 修改数据、校验、抛异常拦截 |
| `afterAdd($id, $data)` | 新增后 | 保存关联表、发送通知 |
| `beforeUpdate($id, $data)` | 修改前 | 修改数据、校验 |
| `afterUpdate($id, $data)` | 修改后 | 更新关联表 |
| `beforeDelete($id)` | 删除前 | 检查是否有子级、抛异常拦截 |
| `afterDelete($id)` | 删除后 | 清理关联数据 |
| `afterDetail($id, &$data)` | 详情查询后 | 补充关联数据、格式化输出 |
| `afterPageList(&$records, $params)` | 列表查询后 | 批量补充关联数据(分页和全量列表均触发) |
| `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 示例
### afterPageList 示例(摘自 `app/Services/UserService.php`)
当列表需要补充关联统计数据时,`afterPageList` 比逐条 `afterDetail` 更高效,只需一次 SQL 查询:
当列表需要补充关联数据时,`afterPageList` 比逐条 `afterDetail` 更高效,批量查询避免 N+1:
```php
/**
* 列表查询后处理:批量补充评论数
*
* @param array &$records 记录列表(引用传递)
* @param array $params 查询参数
* @return void
* 列表查询后处理:批量补充角色与名称映射
*/
protected function afterPageList(array &$records, array $params): void
{
if (empty($records)) return;
$userIds = array_column($records, 'id');
if (empty($userIds)) {
return;
}
// 一次查询获取所有记录的评论数,避免 N+1
$ids = array_column($records, 'id');
$counts = Comment::whereIn('article_id', $ids)
->group('article_id')
->column('count(*)', 'article_id');
// 批量获取用户角色(仅查所需字段,减少数据传输)
$userRoles = [];
$roleData = UserRole::whereIn('user_id', $userIds)->select('user_id', 'role_id')->get();
foreach ($roleData as $rd) {
$userRoles[$rd->user_id][] = $rd->role_id;
}
foreach ($records as &$record) {
$record['commentCount'] = $counts[$record['id']] ?? 0;
// 预加载名称映射(一次查询,避免逐条查)
$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 查询
- `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
{
$this->startTrans(); // 开启事务
try {
$id = $this->add($data);
return DB::transaction(function () use ($data) {
$id = $this->add($data); // 内部又有事务 → 嵌套事务(Laravel 保存点)
// 保存关联数据...
$this->commit(); // 提交事务
return $id;
} catch (\Exception $e) {
$this->rollback(); // 回滚事务
throw $e;
}
// 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)。