# 6.15 首页与个人中心 :::tip 概述 首页与个人中心模块负责当前登录用户的信息获取、菜单加载、密码修改和个人信息更新。逻辑由专用的 `IndexService` 承载(不走 BaseService CRUD),接口位于认证组内但**无需配置 `#[Permission]`**——已登录即可访问。 ::: ## 模块特点 | 特性 | 说明 | |------|------| | Service | `IndexService`(专用逻辑,非 CRUD 服务) | | 权限 | 无需 `#[Permission]`,已登录即可访问 | | 权限缓存复用 | `getUser` 的权限列表复用 `JwtService::getUserPermissions`(Redis 缓存),与 `Authenticate` 中间件保持一致 | | 敏感字段 | 返回前移除 `password` / `salt` | | 修改密码 | 改密成功后**吊销该用户全部令牌**(全端下线) | ## API 接口 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/index/getUser` | 获取当前登录用户信息(含角色和权限) | | GET | `/api/index/getMenus` | 获取当前用户菜单(树形,动态路由数据源) | | PUT | `/api/index/updatePassword` | 修改密码(改密后全端下线) | | PUT | `/api/index/updateProfile` | 更新个人信息 | ## 核心代码(来自项目实际代码) ### 获取用户信息 ```php // app/Services/IndexService.php(节选) public function getUser(int $userId): array { $user = User::with(['dept'])->find($userId); if (! $user) { throw new BizException('用户不存在'); } $data = $user->toArray(); // 头像补全域名 if (! empty($data['avatar'])) { $data['avatar'] = get_file_url($data['avatar']); } // 获取角色列表(完整角色对象) $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'] ?? '', $data['district_code'] ?? '', $data['street_code'] ?? '', ]; // 隐藏敏感字段 unset($data['password'], $data['salt']); return $data; } ``` :::info 返回结构说明 - `roles`:完整角色对象数组(含 id、name、code 等),非仅 ID - `permissions`:权限码数组,超级管理员为 `['*:*:*']` - `city`:4 层城市编码数组 `[省, 市, 区, 街道]` - `avatar`:自动补全域名前缀 - 字段名由 `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 { // 超级管理员获取所有菜单 if ($userId === 1) { $menus = Menu::where('status', 0) ->where('hide', 0) ->where('type', 0) ->where('is_delete', 0) ->orderBy('sort', 'asc') ->get() ->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`(未删除)。`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); // 验证旧密码(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']; $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 (! $user) { throw new BizException('用户不存在'); } // 字段白名单:仅允许更新以下字段 $allowFields = ['realname', 'gender', 'avatar', 'mobile', 'email', 'intro', 'note', 'address']; $updateData = []; foreach ($allowFields as $field) { if (isset($data[$field])) { $updateData[$field] = $data[$field]; } } // 处理城市字段: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` 采用字段白名单(`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 = [{id:1, name:'超级管理员', ...}] // userInfo.city = [省编码, 市编码, 区编码, 街道编码] // 获取菜单(用于动态路由生成) const menus = await getMenuList(); // 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 生成路由