> 所属产品: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 /usersPOST/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_adminproject_admindeveloperoperatorviewer)并把 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 认证与权限

2.3 端口与 API 前缀

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「搜索用户名 / 姓名 / 邮箱」 @inputpage 重置为 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=truetoAdd 逐个 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.Roleidrole_namepermissions[])。安全视图刻意剔除 HashedPasswordFailedLoginAttempts 等敏感字段——前端拿不到密码哈希,列表接口无泄漏面。

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_permissionspermission_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.itemsGET /roles 页面用 Array.isArray(data) 判断的原因。handleListUsers 虽支持 ?page=&page_size=,但服务端每次都返回全量ListUsers 无 LIMIT),前端拿全量后再过滤/切片,PAGE_SIZE=10 仅是显示切片。用户量极大时首屏与每次刷新都会传整表(见 8 章)。

列表数据血缘ListUsersPreload("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 循环要求目标确实存在。

审计。根路由组挂了 httpAuditMiddlewarehttp_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_adminproject_admin*:* 之所以能覆盖本页全部动作,正是这条「全集通配」判定;给角色手工加 *:* 需谨慎——那等于把账号层全部读写权交出去,操作会落审计,可在审计页反查授权者与授权时间。

6. 权限与安全

7. 常见问题与排错

以下现象均由 UsersPage.vue 各 catch 分支提示与后端 handler/service 代码归纳,均可按步骤复现或溯源。

  1. 打开页面列表空白并提示「加载用户列表失败:无权限执行该操作」:原因是当前账号无 user:read 权限点(403)。处理:换 platform_admin/project_admin 账号或让超管先在「角色管理」为该账号补 user:read;403 时拦截器会先弹「无权限执行该操作」,这是同一次失败的两条提示,属预期。
  1. 点「新建用户」提示「创建用户失败:用户名已存在」:原因是该用户名已在 Apollo 用户库存在(用户名唯一)。处理:换用户名;注意 Apollo 是独立用户库,与 AIP/Foundry 的账号不互通,登录过 AIP 不代表 Apollo 有同名用户。
  1. 新建用户成功后列表里姓名/邮箱是「-」:原因是后端 createUserRequest 只有 username/password 两个字段(弹窗内 modal-hint 已说明),姓名/邮箱无入口可填。处理:属已知边界(见 8 章),不影响登录与授权;如需姓名/邮箱展示需后端扩展字段并同步前端。
  1. 分配角色弹窗空白,提示「暂无可用角色,请先到「角色管理」创建。」:原因是 GET /roles 返回空数组(角色表为空)。处理:先到「角色管理」页创建角色(或确认 seed 未跑导致内置角色缺失);候选加载失败则提示「加载角色列表失败:{错误}」,核对 18082 与 token。
  1. 点「保存」报「保存角色失败(部分变更可能已生效,已按服务端状态刷新):用户不存在/角色不存在」:原因通常是弹窗打开期间目标账号被删、或差分基准过期(本地列表快照 vs 服务端不一致)。处理:本批起失败会自动重拉列表并把弹窗勾选对齐到服务端真实结果(UsersPage.vue:301-315),确认提示后按刷新后的勾选基线重试即可;绑定接口幂等,重按保存不会重复加角色。
  1. 点「×」移除角色失败,提示「移除角色失败:用户角色绑定不存在」:原因是该绑定已在别处(另一会话/另一管理员)被移除,或当前列表是旧数据。处理:刷新列表确认现状;DELETE 语义要求绑定真实存在,重复移除必 404。
  1. user:write 却无法分配角色(403):原因是绑定走的是 role:write 权限点,不是 user:write。处理:到「角色管理」给该账号所在角色补 role:write,或用内置超管角色执行。
  1. 状态列显示「已锁定」但想恢复登录:原因是连续登录失败触发自动锁定(普通账号 5 次、admin 语义账号 10 次),而本页及全站均无解锁/重置密码入口。处理:由管理员直接改 Apollo 用户库把 is_locked 置回 0(并清 failed_login_attempts),或让该账号持有 admin 语义角色后用正确密码登录自动解锁;这是已知缺口(见 8 章)。
  1. 搜索框输入中文查不到预期用户:原因是过滤对三字段做小写包含匹配,中文按子串匹配本身可用,但姓名/邮箱通常为空无法命中——用「用户名」片段搜最稳。处理:确认关键字与任一列文本一致;关键字过滤纯前端,不改服务端查询。
  1. 列表出现「…」截断的 ID,想拿完整 ID:原因是 shortId 只显示前 12 位。处理:本页无详情入口;如需完整 id 直接调 GET /api/v1/users(受保护,需 token)看原始返回,或在浏览器 Network 里查看 /users 响应体。
  1. 按钮停在「提交中...」/「保存中...」长时间不动:原因是 creating/saving 互斥位在请求返回前保持禁用态(防连点重复提交),若后端未返回(网络断/超时 30s)会一直转。处理:看 Network 面板该请求是否挂起,超时或报错后互斥位自动复位即可继续操作;属前端互斥保护而非死锁。
  1. 刷新页面后刚才勾选的角色又变回旧值:原因是弹窗勾选只是本地状态,未点「保存」不会发任何请求,刷新即丢弃。处理:改动后必须点「保存」并确认出现「角色已更新:新增 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-1466action/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/:idPUT/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 位 +