# 6.1 用户管理 :::tip 概述 用户管理是系统核心模块,`UserService` 继承 `BaseTenantService` 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出,并通过钩子完成编码生成、密码加密、角色关联等业务。 ::: ## 模块特点 | 特性 | 说明 | |------|------| | 租户隔离 | 继承 `BaseTenantService`,自动按 `tenant_id` 过滤与填充 | | 数据权限 | `dataScopeDeptField='dept_id'`,支持按部门过滤 | | 编码生成 | `beforeAdd` 自动生成用户编码(租户编码-6位序号,如 `T0001-000020`) | | 唯一校验 | `username` 全局唯一;`mobile` **按租户分组唯一**(`['mobile','tenant_id']`) | | 枚举映射 | `gender`→gender 字典,`status`→user_status 字典 | | 密码加密 | bcrypt 双重加盐,新增/重置时自动加密 | | 角色关联 | 通过 `UserRole` 中间表关联,支持多角色(`roles` 数组) | | 导入导出 | Excel 分批导出 + 表头映射导入(字典/部门/城市反查) | ## 文件清单 | 文件 | 说明 | |------|------| | `app/Http/Controllers/Api/UserController.php` | 控制器(9 个接口) | | `app/Services/UserService.php` | 业务服务(含 import/export/resetPassword + 6 个钩子) | | `app/Models/User.php` | 模型 | | `app/Models/UserRole.php` | 用户角色关联模型 | | `app/Http/Requests/UserRequest.php` | 表单请求验证 | ## API 接口 | 方法 | 路径 | 权限码 | 说明 | |------|------|--------|------| | GET | `/api/user/page` | sys:user:page | 分页列表 | | GET | `/api/user/detail/{id}` | sys:user:detail | 详情 | | POST | `/api/user/add` | sys:user:add | 新增 | | PUT | `/api/user/update` | sys:user:update | 修改 | | PUT | `/api/user/resetPwd` | sys:user:resetPwd | 重置密码 | | DELETE | `/api/user/delete/{id}` | sys:user:delete | 删除 | | DELETE | `/api/user/batchDelete` | sys:user:batchDelete | 批量删除 | | POST | `/api/user/import` | sys:user:import | 导入 | | GET | `/api/user/export` | sys:user:export | 导出 | ## Service 属性配置 ```php // app/Services/UserService.php(来自项目实际代码) class UserService extends BaseTenantService { // HasDataScope(数据权限 + 租户隔离)由 BaseTenantService 继承提供 // HasFileProcessing:avatar 头像字段的保存前迁移与读取后补域名 use HasCrudOperations, HasFileProcessing, HasQueryScopes, HasSerializeMaps, HasUniqueCheck; protected string $modelClass = User::class; // 单文件字段:保存时迁移临时文件,读取时补全域名 protected array $fileFields = ['avatar']; // 文件保存子目录 protected string $fileSaveDir = 'user'; // LIKE 模糊匹配字段 protected array $pageLikeFields = ['username', 'realname', 'mobile']; // 精确匹配字段 protected array $pageEqFields = ['status', 'dept_id', 'gender', 'tenant_id']; // 默认排序:先按租户分组,再按 id 倒序 protected array $pageOrderBy = [ ['field' => 'tenant_id', 'type' => 'asc'], ['field' => 'id', 'type' => 'desc'], ]; // 唯一性校验:username 全局唯一;mobile 在同一租户内唯一 protected array $uniqueFields = ['username', ['mobile', 'tenant_id']]; // 字典翻译 protected array $serializeMaps = [ 'gender' => 'gender', 'status' => 'user_status', ]; // 数据权限:按部门过滤 protected string $dataScopeDeptField = 'dept_id'; } ``` ## 特殊逻辑 ### 重置密码 ```php // app/Http/Controllers/Api/UserController.php(来自项目实际代码) public function resetPwd(): JsonResponse { $data = $this->getJsonBody(); $id = $data['userId'] ?? 0; if (empty($id)) { return $this->fail('缺少id参数'); } try { $this->userService->resetPassword($id); return $this->success(null, '密码重置成功'); } catch (BizException $e) { return $this->fail($e->getMessage(), $e->getCode()); } catch (\Exception $e) { logger()->error('操作失败', ['error' => $e->getMessage()]); return $this->fail('操作失败'); } } ``` **请求参数:** | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `userId` | int | 是 | 用户ID | :::tip 默认密码与全端下线 重置密码接口无需传递密码参数,系统自动使用默认密码(系统参数 `DEFAULT_PASSWORD`,默认 `123456`)加密写入。`UserService::resetPassword()` 成功后会自动调用 `JwtService::revokeAllTokens($id)` 吊销该用户全部令牌(见 [5.6 密码加密服务](/zh/core/password.html))。 ::: ### 新增用户(beforeAdd 钩子) ```php // app/Services/UserService.php(行为说明) protected function beforeAdd(array $data): array { // 1. 自动生成用户编码:租户编码 + 租户内序号(如 T0001-000020) // 序号 = 当前租户下未删除用户数 + 1 // 平台用户(tenantId=0)自动代入默认租户生成编码 // 2. 处理城市字段:city 数组拆为 province_code / city_code / // district_code / street_code 四个编码 + city_info 名称拼接 // 3. 密码加密:PasswordService::encrypt()(未传密码时用默认密码) // → $data['password'] = 哈希; $data['salt'] = 外部盐 return $data; } ``` :::tip 编码规则 - 用户编码格式:`租户编码-6位序号`,如 `T0001-000020` - 序号 = 当前租户下未删除用户数 + 1 - 编码由系统自动生成,前端不传,修改时不可变更 ::: ### 角色关联(afterAdd / afterUpdate 钩子) ```php // afterAdd:新增后保存用户角色关联 protected function afterAdd(int $id, array $data): void { $roleIds = $data['roles'] ?? []; foreach ($roleIds as $roleId) { UserRole::create(['user_id' => $id, 'role_id' => $roleId]); } } // afterUpdate:先删后插实现全量覆盖 ``` :::info 多角色支持 新增/修改时前端传 `roles: [1, 2, 3]` ID 数组,`afterAdd` 遍历写入 `UserRole` 中间表,`afterUpdate` 先删除旧关联再重建;详情接口返回 ID 数组供表单回显,分页列表返回 `[{id, name}]` 对象数组,表格可直接取 `name` 展示。 ::: ### 详情补全(afterDetail 钩子) ```php // 详情查询后处理(引用传递) protected function afterDetail(int $id, array &$data): void { // 补充角色 ID 数组 $data['roles'] = UserRole::where('user_id', $id)->pluck('role_id'); // 补充关联名称(部门/职级/岗位显示名) $data['deptName'] = ...; $data['levelName'] = ...; $data['positionName'] = ...; // 组装城市编码数组(供前端级联回显) $data['city'] = [ $data['province_code'] ?? '', $data['city_code'] ?? '', $data['district_code'] ?? '', $data['street_code'] ?? '', ]; // 移除敏感字段:password / salt 绝不返回前端 unset($data['password'], $data['salt']); } ``` :::warning 敏感字段 `password` 与 `salt` 在详情/列表输出时必须剔除——这也是日志安全(操作日志 param 排除 password/token)之外的第二道防线。 ::: ### 列表补全(afterPageList 钩子) 分页列表批量补充角色名、部门/职级/岗位名称与字典翻译(避免 N+1,见 [3.5 Service 层开发](/zh/guide/logic.html))。 ### 导出(export) 导出为分批导出(`ExcelService`),每批 500 条,避免内存溢出。 | 导出列 | 字段来源 | |--------|----------| | 用户编码 | code(系统自动生成) | | 姓名 | realname | | 性别 | genderText(字典自动转换) | | 手机号 / 邮箱 | mobile / email | | 部门 / 职级 / 岗位 | deptName / levelName / positionName | | 城市 | cityInfo(四个城市编码拼接为名称) | | 地址 | address | | 用户名 | username | | 状态 | statusText(字典自动转换) | | 创建时间 | create_time | ### 导入(import) 导入时需处理以下反向映射: | 导入列 | 处理逻辑 | |--------|----------| | 性别/状态 | 非数字时 `DictCacheService::getValue` 反查 | | 部门/职级/岗位 | 名称通过名称映射反查 ID | | 城市 | cityInfo 通过城市工具拆为 4 个编码 | | 用户编码 | 忽略(由 `beforeAdd` 自动生成) | 单条失败不中断整体,失败明细(行号 + 原因)汇总返回。 :::warning 导入不处理 code 导入时即使 Excel 中有「用户编码」列也会被忽略,编码由 `beforeAdd` 自动生成。 :::