> 所属产品:LightApollo · 信息截止:2026-09-07(deep-rev)
1. 页面概览
1.1 是什么
「用户管理」页(对应前端源码 action/web/src/views/apollo/UsersPage.vue,页面标题「平台管理 · 用户管理」)是 LightApollo 独立账号体系的治理入口。LightApollo 拥有独立于 AIP/Foundry 的用户库与登录态(独立 apollo_token),本页只管理 Apollo 域内的账号与角色挂载:上半部分是一个带关键字过滤与客户端分页的用户列表,把每个用户的 ID、用户名、姓名、邮箱、角色标签、锁定状态与创建时间铺成一览表;操作入口只有两个——页头「新建用户」弹窗(后端当前仅支持 username/password 两个字段)与每行「分配角色」弹窗(勾选/取消即增删该用户的角色绑定)。页面在 TAD-11(细粒度 RBAC)批次 2 引入,配套「角色管理」页(定义角色与权限点)与「API Key 管理」页(为第三方程序发放 lap_ 开头访问凭据)构成平台管理组三条账号主线。
本页的定位是「账号创建 + 角色挂载」两个动作,而非完整的账号生命周期治理:与 AIP 管理后台的 UsersPage.vue(支持编辑、锁定/解锁、重置密码、删除)不同,Apollo 用户表只开放「读、建、挂角色」三类能力——后端没有注册 PUT /users/:id(改信息/重置密码)与 DELETE /users/:id(删除)路由,页面也因此不出现编辑、锁定、重置密码、删除按钮。也就是说:创建出来的账号一旦因连续登录失败被自动锁定(is_locked=true),没有任何页面入口可解锁或重置其密码,只能靠管理员直接改库,这是本页边界最需要先讲清的一点(详见第 8 章)。
从「输入→输出」看页面闭环:输入是管理员手动填写的用户名/密码,或对某个已有账号勾选的角色;输出是 POST /users、POST/DELETE /users/:id/roles[...] 对 Apollo users 表(bcrypt 密码哈希)与 user_roles 关联表的写操作,以及页面顶部提示条的成败反馈。整个过程都经过受保护 API:GET /users 需要 user:read 权限点,创建用户需要 user:write,而「分配/移除角色」走的是 role:write 权限点(绑定关系的语义归属「角色管理」,不是「用户管理」,首次用本页前最好先记住这一点,否则会发现有 user:write 却仍 403,见 5.4 与 7 章)。全部写操作都会落审计(见 5.4 审计一节)。
典型使用链路(管理员视角):① 首次部署后 seed 自动建好 5 个内置角色(platform_admin、project_admin、developer、operator、viewer)并把 admin 用户绑定 platform_admin,用 admin 登录 Apollo(独立登录,见 login.md);② 需要自定义授权边界时先到「角色管理」新建角色并勾权限点;③ 回本页「新建用户」为同事创建账号(此时只有用户名/密码,姓名邮箱留空);④ 在该用户行「分配角色」挂上角色;⑤ 同事用账号密码到 /apollo/login 登录。若某账号连续 5 次输错密码会被自动锁定(状态列翻红「已锁定」),本页与全站都没有解锁/重置密码入口,只能改库恢复——建议把「账号锁定后的处理流程」写进运维手册。与 AIP 管理后台的差异:AIP 的 UsersPage.vue(/admin/users)支持编辑资料、锁定/解锁、重置密码与删除(当前登录用户删除按钮隐藏);Apollo 用户管理刻意收窄为「读、建、挂角色」三件事,编辑/删除/解锁路由未注册,页面按钮也对应不渲染——两页虽同名,能力集与后端路由完全不同,跨产品切换时不要套用旧操作习惯。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| 用户一览 | 全量用户表按 username 升序拉取,展示 ID(前 12 位截断)/用户名/姓名/邮箱/角色/状态/创建时间 | 页面主体用户列表 |
| 关键字过滤 | 客户端按用户名/姓名/邮箱做不区分大小写的包含匹配,输入即回到第 1 页 | 搜索框(placeholder「搜索用户名 / 姓名 / 邮箱」) |
| 客户端分页 | 每页 10 条本地切片翻页(后端返回全量,不做服务端分页) | 「上一页」/「下一页」+ page / totalPages |
| 新建用户 | 仅 username/password 两字段创建账号(bcrypt 哈希落库,用户名唯一,重复返回 409) | 页头「新建用户」按钮 → 弹窗「创建」 |
| 分配角色 | 加载全量角色勾选,「保存」时按「当前已挂 vs 目标勾选」差分逐条绑定/移除 | 「分配角色」按钮 → 弹窗「保存」 |
| 快捷移除角色 | 不进入弹窗,直接移除单条角色绑定(confirm 二次确认) | 角色标签上的「×」 |
| 锁定状态可视 | is_locked 由后端自动锁定(连续 5 次登录失败)写入,页面以徽标呈现 | 状态列「正常/已锁定」徽标 |
1.3 一句话总结
本页是「看账号、建账号、挂角色」的 LightApollo 独立账号治理控制台:只开放读/建/绑角色,刻意不提供编辑、删除、解锁与重置密码入口。
2. 访问入口
2.1 路由与菜单
| 项 | 值 |
|---|---|
| 路由 path | /apollo/admin/users |
| 路由 name | ApolloAdminUsers |
| meta.title | Apollo 用户管理 |
| meta.requiresAuth | true(未登录由前端路由守卫拦到 /apollo/login) |
| 侧边栏位置 | ApolloLayout 侧边栏「平台管理」分组第一项(分组顺序:用户管理、角色管理、API Key 管理、项目管理、审计日志、签名者白名单) |
| 前端源码 | action/web/src/views/apollo/UsersPage.vue |
| API 客户端 | action/web/src/api/apolloClient.js |
| 路由注册 | action/web/src/router/index.js,/apollo 子路由 children 下 481–487 行,component 挂 ApolloLayout |
相邻页(同属平台管理组):角色管理 admin-roles.md(后,负责定义本页可勾选的角色与权限点)、API Key 管理 admin-api-keys.md。创建角色要先去「角色管理」,再回本页做分配。
2.2 认证与权限
- 路由
requiresAuth: true;登录态为独立apollo_token(Bearer JWT),2026-09-12 修订后不再回退读取旧aip_token。401 由响应拦截器清 token 并跳/apollo/login(登录页自身 401 不跳转,避免死循环)。 - 页面访问与「读」接口需要
user:read(用户列表、查看详情),「写」接口按动作细分:POST /users需user:write,POST /users/:id/roles与DELETE /users/:id/roles/:roleId需role:write,分配弹窗加载角色列表需role:read(后端统一RequirePerm中间件鉴权)。 - 403 时响应拦截器统一
alert('无权限执行该操作'),随后页面 catch 再以「…失败:」前缀提示(一次 403 会同时看到两条提示,见 7 章)。 - 内置超管:角色
platform_admin/project_admin持有通配权限点*:*,任何权限点校验直接放行;普通用户的有效权限为其全部角色role_permissions(类型ACTION_ACCESS)中resource_name的并集。 - 页面动作与权限点映射:列表与详情读
user:read;创建用户user:write;分配/移除角色role:write;分配弹窗的候选角色列表读role:read。内置 5 角色中仅platform_admin/project_admin(*:*)默认具备这些权限点——developer/operator/viewer的 seed 权限清单不含 user/role/apikey 的任何权限点,即平台管理组三个页面(用户/角色/API Key)默认只对两类超管开放,普通开发/运维/只读角色打开本页即列表 403。需要下放管理权时,在「角色管理」建自定义角色勾选user:read/user:write/role:read/role:write即可。
2.3 端口与 API 前缀
- Apollo 后端端口 18082;Vite 开发代理把
/apollo-api前缀 rewrite 为/api转发,客户端 baseURL 为/apollo-api/v1。浏览器内实际请求如/apollo-api/v1/users→ 后端收到/api/v1/users。 - 统一响应 envelope
{code, message, data, request_id}:拦截器在code===0时把response.data解包为业务数据,页面const { data } = await apiClient.get(...)拿到的即是业务数据本身(列表页data.items/ 角色页裸数组等,见 5.3)。
3. 界面布局
+--------------------------------------------------------------------+
| 平台管理 · 用户管理 [新建用户] |
| [操作结果提示条(v-if alert.message,右侧「关闭」,四型可选)] |
+--------------------------------------------------------------------+
| ┌ 列表卡片 ──────────────────────────────────────────────────────┐ |
| │ 工具栏:[搜索用户名 / 姓名 / 邮箱] 共 N 个用户 │ |
| │ 表格列:ID | 用户名 | 姓名 | 邮箱 | 角色 | 状态 | 创建时间 | 操作 │ |
| │ · ID:等宽前 12 位 + …;用户名加粗 │ |
| │ · 姓名/邮箱:缺省显示 '-'(新建用户不填这两字段) │ |
| │ · 角色:role-tag 蓝底圆角(含「×」移除)+「未分配」灰字兜底 │ |
| │ · 状态:正常(绿)/ 已锁定(红)徽标 │ |
| │ · 创建时间:YYYY-MM-DD HH:MM:SS(RFC3339 去 T 截 19) │ |
| │ · 操作:[分配角色] │ |
| │ 空态:加载中... / 暂无用户(可调整关键字) │ |
| │ 分页(totalPages>1 才显示):上一页 | page / totalPages | 下一页 │ |
| └────────────────────────────────────────────────────────────────┘ │
+--------------------------------------------------------------------+
| ┌ 新建用户弹窗(modal-sm)───────────────────────────────────────┐ │
| │ 用户名(username)* [如 alice] 密码(password)* [初始登录密码] │ │
| │ modal-hint:后端当前仅支持 username/password 两个字段创建用户… │ │
| │ [取消] [创建/提交中...](点遮罩/「关闭」亦可关) │ │
| └────────────────────────────────────────────────────────────────┘ │
| ┌ 分配角色弹窗 ──────────────────────────────────────────────────┐ │
| │ 分配角色:{{username}} │ │
| │ 空态:暂无可用角色,请先到「角色管理」创建。 │ │
| │ 每角色一行 checkbox:role_name + (权限 N 个 / 无权限) │ │
| │ [取消] [保存/保存中...] │ │
| └────────────────────────────────────────────────────────────────┘ │
+--------------------------------------------------------------------+
各板块职责:
| 板块 | 职责 |
|---|---|
| 页头 + 操作结果提示条 | 页面标题与「新建用户」按钮;全局单槽操作结果提示(info/success/error 三型,可关闭),每次新操作覆盖旧提示 |
| 列表卡片 | 工具栏(搜索框 + 计数)、用户表格、加载/空态、客户端分页四部分组成一体卡片 |
| 新建用户弹窗 | 收集 username/password 两个必填项并提交 POST /users |
| 分配角色弹窗 | 加载全量角色并回显当前勾选,保存时差分提交增删绑定 |
4. 交互元素
4.1 页头与操作结果提示条
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 页面标题 | 页头 h2 | 页面身份 | 常驻 | 文案「平台管理 · 用户管理」 | 无 | 与源码 h2 逐字一致 |
| 「新建用户」 | 页头(btn-primary) | 打开新建用户弹窗 | 常驻可点 | openCreate() 置 createModal.visible=true,清空表单 | 无 | 不预检查权限,权限不足时在提交后被 403 |
| 操作结果提示条 | 页头下方 | 最近一次操作的结果 | 有 alert.message 才显示 | 以 success/error/info 三型展示;点「关闭」清空 | 无 | 单槽覆盖:后一次操作覆盖前一次,无历史记录 |
4.2 「新建用户」弹窗
页面加载与每次成功写操作后都会 fetchUsers();新建用户是唯一创建账号的入口,弹窗为小尺寸 modal-sm。
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 用户名(username) | 弹窗首个输入框,placeholder「如 alice」 | 登录用户名(唯一) | 必填(HTML required),提交前 JS 再判 !username.trim() | 提交后随 body {username} 发送 | POST /users | 后端 trim 后判空,空则 400「用户名与密码不能为空」;重复 409「用户名已存在」 |
| 密码(password) | 第二个输入框,type=password,placeholder「初始登录密码」 | 初始登录密码 | 必填(required) | 提交后随 body {password} 发送 | 同上 | 前端只判非空、无最小长度;后端 bcrypt(DefaultCost)哈希落库,不落明文 |
| modal-hint | 弹窗内提示条 | 说明字段边界 | 常驻 | 文案「后端当前仅支持 username/password 两个字段创建用户(姓名/邮箱留空)。」 | 无 | 逐字与源码一致;解释为何姓名/邮箱列恒 '-' |
| 「取消」 | 弹窗底部(btn-outline) | 关闭弹窗 | 常驻 | visible=false | 无 | 不校验、不提交 |
| 「关闭」/ 遮罩 | 弹窗右上/遮罩层 | 同取消 | 常驻 | 同取消;遮罩 @click.self | 无 | 三处关闭途径等价 |
| 「创建」 | 底部提交(btn-primary) | 提交新建 | creating=false 时可用;提交中禁用并显示「提交中...」 | 成功:弹窗关闭、提示 用户 "{username}" 创建成功(success)、自动 fetchUsers() | POST /users | 失败:弹窗保持打开,提示「创建用户失败:{后端错误}」 |
创建成功后后端返回 201,data 为 {id, username};提示文案把 data.username 反填进消息。注意创建动作不自动分配角色——新账号默认无角色,直到在行内点「分配角色」。
4.3 搜索框(客户端关键字过滤)
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 搜索框 | 工具栏左侧(form-input search-input) | 列表关键字过滤 | 选填;placeholder「搜索用户名 / 姓名 / 邮箱」 | @input 把 page 重置为 1;filteredUsers 对用户名/姓名/邮箱三字段做 toLowerCase().includes(kw) | 无(纯前端,基于已拉取全量) | 关键字 trim + 小写;空关键字恢复全量;过滤计数显示「共 N 个用户」 |
4.4 用户列表表格
| 列/元素 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| ID | 首列(mono 等宽) | 用户主键 | 由后端 uuid 生成 | shortId 取前 12 位 +「…」;空显示 '-' | 无 | 全 id 展示需看详情接口(本页无此入口) |
| 用户名 | 第二列加粗 | 登录名(排序键) | - | 纯展示 | 无 | 列表按 username 升序 |
| 姓名 | 第三列 | 用户姓名 | 后端仅两字段建号 | 显示 name || '-' | 无 | 页面无入口设置,通常恒 '-' |
| 邮箱 | 第四列 | 用户邮箱 | 同上 | 显示 email || '-' | 无 | 通常恒 '-' |
| 角色 | 第五列 | 角色标签组 | 来自 roles[] | 每个角色一个蓝底圆角 role-tag(role_name + 右上「×」);无角色显示灰字「未分配」 | 无(数据随列表返回) | 移除按钮见 4.7 |
| 状态 | 第六列 | 锁定状态 | is_locked | true→红底「已锁定」,false→绿底「正常」 | 无 | 状态仅由登录失败锁定逻辑/直接改库翻转,本页不可改 |
| 创建时间 | 第七列 | 创建时间 | created_at | formatTime:RFC3339 串去 T 截前 19 位(如 2026-09-07 04:59:00);空 '-' | 无 | 不转浏览器本地时区 |
| 「分配角色」 | 第八列(btn-sm btn-outline) | 打开角色分配弹窗 | 常驻可点(saving 只禁「×」不阻此钮) | 见 4.5 | 弹窗打开时 GET /roles | 见 4.5 |
| 加载/空态 | 表格区 | 状态反馈 | loading 时「加载中...」;空列表「暂无用户(可调整关键字)」 | 纯提示 | 无 | 空态文案随是否有关键字变化 |
字段兼容说明:列表项按后端 userView 序列化字段读取(id/username/name/email/is_locked/created_at/roles 全小写),但 Vue 里统一经 uId/uUsername/uName/uEmail/uIsLocked/uCreatedAt/uRoles 六个兼容函数取值,|| 兜底旧接口的 PascalCase 变体(ID/Username/...),防止换后端后整表空渲染。
4.5 「分配角色」按钮与弹窗
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「分配角色」 | 行末操作列 | 打开该用户的角色分配弹窗 | 常驻 | openAssignRoles(u):置弹窗可见,标题「分配角色:{{username}}」,把当前已挂角色 id 预勾进 selected,并 GET /roles 填充候选 | 弹窗打开即 GET /roles | 候选失败:提示「加载角色列表失败:{错误}」,弹窗仍打开且候选为空 |
| 角色勾选行 | 弹窗 body | 每个候选角色一个 checkbox | 候选 roles.length===0 时改显 alert 文案「暂无可用角色,请先到「角色管理」创建。」 | v-model 双向维护 selected(勾选/取消即时反映,不立即提交) | 无 | 勾选项展示 role_name 加粗 + 灰色 (N 个权限/无权限),N 为该角色 ACTION_ACCESS 权限点计数(permCountText) |
| 「取消」/「关闭」/遮罩 | 弹窗 | 放弃修改关闭 | 常驻 | visible=false,勾选改动丢弃 | 无 | 关闭后不刷新列表(服务端未变) |
| 「保存」 | 弹窗底部(btn-primary) | 提交差分增删 | saving=false;提交中禁用并显示「保存中...」 | 见 4.6 | 逐条 POST/DELETE 绑定接口 | 无勾选变化时点击直接关闭弹窗(见 4.6) |
候选列表来自 GET /roles(返回裸数组);角色是全局定义(无项目/空间区分),因此分配动作不做任何「项目范围」校验(projectScopeAccessGuard 只作用于 /projects/:id/... 路径,见 5.4)。
4.6 「保存」差分计算与提交
这是本页最值得说清的一步:前端不做整表覆盖,而是按差分发幂等请求。
handleAssignRoles() 逻辑:① 从本地 users 数组里按 userId 找到该用户,取其当前角色 id 集合 current;② 取勾选目标集合 target;③ toAdd = 候选里「current 没有且 target 有」的角色,toRemove = 候选里「current 有且 target 没有」的角色;④ 若二者都为空,只关弹窗、不发任何请求、不提示;⑤ 否则 saving=true,toAdd 逐个 POST /users/:id/roles(body {role_id},后端幂等),toRemove 逐个 DELETE /users/:id/roles/:roleId,全部成功提示 角色已更新:新增 N 个,移除 N 个(success)、关弹窗、fetchUsers()。
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「保存」提交 | 分配弹窗 | 落库勾选变化 | 见上 | 成功后提示「角色已更新:新增 N 个,移除 N 个」,自动刷新列表 | 逐条绑定/解绑 | 失败提示「保存角色失败(部分变更可能已生效,已按服务端状态刷新):{错误}」,已修复(本批):catch 分支重拉列表并按服务端真实结果对齐弹窗勾选、弹窗保持打开(非事务逐条请求仍属后端接口粒度边界,见 8 章) |
关键约束:差分基准是本地列表快照,不是服务端实时态。若两个管理员同时改同一账号、或上次保存部分失败后未刷新列表,本次差分基于的 current 可能过时——重按「保存」时对已成功绑定过的角色会再次发幂等 POST(后端 OnConflict DoNothing,不报错),对已被他人移除的角色再发 DELETE 会拿到 404「用户角色绑定不存在」而整体提示失败。多写操作场景下以服务端最终态为准,必要时整页刷新后再分配。
4.7 「×」快捷移除角色
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「×」 | 角色标签 role-tag 内右上 | 移除该角色绑定 | saving=false 时可点(title="移除角色") | 先 confirm('确定移除用户 "{用户名}" 的角色吗?'),确认后发请求 | DELETE /users/:id/roles/:roleId | 成功:提示「角色已移除」并刷新列表;失败:「移除角色失败:{错误}」;取消 confirm 则无操作 |
该按钮不进入弹窗,是「单角色快速解绑」的快捷路径;对每个已挂角色都有独立「×」。解绑不影响该角色本身,也不影响该用户的其他角色。
4.8 分页控件
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「上一页」 | 分页栏左(btn-sm btn-outline) | 回上一页 | page>1 才可点(越界禁用) | page-- | 无 | 页码变化仅影响切片显示 |
| 页码信息 | 分页栏中 | 当前/总页数 | 常驻 | 文案 {{page}} / {{totalPages}} | 无 | totalPages = max(1, ceil(N/10)) |
| 「下一页」 | 分页栏右 | 下一页 | page<totalPages 才可点 | page++ | 无 | 过滤/翻页全程不触发后端请求 |
分页在客户端做:后端一次性返回全部用户(见 5.2),页面按 PAGE_SIZE=10 切片。工具栏计数 共 {{ filteredUsers.length }} 个用户 表示过滤后的数量。分页栏仅在 totalPages > 1 时渲染。
4.9 忙状态互斥(saving / creating / loading)
页面用三个布尔互斥位控制并发,任何进行中的写操作都会禁用相关按钮,防止重复提交:
| 状态位 | 置位时机 | 影响范围 | 复位时机 | 边界与细节 |
|---|---|---|---|---|
creating | 「创建」提交时 | 新建弹窗提交钮禁用并显示「提交中...」 | 该次 POST /users 成功/失败后 | 期间关闭弹窗/取消不受限 |
saving | 「保存」或「×」移除发起时 | 全表所有角色「×」禁用、分配弹窗保存钮禁用并显示「保存中...」 | 该次全部绑定/解绑请求结束 | 全局互斥:一个用户的保存进行中,其它行「×」也点不动,避免并发差分 |
loading | fetchUsers 发起时 | 表格区显示「加载中...」,隐藏表格 | 拉取结束 | 首屏与每次写成功后置位 |
互斥是按钮级而非路由级:saving 期间「分配角色」按钮与弹窗开关、搜索、翻页都不受影响;真正的并发防护依赖后端接口幂等/校验(见 5.4)。
5. 后端关联
5.1 API 客户端
action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、请求拦截器统一附 Authorization: Bearer <token>(仅取 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 envelope code===0 时把 response.data 解包为业务数据;HTTP 2xx 但 code!==0 拒绝;4xx/5xx 提取 body.message || body.error 写入 error.response.data.error 后 reject——401 清 token 并跳 /apollo/login,403 alert('无权限执行该操作')。页面 catch 统一用 err.response?.data?.error || err.message 取后端文案拼进「…失败:」提示。
5.2 端点表
| 方法 | 路径 | 权限点 | 请求体/Query | 页面触发点 |
|---|---|---|---|---|
| GET | /users | user:read | 可选 ?page=&page_size=(默认 1/20,上限 100;页面不带参) | 列表加载与每次成功写操作后刷新 |
| GET | /users/:id | user:read | - | 无页面入口(仅后端能力) |
| POST | /users | user:write | body {username, password} | 「新建用户」弹窗「创建」 |
| POST | /users/:id/roles | role:write | body {role_id: <uint>} | 分配弹窗「保存」toAdd 循环 |
| DELETE | /users/:id/roles/:roleId | role:write | - | 分配弹窗 toRemove 循环 / 「×」 |
| GET | /roles | role:read | - | 分配弹窗打开时加载候选 |
路由注册于 action/products/apollo/server/server.go 810–815 行(/api/v1 protected 组,先经 access.Authn 再逐条 RequirePerm)。projectScopeAccessGuard 只对 /api/v1/projects/:id... 生效,不约束本页端点。
5.3 响应结构示例
GET /users(envelope code===0 解包后 data 为分页结构,页面取 data.items):
{
"code": 0,
"message": "ok",
"request_id": "8f1c2a9e-...",
"data": {
"items": [
{
"id": "3c0f0a1e-2b9d-4e6f-8a2b-5d7c1e0a9f3b",
"username": "alice",
"name": "",
"email": "",
"is_locked": false,
"created_at": "2026-09-07T04:59:00+08:00",
"roles": [
{
"id": 2,
"role_name": "developer",
"permissions": [
{ "id": 3, "role_id": 2, "permission_type": "ACTION_ACCESS", "resource_name": "deployment:read" },
{ "id": 4, "role_id": 2, "permission_type": "ACTION_ACCESS", "resource_name": "agent:read" }
]
}
]
},
{ "id": "7f2b...", "username": "admin", "name": "Administrator", "email": "admin@zyinfo.pro", "is_locked": false, "created_at": "2026-08-01T10:00:00+08:00", "roles": [ { "id": 1, "role_name": "platform_admin", "permissions": [ { "id": 1, "role_id": 1, "permission_type": "ACTION_ACCESS", "resource_name": "*:*" } ] } ] }
],
"total": 2,
"page": 1,
"page_size": 20
}
}
字段含义(userView 安全视图,toUserView 生成):id 用户 uuid 主键;username 登录名(表内唯一);name/email 可选(omitempty,未设置不出现,前端以 - 兜底);is_locked 是否被锁定;created_at 创建时间;roles 该用户全部角色(Preload 关联,每项为 model.Role:id、role_name、permissions[])。安全视图刻意剔除 HashedPassword、FailedLoginAttempts 等敏感字段——前端拿不到密码哈希,列表接口无泄漏面。
POST /users(201 Created):
{ "code": 0, "message": "ok", "request_id": "…", "data": { "id": "3c0f0a1e-…", "username": "alice" } }
GET /roles(envelope 解包后为裸数组,区别于 /users 的 {items,...}):
[
{ "id": 1, "role_name": "platform_admin", "permissions": [ { "id": 1, "role_id": 1, "permission_type": "ACTION_ACCESS", "resource_name": "*:*" } ] },
{ "id": 2, "role_name": "developer", "permissions": [ … ] }
]
POST /users/:id/roles(200):data = {"user_id":"3c0f…","role_id":2};若 body 里 role_id 缺省/为 0,返回 400(code:40001,message「role_id 不能为空」)。DELETE /users/:id/roles/:roleId(200):data = {"user_id":"3c0f…","role_id":2,"deleted":true};无绑定记录返回 404(code:40401 类,message「用户角色绑定不存在」)。
错误示例(409 用户名已存在):HTTP 409,body {"code":40901,"message":"用户名已存在","request_id":"…"}(statusToCode 映射见 api.go)。前端经拦截器把 message 转写进 err.response.data.error,页面提示「创建用户失败:用户名已存在」。
5.4 关键机制
认证与权限点匹配。全部本页端点挂在 /api/v1 的 protected 组:先 access.Authn(Bearer JWT 或 X-API-Key,见 middleware.go;前端 apolloClient 仅取 apollo_token),再从 principal 解析有效权限集合,RequirePerm 做包含匹配——命中即放行,*:* 通配全部。非超管用户的有效权限 = 其全部角色的 role_permissions(permission_type='ACTION_ACCESS')中 resource_name 的并集(resolvePermissions 还支持「跨产品 JWT 的 user id 在本库不存在时,用 username(sub,签名防篡改)反查本库同名用户」兜底)。把「分配角色」挂在 role:write 而非 user:write 是刻意的权限点边界:绑定/解绑语义属于「角色资源的授权管理」,因此只发 user:read/user:write 的账号无法执行分配(403),想要分配能力的角色须同时含 role:write(内置 platform_admin/project_admin 因 *:* 天然具备)。
envelope 与前端解析差异。成功响应统一 envelope;okPage 把列表包装成 {items,total,page,page_size},而 ok 直接把数组/对象放进 data——这就是 GET /users 页面读 data.items、GET /roles 页面用 Array.isArray(data) 判断的原因。handleListUsers 虽支持 ?page=&page_size=,但服务端每次都返回全量(ListUsers 无 LIMIT),前端拿全量后再过滤/切片,PAGE_SIZE=10 仅是显示切片。用户量极大时首屏与每次刷新都会传整表(见 8 章)。
列表数据血缘。ListUsers 用 Preload("Roles").Order("username") 一次取出全部用户并带角色;userView 小写序列化;前端兼容层兜底 PascalCase。表格里的角色标签、权限计数都来自这个 roles[]——也就是创建用户在列表刷新前看不到自己、分配角色后行内角色标签立即更新都依赖每次成功写操作后的 fetchUsers() 全量重拉。
写操作语义:幂等绑定 vs 404 解绑。AssignRoleToUser 先查用户/角色存在(用户不存在/角色不存在),再用 ON CONFLICT (user_id,role_id) DO NOTHING 创建关联——重复绑定不报错;RemoveRoleFromUser 直接 Delete 并按 RowsAffected==0 判 404「用户角色绑定不存在」→ 不可重复解绑。前端差分算法(4.6)配合这套语义:toAdd 循环幂等、toRemove 循环要求目标确实存在。
审计。根路由组挂了 httpAuditMiddleware(http_audit.go):对全部 API(含本页端点)异步落 apollo_audit_logs。请求体(JSON/YAML/text 类)与查询串入库前脱敏:password/token/secret/api_key 等键值替换为 ***,>2KB 截断;action = METHOD path(如 POST /api/v1/users);resource_type = 路径首段(users/roles);路径含 :id 参数时记录 resource_id;结果判定 2xx→success、401/403→denied、其余 4xx/5xx→failure。创建用户的密码字段在审计里永远以 *** 呈现,不落明文。探针与 /docs 噪声路径跳过。
锁定状态(is_locked)来源。platform/auth 登录逻辑(auth_service.go):普通账号连续 5 次登录失败、admin 语义账号(platform_admin 等)连续 10 次失败 → IsLocked=true 并记 ACCOUNT_LOCKOUT 审计(admin 锁定额外输出安全告警日志,见 auth_service.go:134-153)。差异化在于 admin 锁定后仍可用正确密码登录并自动解锁(:166-175),普通用户锁定期间连正确密码也被拒、只能改库恢复;而本页没有解锁/重置密码入口(无对应路由)——这是账号安全与可用性之间的已知缺口(见 8 章)。
权限匹配语义(Authorize):Authorize 对 principal 的有效权限集合做 glob 匹配(access.PermissionMatch),命中形态有三档——集合里存在与目标 resource:action 完全相等的权限点、存在 resource:* 类资源前缀通配(授出 user:* 可同时命中 user:read/user:write)、或存在全集通配 *:*(超管,Authorize 先判 IsSuper 直通,不做逐条扫描)。推论:在「角色管理」页里只勾 user:read 不会顺带获得 user:write——因为页面权限网格只下发单个权限点,不提供 user:* 中间档,二者必须分别勾选;但若有人绕过页面经 POST /roles 给角色写了一条 user:*,则该角色覆盖 user 域全部动作。授权面能否按 read/write 拆分,取决于下发的是单点还是资源前缀通配,本页网格可保证的最小粒度是单点。platform_admin 与 project_admin 的 *:* 之所以能覆盖本页全部动作,正是这条「全集通配」判定;给角色手工加 *:* 需谨慎——那等于把账号层全部读写权交出去,操作会落审计,可在审计页反查授权者与授权时间。
6. 权限与安全
- 认证分层:页面访问、列表读、写操作全部要求登录(Bearer);未登录跳登录页,token 过期由拦截器统一清态重登。
- 权限点:读
user:read/role:read,写user:write/role:write;无权限时 403 且拦截器alert('无权限执行该操作')。页面入口不按权限隐藏——无user:read的用户照样能打开本页,只是接口全 403,提示以「…失败」展示。 - 敏感字段不外泄:列表安全视图不含密码哈希/失败次数;审计中密码以
***脱敏;页面永远拿不到可逆的凭据。 - 写操作防护:唯一写操作创建账号与角色绑定/解绑,均有权限点约束;解绑走 confirm 二次确认;
saving/creating互斥位防连点并发。 - 超管语义:
platform_admin/project_admin通过*:*获得本页全部能力;给普通账号授超管角色的入口在「角色管理/分配角色」链路,操作会落审计,可按审计页追溯授权者。
7. 常见问题与排错
以下现象均由 UsersPage.vue 各 catch 分支提示与后端 handler/service 代码归纳,均可按步骤复现或溯源。
- 打开页面列表空白并提示「加载用户列表失败:无权限执行该操作」:原因是当前账号无
user:read权限点(403)。处理:换platform_admin/project_admin账号或让超管先在「角色管理」为该账号补user:read;403 时拦截器会先弹「无权限执行该操作」,这是同一次失败的两条提示,属预期。
- 点「新建用户」提示「创建用户失败:用户名已存在」:原因是该用户名已在 Apollo 用户库存在(用户名唯一)。处理:换用户名;注意 Apollo 是独立用户库,与 AIP/Foundry 的账号不互通,登录过 AIP 不代表 Apollo 有同名用户。
- 新建用户成功后列表里姓名/邮箱是「-」:原因是后端
createUserRequest只有 username/password 两个字段(弹窗内 modal-hint 已说明),姓名/邮箱无入口可填。处理:属已知边界(见 8 章),不影响登录与授权;如需姓名/邮箱展示需后端扩展字段并同步前端。
- 分配角色弹窗空白,提示「暂无可用角色,请先到「角色管理」创建。」:原因是
GET /roles返回空数组(角色表为空)。处理:先到「角色管理」页创建角色(或确认 seed 未跑导致内置角色缺失);候选加载失败则提示「加载角色列表失败:{错误}」,核对 18082 与 token。
- 点「保存」报「保存角色失败(部分变更可能已生效,已按服务端状态刷新):用户不存在/角色不存在」:原因通常是弹窗打开期间目标账号被删、或差分基准过期(本地列表快照 vs 服务端不一致)。处理:本批起失败会自动重拉列表并把弹窗勾选对齐到服务端真实结果(
UsersPage.vue:301-315),确认提示后按刷新后的勾选基线重试即可;绑定接口幂等,重按保存不会重复加角色。
- 点「×」移除角色失败,提示「移除角色失败:用户角色绑定不存在」:原因是该绑定已在别处(另一会话/另一管理员)被移除,或当前列表是旧数据。处理:刷新列表确认现状;DELETE 语义要求绑定真实存在,重复移除必 404。
- 有
user:write却无法分配角色(403):原因是绑定走的是role:write权限点,不是user:write。处理:到「角色管理」给该账号所在角色补role:write,或用内置超管角色执行。
- 状态列显示「已锁定」但想恢复登录:原因是连续登录失败触发自动锁定(普通账号 5 次、admin 语义账号 10 次),而本页及全站均无解锁/重置密码入口。处理:由管理员直接改 Apollo 用户库把
is_locked置回 0(并清failed_login_attempts),或让该账号持有 admin 语义角色后用正确密码登录自动解锁;这是已知缺口(见 8 章)。
- 搜索框输入中文查不到预期用户:原因是过滤对三字段做小写包含匹配,中文按子串匹配本身可用,但姓名/邮箱通常为空无法命中——用「用户名」片段搜最稳。处理:确认关键字与任一列文本一致;关键字过滤纯前端,不改服务端查询。
- 列表出现「…」截断的 ID,想拿完整 ID:原因是
shortId只显示前 12 位。处理:本页无详情入口;如需完整 id 直接调GET /api/v1/users(受保护,需 token)看原始返回,或在浏览器 Network 里查看/users响应体。
- 按钮停在「提交中...」/「保存中...」长时间不动:原因是
creating/saving互斥位在请求返回前保持禁用态(防连点重复提交),若后端未返回(网络断/超时 30s)会一直转。处理:看 Network 面板该请求是否挂起,超时或报错后互斥位自动复位即可继续操作;属前端互斥保护而非死锁。
- 刷新页面后刚才勾选的角色又变回旧值:原因是弹窗勾选只是本地状态,未点「保存」不会发任何请求,刷新即丢弃。处理:改动后必须点「保存」并确认出现「角色已更新:新增 N 个,移除 N 个」提示;未保存就离开是预期丢弃,不会产生脏数据。
8. 已知缺陷与边界
| 缺陷/边界 | 说明 |
|---|---|
| 无编辑/删除/重置密码/解锁能力 | 后端仅注册了读、建、挂角色三类路由;permissions.go 权限目录虽预列 PUT/DELETE /users/:id,但 server.go 未注册对应 handler,页面相应不渲染任何此类按钮。创建后账号信息不可改、密码不可重置,被锁账号无法在本页解锁 |
| 锁定即「死锁」风险 | 现实边界(后端缺口,非前端可修):server.go 未注册任何解锁/重置密码路由(action/products/apollo/server/server.go:828-832 仅 users 读/建/挂角色),页面也无入口;锁定逻辑在 action/platform/auth/auth_service.go:142-153(普通用户连续 5 次失败、admin 10 次即 LockUserAccount);admin 账号凭正确密码可在 :166-175 自动解锁,普通用户锁定期间连正确密码也被拒,只能改库把 is_locked 置 0 并清 failed_login_attempts 恢复。需后端先补解锁端点 |
| 仅两字段创建 | 创建时姓名/邮箱恒空、无扩展字段;此后无编辑入口,信息不可补全。属后端契约限制,非前端可独立修复 |
| 客户端分页/过滤 | 现实边界(需后端改造,非纯前端可修):GET /users 服务端全量返回——handleListUsers 虽读 parsePage(c) 却把全量 views 交给 okPage,而 okPage 不做切片(action/products/apollo/server/handlers.go:1454-1466、action/products/apollo/server/api.go:74-81),服务层 ListUsers(ctx) 亦无 limit/offset 参数(action/products/apollo/access/access_service.go:261-267);前端 10 条/页切片仅渲染层,用户规模大时首屏加载与每次写后整表刷新开销大。需后端 ListUsers 支持 limit/offset 后前端改传 page/page_size |
| 差分保存非事务 | 本批已修复:分配弹窗「保存」仍逐条发请求(后端无整批替换接口),但 catch 分支已改为重拉列表并按服务端真实结果对齐 roleModal.selected,错误提示如实说明「部分变更可能已生效,已按服务端状态刷新」(action/web/src/views/apollo/UsersPage.vue:301-315)——失败后本地快照不再失真,用户再点保存不会基于错误基线做差分。逐条非事务仍属后端接口粒度边界 |
| 差分基准是本地快照 | 多管理员并发改同一账号时基于过期快照的差分可能发多余/缺失请求(幂等绑定可容忍、DELETE 会 404),最终以服务端为准 |
| 无锁定自动刷新 | 锁定状态只随列表刷新更新,页面无轮询/手动刷新按钮;他人 5 次失败把账号锁了,本页不会实时翻红 |
| 时间展示为 UTC/原串 | 创建时间仅做「去 T 截 19 位」的字符串处理,不转本地时区,跨时区展示有偏差 |
| 权限点目录与路由漂移 | permissions.go 权限目录含 PUT/DELETE /users/:id、PUT/DELETE /roles/:id 与部分路径(如 /audit/logs),与 server.go 实际注册路由不一致,是文档/权限清单的历史残留,读代码时以 server.go 注册为准 |
| 入口不按权限隐藏 | 无 user:read 的用户也能打开页面,仅靠接口 403 兜底(用户体验一般,未见越权风险) |
与 AIP /admin/users(UsersPage.vue)的差异对照
两页同名「用户管理」,但分属两套产品/后端,能力集差异很大,跨产品切换前先看下表:
| 维度 | LightApollo 用户管理(本页) | AIP /admin/users(AIP UsersPage.vue) |
|---|---|---|
| 后端路由 | 读 user:read / 建 user:write / 绑角色 role:write,无改/删/锁/重置密码路由 | user CRUD + lock/unlock + reset-password(requiresAdmin 的 /admin 组) |
| 创建字段 | 仅 username/password 两字段 | username/password/email/name 四字段 |
| 编辑资料 | 无入口(姓名/邮箱创建后不可补) | 支持编辑邮箱、姓名 |
| 删除用户 | 无入口(路由未注册) | 支持删除;当前登录用户所在行隐藏删除钮 |
| 锁定/解锁 | 仅状态徽标展示(无操作按钮) | 行内「锁定/解锁」切换(PUT /users/:id/lock) |
| 重置密码 | 无入口 | 行内「重置密码」弹窗(POST /users/:id/password) |
| 角色分配 | 全局角色勾选 + 差分保存 + 行内「×」快捷移除(role:write) | 独立「分配角色」弹窗,勾选即存(assign/remove 单条接口) |
| 分页 | 客户端 10 条/页切片 + 关键字过滤(后端全量返回) | 客户端 10/20/50/100 条切片分页(后端同样全量返回) |
| 认证 | 独立 apollo_token(Apollo 独立登录) | aip_token(AIP 登录) |
对已熟悉 AIP 管理后台的读者,最需要记住的三点差异:Apollo 页不能删除/锁定/重置密码(不提供按钮且后端无路由);创建用户只有账号与密码;分配角色走差分批量保存而非单勾即存。
附录 A:页面文案与关键常量速查
附录 A:页面文案与关键常量速查
| 场景 | 源码取值/文案(逐字) |
|---|---|
| 页面标题 | 平台管理 · 用户管理 |
| 按钮 | 新建用户 / 分配角色 / 创建 / 提交中... / 保存 / 保存中... / 取消 / 关闭 / 上一页 / 下一页 |
| 搜索 placeholder | 搜索用户名 / 姓名 / 邮箱 |
| 用户数计数 | 共 {{ filteredUsers.length }} 个用户 |
| 空态 | 加载中... / 暂无用户{{ keyword ? '(可调整关键字)' : '' }} |
| 新建字段 label | 用户名(username)* / 密码(password)* |
| 新建 placeholder | 如 alice / 初始登录密码 |
| modal-hint | 后端当前仅支持 username/password 两个字段创建用户(姓名/邮箱留空)。 |
| 分配弹窗标题 | 分配角色:{{ roleModal.username }} |
| 分配空态 | 暂无可用角色,请先到「角色管理」创建。 |
| 角色权限计数 | {{ permCountText(r) }} → N 个权限 / 无权限 |
| 无角色兜底 | 未分配 |
| 状态徽标 | 正常(绿)/ 已锁定(红) |
| 创建成功 | 用户 "{{data.username}}" 创建成功 |
| 分配成功 | 角色已更新:新增 {{toAdd.length}} 个,移除 {{toRemove.length}} 个 |
| 移除成功 | 角色已移除 |
| 移除 confirm | 确定移除用户 "{{uUsername(u)}}" 的角色吗? |
| 失败提示前缀 | 创建用户失败: / 加载用户列表失败: / 加载角色列表失败: / 保存角色失败(部分变更可能已生效,已按服务端状态刷新): / 移除角色失败: |
| 后端错误(可溯源) | 用户名与密码不能为空(400) / 用户名已存在(409) / 用户不存在(404) / 角色不存在(404) / role_id 不能为空(400) / 用户角色绑定不存在(404) |
| 分页 | PAGE_SIZE=10,{{page}} / {{totalPages}},totalPages = max(1, ceil(N/10)) |
| ID 截断 | shortId 前 12 位 + … |