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

12 KiB
Raw Blame History

3.5 Service 层开发

:::tip 概述 Service 层是业务核心,通过"属性配置 + 生命周期钩子"实现声明式 CRUD。子类只需配置属性和按需重写钩子,即可获得完整的增删改查能力。 :::

Service 文件位置

app/Services/ExampleService.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:

/**
 * 列表查询后处理:批量补充角色与名称映射
 */
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):

/**
 * 导出
 *
 * 按查询参数取全量列表后交给 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 包裹(钩子抛异常自动回滚),无需手动管理。编写自定义组合方法时自行包裹:

/**
 * 新增并保存关联数据(自定义组合操作)
 */
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 统一响应与异常抛出。 :::

租户版 Service(摘自 app/Services/ArticleService.php 实际代码)

多租户模块继承 BaseTenantService(自动 tenant_id 查询过滤与写入填充),文件字段、查询条件、字典翻译照常声明:

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 多租户隔离 与 5.9 数据权限。