签入版本
This commit is contained in:
@@ -1,64 +1,117 @@
|
||||
# 6.1 用户管理
|
||||
|
||||
:::tip 概述
|
||||
用户管理是系统核心模块,继承 BaseTenantLogic 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出。
|
||||
用户管理是系统核心模块,`UserService` 继承 `BaseTenantService` 实现租户隔离,支持 CRUD、重置密码、Excel 导入导出,并通过钩子完成编码生成、密码加密、角色关联等业务。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 租户隔离 | 继承 BaseTenantLogic,自动按 tenant_id 过滤 |
|
||||
| 数据权限 | 配置 dataScopeDeptField='dept_id',支持按部门过滤 |
|
||||
| 编码生成 | beforeAdd 自动生成用户编码(租户编码-6位序号,如 T0001-000020) |
|
||||
| 文件字段 | avatar(头像),保存到 user/avatar 目录 |
|
||||
| 唯一校验 | username、mobile 全局唯一 |
|
||||
| 枚举映射 | gender→gender 字典,status→user_status 字典 |
|
||||
| 密码加密 | bcrypt 双重加盐,新增时自动加密 |
|
||||
| 角色关联 | 通过 UserRole 中间表关联,支持多角色 |
|
||||
| 租户隔离 | 继承 `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/controller/UserController.php` | 控制器(9 个接口) |
|
||||
| `app/logic/UserLogic.php` | 逻辑层(含 import/export/resetPassword) |
|
||||
| `app/model/User.php` | 模型 |
|
||||
| `app/model/UserRole.php` | 用户角色关联模型 |
|
||||
| `app/validate/UserValidate.php` | 验证器 |
|
||||
| `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 | 详情 |
|
||||
| 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/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/controller/UserController.php
|
||||
// app/Http/Controllers/Api/UserController.php(来自项目实际代码)
|
||||
|
||||
public function resetPwd(): Json
|
||||
public function resetPwd(): JsonResponse
|
||||
{
|
||||
$data = $this->getJsonBody();
|
||||
$id = $data['userId'] ?? 0;
|
||||
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少id参数');
|
||||
}
|
||||
|
||||
$this->logic->resetPassword($id);
|
||||
return $this->success(null, '密码重置成功');
|
||||
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('操作失败');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -68,50 +121,26 @@ public function resetPwd(): Json
|
||||
|------|------|------|------|
|
||||
| `userId` | int | 是 | 用户ID |
|
||||
|
||||
:::tip 默认密码
|
||||
重置密码接口无需传递密码参数,系统自动使用 `PasswordService::getDefaultPasswordPlain()` 作为默认密码。
|
||||
:::tip 默认密码与全端下线
|
||||
重置密码接口无需传递密码参数,系统自动使用默认密码(系统参数 `DEFAULT_PASSWORD`,默认 `123456`)加密写入。`UserService::resetPassword()` 成功后会自动调用 `JwtService::revokeAllTokens($id)` 吊销该用户全部令牌(见 [5.6 密码加密服务](/zh/core/password.html))。
|
||||
:::
|
||||
|
||||
### 新增用户(beforeAdd 钩子)
|
||||
|
||||
```php
|
||||
// app/logic/UserLogic.php
|
||||
// app/Services/UserService.php(行为说明)
|
||||
|
||||
/**
|
||||
* 新增前处理:生成编码、处理城市、加密密码
|
||||
*
|
||||
* @param array $data 待新增的数据
|
||||
* @return array 处理后的数据
|
||||
*/
|
||||
protected function beforeAdd(array $data): array
|
||||
{
|
||||
// 自动生成用户编码:租户编码 + 租户内序号(如 T0001-000020)
|
||||
$tenantId = $data['tenantId'] ?? request()->tenantId ?? 0;
|
||||
if ($tenantId == 0) {
|
||||
$tenantId = config('tenant.default_tenant_id', 1);
|
||||
}
|
||||
if ($tenantId > 0) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$currentUserCount = User::where('tenant_id', $tenantId)->where('is_delete', 0)->count();
|
||||
$nextSeq = $currentUserCount + 1;
|
||||
$data['code'] = $tenant->code . '-' . str_pad((string)$nextSeq, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
// 1. 自动生成用户编码:租户编码 + 租户内序号(如 T0001-000020)
|
||||
// 序号 = 当前租户下未删除用户数 + 1
|
||||
// 平台用户(tenantId=0)自动代入默认租户生成编码
|
||||
|
||||
// 处理城市字段
|
||||
if (isset($data['city']) && is_array($data['city'])) {
|
||||
$data['province_code'] = $data['city'][0] ?? '';
|
||||
$data['city_code'] = $data['city'][1] ?? '';
|
||||
$data['district_code'] = $data['city'][2] ?? '';
|
||||
$data['street_code'] = $data['city'][3] ?? '';
|
||||
$data['city_info'] = City::resolveCityNames($data['city']);
|
||||
unset($data['city']);
|
||||
}
|
||||
// 2. 处理城市字段:city 数组拆为 province_code / city_code /
|
||||
// district_code / street_code 四个编码 + city_info 名称拼接
|
||||
|
||||
// 密码加密
|
||||
$password = $data['password'] ?? PasswordService::getDefaultPasswordPlain();
|
||||
$encrypted = PasswordService::encrypt($password);
|
||||
$data['password'] = $encrypted['password'];
|
||||
$data['salt'] = $encrypted['salt'];
|
||||
// 3. 密码加密:PasswordService::encrypt()(未传密码时用默认密码)
|
||||
// → $data['password'] = 哈希; $data['salt'] = 外部盐
|
||||
|
||||
return $data;
|
||||
}
|
||||
@@ -120,91 +149,77 @@ protected function beforeAdd(array $data): array
|
||||
:::tip 编码规则
|
||||
- 用户编码格式:`租户编码-6位序号`,如 `T0001-000020`
|
||||
- 序号 = 当前租户下未删除用户数 + 1
|
||||
- 超级管理员(tenantId=0)自动代入默认租户生成编码
|
||||
- 编码由系统自动生成,前端不传,修改时不可变更
|
||||
:::
|
||||
|
||||
### 新增后保存角色(afterAdd 钩子)
|
||||
### 角色关联(afterAdd / afterUpdate 钩子)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 新增后处理:保存用户角色关联
|
||||
*
|
||||
* @param int $id 新增用户的主键 ID
|
||||
* @param array $data 新增时提交的原始数据
|
||||
* @return void
|
||||
*/
|
||||
// afterAdd:新增后保存用户角色关联
|
||||
protected function afterAdd(int $id, array $data): void
|
||||
{
|
||||
$roleIds = $data['roles'] ?? [];
|
||||
foreach ($roleIds as $roleId) {
|
||||
UserRole::create([
|
||||
'user_id' => $id,
|
||||
'role_id' => $roleId,
|
||||
'create_user' => request()->userInfo->username ?? '',
|
||||
]);
|
||||
UserRole::create(['user_id' => $id, 'role_id' => $roleId]);
|
||||
}
|
||||
}
|
||||
|
||||
// afterUpdate:先删后插实现全量覆盖
|
||||
```
|
||||
|
||||
:::info 多角色支持
|
||||
前端传 `roles: [1, 2, 3]` 数组,`afterAdd` 遍历写入 `UserRole` 中间表。`afterUpdate` 同理,先删后插实现全量覆盖。
|
||||
新增/修改时前端传 `roles: [1, 2, 3]` ID 数组,`afterAdd` 遍历写入 `UserRole` 中间表,`afterUpdate` 先删除旧关联再重建;详情接口返回 ID 数组供表单回显,分页列表返回 `[{id, name}]` 对象数组,表格可直接取 `name` 展示。
|
||||
:::
|
||||
|
||||
### 详情补全(afterDetail 钩子)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 详情查询后处理:补充关联数据与格式化输出
|
||||
*
|
||||
* @param int $id 用户 ID
|
||||
* @param array &$data 详情数据(引用传递)
|
||||
* @return void
|
||||
*/
|
||||
// 详情查询后处理(引用传递)
|
||||
protected function afterDetail(int $id, array &$data): void
|
||||
{
|
||||
// 补充角色信息
|
||||
$roleIds = UserRole::where('user_id', $id)->column('role_id');
|
||||
$data['roles'] = $roleIds;
|
||||
// 补充角色 ID 数组
|
||||
$data['roles'] = UserRole::where('user_id', $id)->pluck('role_id');
|
||||
|
||||
// 补充关联名称
|
||||
$data['levelName'] = Level::getNameMap()[$data['level_id']] ?? '';
|
||||
$data['positionName'] = Position::getNameMap()[$data['position_id']] ?? '';
|
||||
$data['deptName'] = Dept::getNameMap()[$data['dept_id']] ?? '';
|
||||
// 补充关联名称(部门/职级/岗位显示名)
|
||||
$data['deptName'] = ...;
|
||||
$data['levelName'] = ...;
|
||||
$data['positionName'] = ...;
|
||||
|
||||
// 组装城市数组
|
||||
// 组装城市编码数组(供前端级联回显)
|
||||
$data['city'] = [
|
||||
$data['province_code'] ?? '',
|
||||
$data['city_code'] ?? '',
|
||||
$data['district_code'] ?? '',
|
||||
$data['street_code'] ?? '',
|
||||
$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)
|
||||
|
||||
导出为分批导出(chunkExport),每批 500 条,避免内存溢出。
|
||||
导出为分批导出(`ExcelService`),每批 500 条,避免内存溢出。
|
||||
|
||||
| 导出列 | 字段来源 |
|
||||
|--------|----------|
|
||||
| 用户编码 | code(系统自动生成) |
|
||||
| 姓名 | realname |
|
||||
| 性别 | genderText(DictService 自动转换) |
|
||||
| 手机号 | mobile |
|
||||
| 邮箱 | email |
|
||||
| 部门 | deptName(Dept::getNameMap 映射) |
|
||||
| 职级 | levelName(Level::getNameMap 映射) |
|
||||
| 岗位 | positionName(Position::getNameMap 映射) |
|
||||
| 城市 | cityInfo(4个城市编码拼接为名称字符串) |
|
||||
| 性别 | genderText(字典自动转换) |
|
||||
| 手机号 / 邮箱 | mobile / email |
|
||||
| 部门 / 职级 / 岗位 | deptName / levelName / positionName |
|
||||
| 城市 | cityInfo(四个城市编码拼接为名称) |
|
||||
| 地址 | address |
|
||||
| 用户名 | username |
|
||||
| 简介 | intro |
|
||||
| 状态 | statusText(DictService 自动转换) |
|
||||
| 备注 | note |
|
||||
| 状态 | statusText(字典自动转换) |
|
||||
| 创建时间 | create_time |
|
||||
|
||||
### 导入(import)
|
||||
|
||||
@@ -212,11 +227,13 @@ protected function afterDetail(int $id, array &$data): void
|
||||
|
||||
| 导入列 | 处理逻辑 |
|
||||
|--------|----------|
|
||||
| 性别/状态 | 非数字时 DictService::getValue 反查 |
|
||||
| 部门/职级/岗位 | 名称通过 array_flip(getNameMap) 反查 ID |
|
||||
| 城市 | cityInfo 通过 City::resolveCityCodes 拆为4个编码 |
|
||||
| 用户编码 | 忽略(由 beforeAdd 自动生成) |
|
||||
| 性别/状态 | 非数字时 `DictCacheService::getValue` 反查 |
|
||||
| 部门/职级/岗位 | 名称通过名称映射反查 ID |
|
||||
| 城市 | cityInfo 通过城市工具拆为 4 个编码 |
|
||||
| 用户编码 | 忽略(由 `beforeAdd` 自动生成) |
|
||||
|
||||
单条失败不中断整体,失败明细(行号 + 原因)汇总返回。
|
||||
|
||||
:::warning 导入不处理 code
|
||||
导入时即使 Excel 中有「用户编码」列也会被忽略,编码由 `beforeAdd` 自动生成。
|
||||
:::
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user