签入版本
This commit is contained in:
@@ -1,49 +1,61 @@
|
||||
# 6.15 首页与个人中心
|
||||
|
||||
:::tip 概述
|
||||
首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。该模块不继承 BaseLogic,而是专用业务逻辑。
|
||||
首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。逻辑由专用的 `IndexService` 承载(不走 BaseService CRUD),接口位于认证组内但**无需配置 `#[Permission]`**——已登录即可访问。
|
||||
:::
|
||||
|
||||
## 模块特点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 基类 | 无(直接操作 Model) |
|
||||
| 用途 | 当前登录用户的自身操作 |
|
||||
| 权限 | 无需配置 #[Permission],已登录即可访问 |
|
||||
| 超级管理员 | uid=1 拥有全部权限 `*:*:*` |
|
||||
| Service | `IndexService`(专用逻辑,非 CRUD 服务) |
|
||||
| 权限 | 无需 `#[Permission]`,已登录即可访问 |
|
||||
| 权限缓存复用 | `getUser` 的权限列表复用 `JwtService::getUserPermissions`(Redis 缓存),与 `Authenticate` 中间件保持一致 |
|
||||
| 敏感字段 | 返回前移除 `password` / `salt` |
|
||||
| 修改密码 | 改密成功后**吊销该用户全部令牌**(全端下线) |
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/index/getUser` | 获取当前登录用户信息(含角色和权限) |
|
||||
| GET | `/api/index/getMenus` | 获取当前用户菜单(树形) |
|
||||
| PUT | `/api/index/updatePassword` | 修改密码 |
|
||||
| GET | `/api/index/getMenus` | 获取当前用户菜单(树形,动态路由数据源) |
|
||||
| PUT | `/api/index/updatePassword` | 修改密码(改密后全端下线) |
|
||||
| PUT | `/api/index/updateProfile` | 更新个人信息 |
|
||||
|
||||
## 核心代码
|
||||
## 核心代码(来自项目实际代码)
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
```php
|
||||
// app/logic/IndexLogic.php
|
||||
// app/Services/IndexService.php(节选)
|
||||
|
||||
public function getUser(int $userId): array
|
||||
{
|
||||
$user = User::with(['dept'])->find($userId);
|
||||
if (!$user) {
|
||||
throw new \Exception('用户不存在');
|
||||
if (! $user) {
|
||||
throw new BizException('用户不存在');
|
||||
}
|
||||
|
||||
$data = $user->toArray();
|
||||
|
||||
// 头像补全域名前缀
|
||||
if (!empty($data['avatar'])) {
|
||||
// 头像补全域名
|
||||
if (! empty($data['avatar'])) {
|
||||
$data['avatar'] = get_file_url($data['avatar']);
|
||||
}
|
||||
|
||||
// 组装城市编码数组(province_code, city_code, district_code, street_code)
|
||||
// 获取角色列表(完整角色对象)
|
||||
$roleIds = UserRole::where('user_id', $userId)->pluck('role_id')->toArray();
|
||||
if (! empty($roleIds)) {
|
||||
$data['roles'] = Role::whereIn('id', $roleIds)->get()->toArray();
|
||||
} else {
|
||||
$data['roles'] = [];
|
||||
}
|
||||
|
||||
// 获取权限列表:复用 JwtService 的 Redis 缓存,与 Authenticate 保持一致
|
||||
$data['permissions'] = app(JwtService::class)->getUserPermissions($userId);
|
||||
|
||||
// 组装城市编码数组,供前端级联选择回显
|
||||
$data['city'] = [
|
||||
$data['province_code'] ?? '',
|
||||
$data['city_code'] ?? '',
|
||||
@@ -51,33 +63,6 @@ public function getUser(int $userId): array
|
||||
$data['street_code'] ?? '',
|
||||
];
|
||||
|
||||
// 获取角色列表(完整角色对象)
|
||||
$roleIds = UserRole::where('user_id', $userId)->column('role_id');
|
||||
if (!empty($roleIds)) {
|
||||
$data['roles'] = Role::whereIn('id', $roleIds)->select()->toArray();
|
||||
} else {
|
||||
$data['roles'] = [];
|
||||
}
|
||||
|
||||
// 获取权限列表
|
||||
if ($userId === 1) {
|
||||
$data['permissions'] = ['*:*:*'];
|
||||
} else {
|
||||
if (!empty($roleIds)) {
|
||||
$data['permissions'] = RoleMenu::alias('rm')
|
||||
->join('think_menu m', 'rm.menu_id = m.id')
|
||||
->whereIn('rm.role_id', $roleIds)
|
||||
->where('m.is_delete', 0)
|
||||
->where('m.type', 1)
|
||||
->column('m.permission');
|
||||
} else {
|
||||
$data['permissions'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// authorities 字段(预留)
|
||||
$data['authorities'] = null;
|
||||
|
||||
// 隐藏敏感字段
|
||||
unset($data['password'], $data['salt']);
|
||||
|
||||
@@ -93,119 +78,141 @@ public function getUser(int $userId): array
|
||||
- 字段名由 `Result::success()` 自动转为 camelCase 返回前端
|
||||
:::
|
||||
|
||||
### 获取菜单
|
||||
### 获取菜单(动态路由数据源)
|
||||
|
||||
```php
|
||||
/**
|
||||
* 获取当前用户菜单(树形)
|
||||
*
|
||||
* - 超管(userId=1):返回全部启用的目录/菜单;
|
||||
* - 普通用户:按角色关联的菜单ID取菜单,再组装为树形;
|
||||
* - 无角色或无关联菜单时返回空数组。
|
||||
*
|
||||
* 字段说明:
|
||||
* - hide:0=可见,1=不可见(取 hide=0);
|
||||
* - type:0=菜单/目录,1=节点/权限按钮(取 0);
|
||||
* - status:0=正常,1=停用(取 0)。
|
||||
*/
|
||||
public function getMenus(int $userId): array
|
||||
{
|
||||
// 超级管理员:全部菜单
|
||||
// type=0 仅返回可导航菜单(目录+页面),type=1 为权限按钮不返回
|
||||
// 超级管理员获取所有菜单
|
||||
if ($userId === 1) {
|
||||
$menus = Menu::where('status', 0)
|
||||
->where('hide', 0)
|
||||
->where('type', 0)
|
||||
->where('is_delete', 0)
|
||||
->order('sort', 'asc')
|
||||
->select()->toArray();
|
||||
->orderBy('sort', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $this->buildTree($menus);
|
||||
}
|
||||
|
||||
// 普通用户:通过角色关联获取菜单
|
||||
$roleIds = UserRole::where('user_id', $userId)->column('role_id');
|
||||
if (empty($roleIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$menuIds = RoleMenu::whereIn('role_id', $roleIds)->column('menu_id');
|
||||
$menuIds = array_unique($menuIds);
|
||||
|
||||
if (empty($menuIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$menus = Menu::whereIn('id', $menuIds)
|
||||
->where('status', 0)
|
||||
->where('hide', 0)
|
||||
->where('type', 0)
|
||||
->where('is_delete', 0)
|
||||
->order('sort', 'asc')
|
||||
->select()->toArray();
|
||||
|
||||
return $this->buildTree($menus);
|
||||
// 获取用户角色 → 角色关联菜单ID(去重)→ 过滤后组装树形
|
||||
// (同样附加 status=0、hide=0、type=0、is_delete=0 四个条件)
|
||||
}
|
||||
```
|
||||
|
||||
:::info 菜单查询条件
|
||||
获取菜单(非节点)数据时保证四个条件:`type=0`(可导航菜单)、`status=0`(启用)、`hide=0`(未隐藏)、`is_delete=0`(未删除)。
|
||||
:::
|
||||
|
||||
:::info 菜单类型说明
|
||||
代码中 `where('type', 0)` 用于筛选可导航的菜单项(目录和页面)。在当前实现中,`type=0` 表示可导航的菜单项(包括目录和页面),`type=1` 表示仅用于权限控制的按钮节点,不会出现在菜单树中。
|
||||
:::info 菜单查询四条件
|
||||
获取可导航菜单时保证四个条件:`type=0`(菜单/目录)、`status=0`(启用)、`hide=0`(未隐藏)、`is_delete=0`(未删除)。`type=1` 的权限节点不返回给前端菜单树(仅供后端鉴权)。详见 [6.3 菜单管理](/zh/modules/menu.html)。
|
||||
:::
|
||||
|
||||
### 修改密码
|
||||
|
||||
```php
|
||||
/**
|
||||
* 流程:校验旧密码 → 加密新密码并保存 → 吊销该用户全部令牌。
|
||||
*
|
||||
* @throws BizException 用户不存在或旧密码错误时抛出
|
||||
*/
|
||||
public function updatePassword(int $userId, string $oldPassword, string $newPassword): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
|
||||
// 验证旧密码
|
||||
if (!PasswordService::verify($oldPassword, $user->password, $user->salt)) {
|
||||
throw new \Exception('旧密码错误');
|
||||
// 验证旧密码(bcrypt 双重加盐)
|
||||
if (! PasswordService::verify($oldPassword, $user->password, $user->salt)) {
|
||||
throw new BizException('旧密码错误');
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
// 加密新密码(外部盐 + bcrypt)
|
||||
$encrypted = PasswordService::encrypt($newPassword);
|
||||
$user->password = $encrypted['password'];
|
||||
$user->salt = $encrypted['salt'];
|
||||
return $user->save() !== false;
|
||||
$saved = $user->save();
|
||||
|
||||
if ($saved) {
|
||||
// 改密后全端下线:吊销该用户全部令牌 + 权限缓存
|
||||
app(JwtService::class)->revokeAllTokens($userId);
|
||||
}
|
||||
|
||||
return $saved !== false;
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 改密安全闭环
|
||||
修改密码成功后立即调用 `revokeAllTokens($userId)`——删除 Redis 中该用户**全部** refresh_token 与权限缓存,所有已登录设备(含攻击者持有的旧会话)下一次请求即失效。见 [5.1 用户认证(JWT)](/zh/core/jwt.html)。
|
||||
:::
|
||||
|
||||
### 更新个人信息
|
||||
|
||||
```php
|
||||
public function updateProfile(int $userId, array $data): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
|
||||
// 处理城市字段
|
||||
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']);
|
||||
if (! $user) {
|
||||
throw new BizException('用户不存在');
|
||||
}
|
||||
|
||||
// 排除敏感字段
|
||||
unset($data['password'], $data['salt'], $data['role_id'], $data['tenant_id'], $data['id']);
|
||||
// 字段白名单:仅允许更新以下字段
|
||||
$allowFields = ['realname', 'gender', 'avatar', 'mobile', 'email', 'intro', 'note', 'address'];
|
||||
$updateData = [];
|
||||
foreach ($allowFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updateData[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
return $user->save($data) !== false;
|
||||
// 处理城市字段:city 数组拆分到 4 个独立字段,并生成 city_info
|
||||
if (isset($data['city']) && is_array($data['city'])) {
|
||||
$updateData['province_code'] = $data['city'][0] ?? '';
|
||||
$updateData['city_code'] = $data['city'][1] ?? '';
|
||||
$updateData['district_code'] = $data['city'][2] ?? '';
|
||||
$updateData['street_code'] = $data['city'][3] ?? '';
|
||||
$updateData['city_info'] = City::resolveCityNames($data['city']);
|
||||
}
|
||||
|
||||
$user->update($updateData);
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 安全设计
|
||||
updateProfile 会排除 `password`、`salt`、`role_id`、`tenant_id` 等敏感字段,防止用户越权修改自身权限。
|
||||
:::warning 越权防护
|
||||
`updateProfile` 采用字段白名单(`realname / gender / avatar / mobile / email / intro / note / address`),`city` 数组拆分写入四级编码;`password`、`salt`、`role_id`、`tenant_id`、`id` 等不在名单内的字段一律忽略,防止用户通过个人信息接口越权修改自身密码、角色或租户归属——改密码必须走 `updatePassword`(校验旧密码 + 全端下线)。
|
||||
:::
|
||||
|
||||
## 前端调用
|
||||
|
||||
```typescript
|
||||
// 获取当前用户信息
|
||||
// 获取当前用户信息(登录后 / 刷新页面时)
|
||||
const userInfo = await getUserInfo();
|
||||
// userInfo.permissions = ['sys:user:page', 'sys:article:add', ...]
|
||||
// userInfo.roles = [1]
|
||||
// userInfo.roles = [{id:1, name:'超级管理员', ...}]
|
||||
// userInfo.city = [省编码, 市编码, 区编码, 街道编码]
|
||||
|
||||
// 获取菜单(用于动态路由生成)
|
||||
const menus = await getMenuList();
|
||||
// menus = [{id:1, name:'系统管理', path:'/system', children:[...]}]
|
||||
// menus = [{id:1, name:'控制面板', path:'/dashboard', children:[...]}]
|
||||
|
||||
// 修改密码
|
||||
// 修改密码(成功后前端需清除令牌重新登录 —— 后端已全端吊销)
|
||||
await updatePassword({ oldPassword: '123456', newPassword: '654321' });
|
||||
|
||||
// 更新个人信息
|
||||
await updateProfile({ realname: '张三', mobile: '13800138000' });
|
||||
```
|
||||
```
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [4.2 请求处理链路](/zh/architecture/request-flow.html) — 认证与菜单加载在链路中的位置
|
||||
- [5.4 权限管理(RBAC)](/zh/core/rbac.html) — 权限码与角色体系
|
||||
- [9.5 路由与动态菜单](/zh/frontend/router.html) — 前端如何用 getMenus 生成路由
|
||||
|
||||
Reference in New Issue
Block a user