> 所属产品:LightApollo · 信息截止:2026-09-07(deep-rev);2026-09-13 补充列表加载失败独立错误态
1. 页面概览
1.1 是什么
「API Key 管理」页(对应前端源码 action/web/src/views/apollo/ApiKeysPage.vue,页面内标题「Apollo API Key 管理」)是 LightApollo 的长期凭据签发与管理页:为 CI/CD 流水线、Spoke Agent、脚本等「无人值守调用方」签发独立于登录会话的 API Key,并提供列表、状态标识、一次性明文展示与撤销。页面位于「平台管理」分组的第三项(用户管理、角色管理之后),与「角色管理」页共用同一套 RBAC 底座:角色页定义权限点、用户页把人绑到角色,而本页把某个用户的权限以「机器钥匙」形式交给程序——Key 的权限继承其所属用户的角色权限,请求带 X-API-Key 头即可替代登录态 JWT 调用受保护接口(Authn 中间件按 lap_ 前缀识别)。
后端是 TAD-11 批次 2 的一部分,端点为三组 protected 路由:GET /api-keys(列表)、POST /api-keys(创建)、DELETE /api-keys/:id(撤销),外加页面初始化要用的 GET /me(当前登录主体)。页面交互的全部分支都围绕一个安全事实展开:明文 Key 只在创建成功的这一次响应里出现,之后数据库只存 SHA256 哈希与前 10 位前缀,谁也看不到第二次——所以「新建后立即复制保存」是本页唯一的操作纪律。
从「输入→输出」看:输入是管理员填写的所属用户(user_id,默认取当前登录用户)、可选的名称与过期时间;输出是 POST /api-keys 对 apollo_api_keys 表的写操作(生成 lap_ + 32 位十六进制明文、落库仅哈希与前缀),以及创建成功后一次性的 {key, prefix} 响应;读操作 GET /api-keys 拉全量 Key 行(不含哈希/明文)。撤销走 DELETE /api-keys/:id 物理删除该行,之后携带该 Key 的请求立刻 401。本页无任何对 Key 的编辑/续期/重新显示入口——Key 生命周期只有「创建 → 使用 → 过期/撤销」三段,且后两段均不可逆。
一个账号可以拥有任意多把 Key:apollo_api_keys 对 (user_id, name) 没有唯一约束,同名 Key 可反复创建,彼此只靠自增 id 与 key_prefix 区分。这为「轮转」提供了基础——实践中「先建新 Key、切换调用方、再撤销旧 Key」三步即可完成无感替换,而撤销旧 Key 不影响新 Key。本页不提供批量操作,每把 Key 都要独立创建与撤销;批量轮转只能靠脚本直调三个端点完成。
一个值得注意的实现细节:本页对应的表是 apollo_api_keys(带 apollo_ 前缀),而不是通用命名 api_keys——因为 Apollo 与 Foundry 在同一数据库(chatbi_action_dev)下共存,Foundry 侧已有一张列集不同的 api_keys 表(含 scopes/is_active),若同名共库会造成两版数据互相可见、Foundry 撤销的 Key 在 Apollo 仍有效这类认证安全隐患。表名隔离是 2026-09 共库冲突修复的一部分(与 apollo_audit_logs 同一命名实践)。读者在排查 SQL 时若找不到 api_keys 表,先确认找的是 apollo_api_keys。
典型使用链路(演示视角):① 超管 admin 登录进入本页(fetchMe 自动把当前用户 ID 填入新建弹窗);② 点「新建 API Key」,名称填 ci-deploy-key,不设过期时间,点「创建 API Key」;③ 后端 201 返回明文 lap_...,页面弹出「API Key 创建成功」弹窗,红底警示「明文 Key 仅显示这一次」——点「复制」把完整明文存入安全位置后关闭;④ 在 CI 脚本里以 X-API-Key: lap_... 头调用部署/制品等受保护接口(机器调用不保存管理员密码);⑤ 回到本页列表能看到该 Key 行(状态「永不过期」);若 Key 泄露,在行内点「撤销」,confirm 确认后行消失,携带该 Key 的请求立即失败。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| Key 列表 | 全量 apollo_api_keys 按 id 倒序展示九列(ID/名称/前缀/所属用户/过期时间/状态/最后使用/创建时间/操作),无分页 | 页面主体列表 |
| 新建 API Key | 为指定用户签发机器凭据,可选名称与过期时间(空=永不过期) | 页头「新建 API Key」→ 弹窗「创建 API Key」 |
| 一次性明文 | 创建响应仅返回一次明文 lap_xxx,黑底等宽展示 + 一键「复制」 | 创建成功弹窗 |
| 过期状态标识 | 按 expires_at 与当前时间自动分四态:永不过期 / 有效 / 即将过期(7 天内)/ 已过期 | 列表「状态」徽标列 |
| 最后使用跟踪 | 每次 Key 成功鉴权时后端回写 last_used_at,列表展示最近使用时间 | 列表「最后使用」列 |
| 撤销即删 | window.confirm 二次确认后物理删除 Key,后续携带该 Key 的请求立即 401 | 行内「撤销」按钮 |
| 当前用户预填 | 进入页面自动调 GET /me 把当前登录用户 ID 填入所属用户 | 页面挂载自动执行 |
| 加载失败可辨 | 列表拉取失败时渲染独立错误态 + 「重试」,与「确实没有 Key」的空态区分 | 列表卡片错误态 |
1.3 一句话总结
给机器一把「一次性可见明文、过期可控、随时可撤销」的长期钥匙——新建成功后请立即复制保存明文,因为之后谁也看不到第二次。
2. 访问入口
2.1 路由与菜单
| 项 | 值 |
|---|---|
| 路由 path | /apollo/admin/api-keys |
| 路由 name | ApolloAdminApiKeys |
| meta.title | Apollo API Key 管理 |
| meta.requiresAuth | true |
| 侧边栏位置 | ApolloLayout 侧边栏「平台管理」分组第三项(角色管理之后、项目管理之前) |
| 前端源码 | action/web/src/views/apollo/ApiKeysPage.vue |
| API 客户端 | action/web/src/api/apolloClient.js |
| 路由注册 | action/web/src/router/index.js,/apollo 子路由 children 下 494–499 行,component 挂 ApolloLayout |
相邻页(同属平台管理组):角色管理 admin-roles.md(前,本页 Key 的权限语义来自其角色体系)、项目管理 admin-projects.md(后)。用户管理 admin-users.md(前前,Key 所属用户来自其用户库)。
2.2 认证与权限
- 路由
requiresAuth: true;登录态为独立apollo_token(Bearer JWT),2026-09-12 修订后不再回退读取旧aip_token。401 由拦截器清 token 并跳/apollo/login(登录页自身 401 不跳转)。 - 三个端点均在 protected 组并挂
RequirePerm:列表GET /api-keys需apikey:read;创建POST /api-keys与撤销DELETE /api-keys/:id需apikey:write(access.PermAPIKeyRead/PermAPIKeyWrite)。缺权限 403,body{"code":40301,"message":"无权限执行该操作","request_id":"…"}。 GET /me只需「任意已认证主体」(在 protected 组、过Authn但不挂RequirePerm),因此任何已登录账号都能取到自己的user_id。- 谁能进本页:
platform_admin/project_admin(持*:*)天然具备apikey:read/write;developer/operator/viewer三个内置业务角色的 seed 权限清单不含任何 apikey 权限点——平台管理默认只对超管开放。若想下放,需先在「角色管理」页用含*:*或apikey:*的自定义角色实现(建自定义角色本身又要role:write,实际仍是超管先操作)。 - API Key 也能调用这些端点:
Authn同时接受X-API-Key头,若 Key 所属用户持有apikey:*(或其角色含*:*),用 Key 调/api-keys同样放行——即可以用「Key 管 Key」。这种 Key 一旦泄露相当于把凭据管理权也交出去,签发时需谨慎。 - 可为任意存在的用户签发:后端只校验
user_id指向的用户存在(不存在 404用户不存在),不校验「创建者与所属者是否同一人」——超管可以给zhangsan建 Key 而不需要其密码。这是「代签发部署专用 Key」能力的基础,但也意味着apikey:write的实际语义是「能以任意人名义签发凭据」,授权面比字面更宽。
2.3 端口与 API 前缀
- Apollo 后端端口 18082;Vite 代理把
/apollo-api前缀 rewrite 为/api,客户端 baseURL/apollo-api/v1。 - 统一响应 envelope
{code,message,data,request_id}:拦截器在code===0时把response.data解包为业务数据。GET /api-keys的data是裸数组(全部 Key 行,无分页对象),创建成功data为{key,prefix,name,expires_at}。
3. 界面布局
页面为「页头 + 两个弹窗 + 列表」的单列结构:列表卡片下再分「加载中 / 加载失败 / 空态 / 表格」四态;两个弹窗(新建、创建成功)均用同一套全屏遮罩样式,弹窗打开时遮罩覆盖整页、点不到列表。
+--------------------------------------------------------------------+
| Apollo API Key 管理 [新建 API Key] |
| [操作结果提示条(v-if alert.message,右侧「关闭」)] |
+--------------------------------------------------------------------+
| ┌ 新建 API Key 弹窗(modal-overlay,@click.self 可关)─────────────┐ |
| │ 新建 API Key [关闭] │ |
| │ 所属用户(user_id)* [当前用户 ID](提示:默认取 GET /me…) │ |
| │ 名称(name) [如 ci-deploy-key] │ |
| │ 过期时间(expires_at,可空=永不过期)[datetime-local] │ |
| │ [创建 API Key / 创建中...] [取消] │ |
| └──────────────────────────────────────────────────────────────────┘ |
+--------------------------------------------------------------------+
| ┌ 创建成功弹窗:API Key 创建成功 [关闭] ────────────────────────────┐ |
| │ ⚠ key-warning:明文 Key 仅显示这一次,关闭后将无法再次查看… │ |
| │ 明文 Key(前缀 lap_xxxx…) │ |
| │ ┌──────────────────────────────────────────────────┐ [复制] │ |
| │ │ lap_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (等宽黑底) │ │ |
| │ └──────────────────────────────────────────────────┘ │ |
| │ 提示:调用时在请求头携带 X-API-Key: {前缀}… 即可访问受保护接口。 │ |
| └──────────────────────────────────────────────────────────────────┘ |
+--------------------------------------------------------------------+
| ┌ 列表卡片 ──────────────────────────────────────────────────────────┐ |
| │ 四态:加载中… / 加载失败:{msg} [重试] / │ |
| │ 暂无 API Key,点击"新建 API Key"创建。 / 表格 │ |
| │ ID | 名称 | 前缀(prefix) | 所属用户 | 过期时间 | 状态 | │ |
| │ 最后使用 | 创建时间 | 操作 │ |
| │ 行内状态徽标:永不过期/有效/即将过期/已过期;操作列 [撤销] │ |
| └────────────────────────────────────────────────────────────────────┘ |
+--------------------------------------------------------------------+
各板块职责:
| 板块 | 职责 |
|---|---|
| 页头 | 标题「Apollo API Key 管理」+ 右上「新建 API Key」主按钮 |
| 操作结果提示条 | 单槽反馈所有操作成败(info/success/error),右侧「关闭」手动清空 |
| 新建 API Key 弹窗 | 收集所属用户(必填,默认当前用户)/名称(可选)/过期时间(可选),提交创建 |
| 创建成功弹窗 | 一次性明文 Key 展示 + 「复制」按钮 + 调用姿势提示;关闭即永久失去明文 |
| 列表卡片 | 四态呈现 Key 行(加载中 / 加载失败 / 空 / 表格),行内状态徽标与「撤销」操作 |
4. 交互元素
页面状态由一组 ref 维护:keys(列表行)、loading(列表加载互斥)、keysErr(列表加载失败信息,非空时渲染独立错误态 + 重试)、busy(提交/撤销互斥位,覆盖创建与撤销按钮)、alert(提示条)、createModal/createForm(新建弹窗与表单)、plainKeyModal(明文弹窗)。挂载时并行 fetchMe() 与 fetchKeys()。
4.1 「新建 API Key」按钮
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「新建 API Key」 | 页头右上(btn-primary) | 打开新建弹窗 | 始终可点(busy 不约束) | createModal.visible=true,表单字段保留上次提交后重置的值 | 无(仅本地) | 提交成功关闭弹窗后会把 user_id 保留、name/expires_at 清空(见 4.2) |
4.2 新建 API Key 弹窗(表单)
| 字段/控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 所属用户(user_id)* 输入框 | 弹窗首个 form-group | Key 的所属用户;权限继承该用户的角色权限 | 必填(HTML required + 提交前 trim() 判空);挂载时经 GET /me 自动填入当前用户 ID,拿不到时清空 | 手工可改写为任意存在的用户 ID;留空点提交被前端拦为「user_id 必填」 | 无(填值只影响提交 body) | 字段值来源 data?.user_id;fetchMe 失败提示「获取当前用户信息失败,请手动填写 user_id:{msg}」但不阻断弹窗使用 |
| 名称(name)输入框 | 弹窗第二个 form-group | Key 的显示名(列表「名称」列展示) | 选填;空提交为 '' | 列表以 k.name \|\| '-' 展示;撤销确认文案引用它 | 无 | placeholder「如 ci-deploy-key」;不做长度/字符校验 |
| 过期时间(expires_at)输入框 | 弹窗第三个 form-group | Key 失效时刻 | 选填;空=永不过期 | 提交前经 toISO 转成 RFC3339 字符串放入 body;列表按此时间分四态徽标 | 无 | 类型 datetime-local;已修复(提交 412e9c48):前端加 :min="minExpiresAt"(ApiKeysPage.vue:46,取值 :191),下方 form-hint 提示「不得早于当前时间,否则将创建「创建即过期」的废 Key。」(:47);后端 CreateAPIKey 同步校验 ExpiresAt,早于当前时间 1 分钟以上返回 400「过期时间不能早于当前时间」(action/products/apollo/access/access_service.go:418-420)——选过去时间无论页面还是直调 API 都被拦(见 8 章) |
| 「创建 API Key」按钮 | 弹窗底部(btn-primary,type=submit) | 提交创建 | user_id 非空且 busy=false;进行中文字变「创建中...」并禁用 | 成功:关闭新建弹窗、弹出「API Key 创建成功」明文弹窗、提示「API Key 创建成功,请立即保存明文」(success)、重置表单(user_id 保留、name/expires_at 清空)、自动刷新列表;失败:提示「创建失败:{msg}」(error),弹窗不关 | POST /api-keys | 创建是写库且返回一次性明文的动作:成功即永远只有这一次能看到明文,务必提示后当场复制 |
| 「取消」按钮 | 弹窗底部(btn-outline) | 放弃本次创建 | 始终可点 | createModal.visible=false,已填内容保留在表单里 | 无 | 与右上「关闭」、点遮罩等价 |
表单提交 body(源码 handleCreate 组装,payload = {user_id, name: name||''},expires 解析成功才附加):
{
"user_id": "u_1a2b3c",
"name": "ci-deploy-key",
"expires_at": "2026-12-31T16:00:00.000Z"
}
user_id 的归属与权限继承:后端在 body 缺省 user_id 时兜底归属当前登录主体(currentUserID(c)),因此前端即使在 fetchMe 失败后什么都不填也点不了创建(前端先拦),而脚本直调 POST /api-keys 可以只传 name——Key 自动挂在调用者名下。Key 的权限在鉴权时解析,见 5.4。
时区与过期时间的换算:datetime-local 提交的是浏览器本地墙上时间(如 2026-12-31T23:00),前端 toISO() 用 new Date(v) 按浏览器本地时区解释后转成 .toISOString()(UTC、带 Z)再提交——因此选「本地 23:00」建的 Key,后端过期时刻是那一刻的 UTC 值,鉴权以 UTC 判定。列表展示时前端不做时区换算:formatTime 只是把后端返回的 RFC3339 串去掉 T/Z、截取前 19 位字符(.slice(0,19)),显示的墙上时间与本地钟面可能存在偏移(跨时区尤甚),徽标「已过期/即将过期」则按浏览器本地时钟与 expires_at 差值实时判定,详见 8 章。
轮转场景下的提交形态:给「同一台 CI 机器换 Key」时,先以 user_id 填机器专用账号、name 填带日期后缀(如 ci-deploy-key-2026-09)建新 Key,切换 CI 密钥变量后再撤销旧 Key。因为同名可重复创建,新旧 Key 会短暂同时在列表中,靠 key_prefix 与创建时间区分,撤销时注意确认框里的名称/前缀指向旧的那把。
4.3 创建成功弹窗(明文一次性展示)
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 弹窗标题 | 弹窗头 | API Key 创建成功 | 创建成功即出现 | - | 无 | 标题右侧「关闭」、右上 link-btn、点遮罩三者都可关 |
| 警示条 key-warning | 弹窗顶部 | 明文唯一性警告 | 红底白字 | 展示文案「明文 Key 仅显示这一次,关闭后将无法再次查看,请立即妥善保存(复制到安全位置)。」 | 无 | 关闭后明文不可恢复(库中只有哈希);误关只能撤销重建 |
| 明文 Key 展示 | 弹窗中部(.key-box) | 完整明文(plainKeyModal.key) | 创建响应 data.key | 黑底绿字等宽 <code class="plain-key"> 全文展示 | 无 | 标签为「明文 Key(前缀 {prefix}...)」;响应不含明文时 key 为空串 |
| 「复制」按钮 | 明文 Key 右侧(btn-sm btn-primary) | 复制完整明文到剪贴板 | 始终可点 | 成功提示「明文 Key 已复制到剪贴板」(success);失败提示「复制失败,请手动选中复制」(error) | 无 | 走 navigator.clipboard.writeText,非 HTTPS/受限权限环境会失败,需手动选中复制 |
| 调用姿势提示 | 弹窗底部 .result-hint | 提示机器调用怎么带 Key | 蓝底 | 展示「调用时在请求头携带 X-API-Key: {完整明文} 即可访问受保护接口(必须使用上方完整 Key,前缀截断无法鉴权)。」 | 无 | 已修复(提交 412e9c48):.result-hint 内联渲染的是弹窗里的完整明文({{ plainKeyModal.key }})并补注「前缀截断无法鉴权」(ApiKeysPage.vue:76-79),不再出现 {prefix}... 截断示例(见 8 章) |
4.4 操作结果提示条与「关闭」
| 控件 | 位置 | 含义 | 默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 提示条 | 页头下方 | 单槽展示最近一次操作结果 | 有 alert.message 才显示,类型 alert-info/success/error | 新操作覆盖旧提示,无历史 | 无 | 加载失败/创建/撤销/复制成败全部走这里 |
| 提示条「关闭」 | 提示条右侧 | 清空当前提示 | 提示条可见时可用 | alert.message='' | 无 | 纯本地状态 |
4.5 API Key 列表卡片
| 控件/列 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 加载占位 | 卡片内(loading) | 「加载中...」居中灰字 | 首次/刷新拉取时 | 列表未就绪前的占位 | 无 | 数据在 GET /api-keys 返回后替换 |
| 加载失败(错误态) | 卡片内(keysErr 非空) | 拉取失败独立提示 | fetchKeys catch 置 keysErr 后 | 红字「加载失败:{msg}」+「重试」按钮(点击重新执行 fetchKeys) | 重试时 GET /api-keys | 三态顺序 loading > keysErr > 空态 > 表格:错误态在空态之前,避免把「加载失败」误读成「没有任何 Key」;成功时清空 keysErr |
| 空态 | 卡片内(keys.length===0 且无错误) | 无任何 Key | 列表拉取成功但为空时 | 文案「暂无 API Key,点击"新建 API Key"创建。」 | 无 | 仅表示确实没有 Key(已明确排除加载失败);早期「错误提示 + 空态同时出现」的误导已由独立错误态消除(见 8 章) |
| ID | 首列 | 行主键 | - | 纯展示 | 无 | 撤销成功提示引用它(#${id}) |
| 名称 | 第二列 | k.name \|\| '-' 加粗 | - | 纯展示 | 无 | 创建时未填则显示 - |
| 前缀(prefix) | 第三列 | k.key_prefix(明文前 10 位) | - | <code class="cell-code"> 等宽展示 | 无 | 只作识别,不能当 Key 用;完整明文不可从列表恢复 |
| 所属用户 | 第四列 | k.user_id | - | 纯展示 | 无 | Key 权限的继承源头 |
| 过期时间 | 第五列 | formatTime(k.expires_at) | 可空 | 空显示 -;否则 RFC3339 去掉 T/Z 截前 19 位(UTC) | 无 | 永不过期在此列显示 -,状态列才显示「永不过期」 |
| 状态徽标 | 第六列 | expiryBadge/expiryLabel 四态 | - | 无过期=灰「永不过期」;已过=红「已过期」;7 天内=橙「即将过期」;其余=绿「有效」 | 无 | 判定基于浏览器本地时钟与 expires_at 的差值(diff > 0 && diff < 7*24*3600*1000),见 5.4 |
| 最后使用 | 第七列 | formatTime(k.last_used_at) | 可空 | 空显示 - | 无 | 由后端在 Key 每次成功鉴权时回写(见 5.4) |
| 创建时间 | 第八列 | formatTime(k.created_at) | - | 同上格式展示 | 无 | 只读 |
| 「撤销」按钮 | 操作列(btn-sm btn-danger) | 吊销该 Key | busy=false 时可用 | 见 4.6 | DELETE /api-keys/:id | 无权限该按钮不会单独隐藏——整页接口 403 提示更早发生(列表都加载不出) |
列表加载时序与失败表现:页面挂载时 fetchMe 与 fetchKeys 并行发起(后者先置 loading=true、清空 keysErr)。fetchKeys 失败走 catch:置 keysErr=errMsg(err)(keys 保持上次值不变,但错误态在模板中优先于表格渲染),渲染独立错误态(红字「加载失败:{msg}」+「重试」按钮,重试即重新执行 fetchKeys),同时提示条给出「加载 API Key 列表失败:{msg}」;若恰是权限不足,拦截器先 alert('无权限执行该操作'),提示条再叠加一条 403 文案。成功时清空 keysErr,正常进入空态或表格。创建成功与撤销成功之后都会再调一次 fetchKeys 自动刷新——列表没有手动刷新按钮、没有轮询,若 Key 被其它管理员改动(如别处撤销),本页不会自动感知,需刷新页面。
4.6 「撤销」按钮(二次确认写操作)
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「撤销」 | 每行操作列 | 吊销该 API Key | busy=false;busy 期间禁用防连点 | 点击先弹浏览器 confirm,文案 确定撤销 API Key "{k.name \|\| k.key_prefix}" 吗?撤销后使用该 Key 的请求将立即失败。;确认后:成功提示 API Key #{k.id} 已撤销(success)并刷新列表;失败提示「撤销失败:{msg}」(error) | DELETE /api-keys/:id | 撤销是物理删除、不可逆:确认后该 Key 永久失效,apollo_api_keys 行消失,携带旧明文请求立即 401;无重新启用/找回路径,只能重建。取消 confirm 则什么都不发生 |
5. 后端关联
5.1 API 客户端
action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、请求拦截器对所有请求附加 Authorization: Bearer <token>(token 仅取 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 code===0 时把 response.data 解包为业务数据(页面 const { data } = ... 直接得到业务数组/对象);HTTP 2xx 但 code!==0 拒绝;4xx/5xx 提取 body.message || body.error 并把 message 别名写入 error.response.data.error 后 reject——401 清 token 并跳 /apollo/login,403 alert('无权限执行该操作')。页面各 catch 统一以 errMsg()(err?.response?.data?.message || … || '未知错误')拼提示。
5.2 端点表
| 方法 | 路径 | 权限 | 请求体/Query | 页面触发点 |
|---|---|---|---|---|
| GET | /me | protected 组、仅需认证(无 RequirePerm) | - | 页面挂载取当前用户 ID 预填表单 |
| GET | /api-keys | apikey:read | Query user_id 可选过滤 | 页面挂载加载列表 |
| POST | /api-keys | apikey:write | body {user_id?, name?, expires_at?} | 新建弹窗「创建 API Key」 |
| DELETE | /api-keys/:id | apikey:write | - | 行内「撤销」 |
注册位置:action/products/apollo/server/server.go——/me 于 725 行、/api-keys 三端点于 823–825 行(均属 protected 组,access.Authn 已在 717 行挂到该组);列表与详情所需的权限点常量在 access/permissions.go(PermAPIKeyRead/PermAPIKeyWrite)。/me 同时登记在 server/api.go 的公开路由清单第 204 行(供外部自查,实际受 Authn 保护)。
5.3 响应结构示例
GET /me(envelope 解包后为 Principal,前端只取 user_id):
{
"user_id": "u_1a2b3c",
"username": "admin",
"roles": ["platform_admin"],
"is_super": true,
"permissions": ["*:*"]
}
字段说明:user_id 为账号主键(前端用它预填 user_id);username 登录名;roles 该账号绑定的角色名数组;is_super 是否超管(有效权限含 *:*);permissions 解析后的权限点列表(JWT 路径才回填,API Key 认证时 roles 为空但 permissions 有值)。
GET /api-keys(envelope 解包后为裸数组,按 id 倒序,无分页):
[
{
"id": 7,
"user_id": "u_1a2b3c",
"name": "ci-deploy-key",
"key_prefix": "lap_1a2b3c4d",
"expires_at": "2026-12-31T16:00:00Z",
"last_used_at": "2026-09-07T02:13:00Z",
"created_at": "2026-09-01T08:00:00Z"
},
{
"id": 6,
"user_id": "u_9z8y7x",
"name": "",
"key_prefix": "lap_deadbeef",
"expires_at": null,
"last_used_at": null,
"created_at": "2026-08-30T03:00:00Z"
}
]
字段含义(access.APIKey,表 apollo_api_keys):id 主键;user_id 所属用户(权限继承源);name 显示名(可空串);key_prefix 明文前 10 位;expires_at 过期时间(omitempty,永不过期则字段不出现);last_used_at 最近一次成功鉴权时间(omitempty,未用过不出现);created_at 创建时间。key_hash(SHA256)与 permissions(权限快照 JSON)在 struct 上都标了 json:"-",永不下发到前端——列表页因此无法恢复明文、也看不到权限快照字段。
POST /api-keys(201,envelope 解包后;明文仅此一次):
{
"key": "lap_1a2b3c4d5e6f708192a3b4c5d6e7f809",
"prefix": "lap_1a2b3c4d",
"name": "ci-deploy-key",
"expires_at": "2026-12-31T16:00:00Z"
}
DELETE /api-keys/:id(200,envelope 解包后):
{ "id": 7, "deleted": true }
错误码速查:未登录/坏 token → 401 {code:40101,message:"未认证"};缺权限 → 403 {code:40301,message:"无权限执行该操作"};body 无 user_id 且兜底也取不到当前主体 → 认证类错误;user_id 指向不存在的用户 → 404 用户不存在;:id 非数字 → 400 invalid api key id;撤销不存在的 Key → 404 API Key 不存在;内部错误 → 500 envelope。
5.4 关键机制
明文生成与哈希存储(access/api_key.go)。GenerateAPIKey 用 crypto/rand 生成 16 字节随机数,明文形如 lap_ + 32 位十六进制(lap_ 前缀同时是 Authn 识别 API Key 的标记);HashAPIKey 对明文做 SHA256 十六进制摘要,库表只存 key_hash(uniqueIndex)+ key_prefix(明文前 10 位,识别用);创建 handler 返回一次 {key: 明文, prefix}。由此:即使数据库整体泄露,攻击者也拿不到能用的 Key(只能拿哈希做离线爆破),而哈希一旦泄露也无法反推明文去撤销——泄漏面只有「创建响应那一刻」与「使用者自己保存的地方」。
鉴权与权限继承(ValidateAPIKey + Authenticate)。Authn 中间件取令牌的顺序是:有 Authorization: Bearer <值> 就用它,否则才读 X-API-Key: <值>(两者同带时 X-API-Key 被忽略);拿到令牌后 Authenticate 按前缀分流——lap_ 开头走 API Key 校验,其余当 JWT 解析。也就是说凭据类型与所在请求头是解耦的:lap_... 明文放进 Authorization: Bearer 或 X-API-Key 都能过(许多 CLI 只认 Authorization 头,此时把 Key 放 Authorization 即可);反过来把 JWT 放进 X-API-Key 也会被当 JWT 解析。分流后 API Key 路径按序执行:按 key_hash 反查记录(查无 → 401 无效的 API Key)→ 判过期(ExpiresAt != nil && time.Now().After(*ExpiresAt) 即 401 API Key 已过期)→ 解析权限:若该行 Permissions 字段为空(本页创建流程从未写它),动态继承所属用户的角色权限(ResolvePermissionsForUser 现查 user_roles → roles → role_permissions)→ 异步回写 last_used_at(失败仅告警)。之后 Authorize 用这套权限做 glob 判定。API Key 构造出的 Principal 有 UserID/Username/Permissions,但 Roles 为空(只有 JWT 路径回填角色名数组),is_super 由权限是否含 *:* 推导。推论:① 给用户改角色后,其名下未固化权限的 Key 权限跟着变(下次鉴权重新解析),从账号解绑角色即撤销该 Key 相应能力;② Key 无独立身份,审计日志里以所属用户的 user_id 记名,无法区分「人操作」与「机器用 Key 操作」(同一 user_id,可结合 UserAgent/来源 IP 判断);③ 给持 *:* 的账号(如 admin)签发 Key 等于给了一把超管机器钥匙,务必按最小权限绑专用账号。
过期与状态判定(前端逻辑,非后端状态机)。apollo_api_keys 没有「状态」列,四态徽标完全是前端按 expires_at 与浏览器本地时钟算的:无 expires_at → 永不过期;new Date(expires_at) < Date.now() → 已过期;差值为正且小于 7×24×3600×1000ms → 即将过期;否则 有效。因此:跨时区/本地时钟不准时徽标可能与真实 UTC 时刻有偏差;「已过期」只是展示,真正拦截发生在鉴权侧(过期 Key 携带请求会被 401 拒,与徽标无关)。后端不提供过期主动通知/自动删除,过期 Key 行会一直留在列表里直到被撤销。
撤销即物理删除。RevokeAPIKey 先按 id 查行(不存在 404 API Key 不存在),存在则 Delete——不是打标记是删行。删除后携带该明文请求到 ValidateAPIKey 因按 key_hash 查无记录返回 401 无效的 API Key,即「撤销后使用该 Key 的请求将立即失败」。正因为删行,GET /api-keys 也同时看不到它,列表行随之消失。旧 Key 的审计/部署历史记录不受影响(它们引用 user_id 与操作时间,不依赖 Key 行存活)。
列表口径与过滤(ListAPIKeys)。页面调用不带 user_id 参数,后端 Order("id DESC") 返回全表所有用户的 Key——超管在本页能看到每个账号签发的每一把 Key(含最后使用时间与状态),本页因此兼作机器凭据的全局审计台;带 ?user_id= 可只看某账号(供脚本/细粒度场景,页面未暴露此过滤)。由于 (user_id, name) 无唯一约束,全表口径下同名 Key 会并列多行,靠自增 id(新的在上)与 key_prefix 区分。
可用条件与 busy 互斥。创建与撤销共用 busy 位:任一请求进行中,两处按钮同时禁用(创建中.../置灰),防止连点重复创建或重复撤销;列表加载独立用 loading,只在首屏与撤销/创建后的自动刷新间切换,不与 busy 争用。撤销用原生 confirm 二次确认,取消即不发起请求;创建没有二次确认(表单本身即意图表达),点「创建 API Key」即写库——若误填了错误的 user_id/过期时间,会产生一把绑错账号或立即失效的 Key 行(可撤销止损),提交前应核对所属用户与过期时间。
跨页面联动:Key 通常给 CI 机器用(见「部署与漂移」的机器调用故事:X-API-Key 触发 deployments/start)。CI 里用 Key 而非管理员密码,可随时在本页撤销而不影响任何账号登录;撤销后正在跑的流水线若下一跳携带旧 Key 会立即失败。本页创建 Key 的「所属用户」决定它能调什么:绑 admin 的 Key 权限含 *:*(超管),绑普通账号则只有该账号角色权限——给机器 Key 应遵循最小权限,绑一个专门的只读/部署专用账号而不是超管。
6. 权限与安全
- 凭据保护三原则:明文不落库(仅 SHA256 哈希 + 前 10 位前缀);明文仅创建响应返回一次;列表/撤销响应不含
key_hash/permissions(json:"-")。最弱环节是使用方的明文保管,页面已用一次性弹窗 + 警示条强化。 - 双因子入口:
Authn先看Authorization: Bearer再看X-API-Key,凭据类型按令牌前缀(lap_或非)分流而非按头分流。注意不要同时携带Authorization: Bearer <JWT>与X-API-Key——Bearer 优先,X-API-Key 被忽略;把 Key 放Authorization: Bearer lap_...也会被识别为 API Key(CLI 常用)。前端 apolloClient 总带 Bearer JWT,因此本页在浏览器里永远走登录态路径;机器调用才用 Key。 - 可授权的面:创建/撤销 Key 是
apikey:write、列表是apikey:read,读写天然分离;页面所有端点只在超管(*:*)可及范围内被使用。能给任意存在的user_id建 Key(仅校验存在性)——以某用户名义签发机器凭据等同代其行使该用户权限,需有操作纪律。 - 撤销是唯一止损手段:Key 泄露后无轮转/冻结/限流机制,只能撤销重建;撤销即删行的瞬时性保证新请求立即失败。
- 传输层纪律:明文 Key 是 bearer 凭据——经明文 HTTP 传输即等于裸奔。生产访问应走 HTTPS/网关 TLS 终结(18082 直连仅限内网演示);Key 本体不要进 git/日志/镜像,CI 用密钥管理系统注入。
- 审计:本页所有请求经根路由组
httpAuditMiddleware异步落apollo_audit_logs,action分别为POST /api-keys/DELETE /api-keys/:id/GET /api-keys,创建/撤销为写操作留痕,可在「审计日志」页反查谁在何时为谁签发了 Key。注意请求体脱敏后入库,key_hash等敏感字段本就不在请求体里。
7. 常见问题与排错
以下问题均由 ApiKeysPage.vue 的 catch 分支提示、后端 handler/校验代码与既有测试归纳,均可按步骤复现。
- 创建成功但明文弹窗里是空的 / 复制出空串:原因是创建响应
data里没有key/prefix字段(后端版本不一致或代理改写)。处理:看后端handleCreateAPIKey是否 201 返回{key,prefix,name,expires_at};先确认 Key 行是否真的建出来(列表刷新可见),若建出而页面拿不到明文,建议撤销重建。
- 页面提示「获取当前用户信息失败,请手动填写 user_id」:原因是挂载时
GET /me失败(token 失效/后端异常)。处理:重新登录再进页面;或直接手动填当前账号的user_id(可在「用户管理」页查到)——后端在 body 缺省时也会兜底归属当前登录主体,所以手填自己即可。
- 撤销后旧的请求还能成功一小段时间:原因是撤销是删行,理论上删除瞬间即失效;但已建立的长连接或调用方缓存的响应可能在极短窗口内看似成功。处理:确认列表已无该行;对仍失败的调用方检查是否真的换了新 Key;后端日志
RevokeAPIKey应执行成功无 404。
- 列表里 Key 状态显示「已过期」但请求还能过:原因是「已过期」徽标是前端按浏览器本地时钟判定的展示值;真正鉴权按后端 UTC 时间在
ValidateAPIKey判ExpiresAt。若本地时钟比 UTC 慢,徽标先红但后端还没到时刻。处理:以 UTC 为准;时刻一到后端即 401API Key 已过期,徽标只作提醒。
- 新建时选了过去的时间,Key 创建成功却不能用:该缺陷已修复(前端
datetime-local加min+ 提交前拦截,后端CreateAPIKey拒绝过去时间,见 8 章);修复前建出的历史「创建即过期」Key,首次使用即被判过期(401API Key 已过期)。处理:删除该 Key 重建,或选未来时间。
- 用
X-API-Key调用却返回 401「未认证」/「无效的 API Key」:原因是同时带了Authorization: Bearer头(可能是一个已过期的旧 JWT,拦截器/网关仍会带上)时 X-API-Key 被忽略,或明文复制不全/末尾多了空格。处理:去掉Authorization头只留X-API-Key: lap_完整明文(若客户端强制走 Authorization 头,把 Key 放Authorization: Bearer lap_...也一样有效);对照创建弹窗原样复制;确认该 Key 未被撤销。
- 改了某用户的角色,用其名下 Key 调接口权限没变:原因可能是该 Key 行存在
permissions快照(非空则不继承角色,直接用快照)。本页创建流程不会写permissions,但若 Key 由其它通道/旧版本签发可能固化过权限。处理:在库里查apollo_api_keys.permissions字段;为空才动态继承用户角色,有值则需更新该快照或重建 Key。
- 想确认某 Key 什么时候被用过:看列表「最后使用」列(
last_used_at)——后端每次成功鉴权异步回写,失败仅告警不阻断。处理:未用过显示-;配合「审计日志」页按 user_id/动作反查调用轨迹。
- 如何无感轮转一把 Key:原因是撤销旧 Key 会影响正在用它的调用方,直接「撤销 → 新建」会造成服务中断。处理:先建新 Key(同一 user_id、带日期后缀的 name)→ 把调用方密钥切到新明文 → 确认调用方全部跑通后再到本页撤销旧 Key;撤销前核对确认框里的名称/前缀确实指向旧 Key,避免误删新 Key(同名可重复时尤其注意)。
- 同名 Key 太多分不清哪把在用:原因是
(user_id, name)无唯一约束,多次用同一 name 创建会并列多行且列表无搜索。处理:按惯例把过期时间/用途编进 name(如ci-deploy-key-2026-09);列表按 id 倒序、最新在顶部,靠key_prefix与创建时间定位;拿不准的 Key 宁可不撤销(撤销不可逆),先新建再逐步切换。
- 列表显示「加载失败:{msg}」而不是空态:原因是
GET /api-keys请求失败(网络异常、后端 500,或 token 失效触发 401 跳登录)。处理:点错误卡里的「重试」重新拉取;若仍失败,按提示文案定位(401 重新登录、403 由拦截器 alert「无权限执行该操作」并需apikey:read权限);错误态与「暂无 API Key」空态已分离,看到「加载失败」即代表没查到数据而非没有 Key。
8. 已知缺陷与边界
| 缺陷/边界 | 说明 |
|---|---|
| 明文仅此一次且易误失 | 已修复(提交 412e9c48):创建成功弹窗的「关闭」按钮与遮罩点击统一改走 closePlainKey(action/web/src/views/apollo/ApiKeysPage.vue:60/64/272-276),关闭前 window.confirm 二次确认(「请确认已复制,确定关闭吗?」);不再因误点遮罩/关闭而静默丢失明文(库中只有哈希,只能撤销重建)。浏览器刷新页面仍会丢失,属一次性展示语义 |
| 结果提示里展示的是前缀 | 已修复(提交 412e9c48):.result-hint 改为直接渲染完整明文(X-API-Key: {{ plainKeyModal.key }})并注明「(必须使用上方完整 Key,前缀截断无法鉴权)」(ApiKeysPage.vue:76-79),上方另有「复制」按钮(:73);不再出现 {prefix}... 截断示例误导新手 |
| 过期时间可选过去值 | 已修复(提交 412e9c48):前端 datetime-local 加 :min="minExpiresAt"(ApiKeysPage.vue:46,取值 :187-191)并在提交前拦截不早于当前时间(:235-238);后端 CreateAPIKey 亦校验 ExpiresAt 拒绝「创建即过期」的废 Key(action/products/apollo/access/access_service.go:414-421,留 1 分钟容差)——前端 min 仅为客户端约束,服务端校验已是兜底 |
| 空态与错误态混淆 | 已修复(2026-09-13):新增独立错误态(keysErr 非空时渲染「加载失败:{msg}」+「重试」卡,模板顺序 loading > keysErr > 空态 > 表格),成功时清空 keysErr——加载失败不再与「暂无 API Key」空态同屏,用户不会误以为没有任何 Key |
| 无分页/搜索 | GET /api-keys 全量返回(id 倒序),Key 多时列表一次渲染所有行,无关键字过滤——大数据量下性能与可读性受限 |
| 无编辑/续期 | Key 无任何编辑端点(改名/改过期/重新显示明文都做不到);过期只能重建,重建后旧明文失效需通知所有调用方切换 |
| 过期不自动清理 | 过期 Key 行永久滞留列表(无自动删除/归档任务),只能人工逐个撤销 |
| 前端时间判定用本地时钟 | 徽标「已过期/即将过期」基于浏览器本地时间与 expires_at 差值,跨时区或本地时钟不准时展示与后端 UTC 判定存在偏差(鉴权本身以后端为准) |
| 页面 hint/前缀仅为识别 | key_prefix 只有前 10 位,不能作为 Key 使用;也无法从列表区分同名 Key(名称可重复) |
| 同名 Key 无唯一约束 | (user_id, name) 可重复创建,列表无搜索/筛选,同名多行只能靠 id 与 key_prefix 区分,误撤销风险高(确认框只显示 name 或前缀) |
| 鉴权日志以 user_id 记名 | Key 调用与用户本人调用在审计中以同一 user_id 出现,无独立主体标识(区分需靠 IP/UserAgent),机器与人的操作不可按凭据类型直接分离 |
附录 A:速查——页面文案与关键常量清单
| 场景 | 源码取值/文案(逐字) |
|---|---|
| 页面标题 | Apollo API Key 管理 |
| 按钮 | 新建 API Key / 创建 API Key / 创建中... / 取消 / 复制 / 撤销 / 关闭 |
| user_id 占位 | 当前用户 ID |
| user_id 表单提示 | 默认取当前登录用户 ID(GET /me);API Key 权限继承该用户角色。 |
| name 占位 | 如 ci-deploy-key |
| 过期标签 | 过期时间(expires_at,可空=永不过期) |
| 警示条 | 明文 Key 仅显示这一次,关闭后将无法再次查看,请立即妥善保存(复制到安全位置)。 |
| 明文标签 | 明文 Key |
| 调用提示 | 调用时在请求头携带 X-API-Key: {{ plainKeyModal.key }} 即可访问受保护接口(必须使用上方完整 Key,前缀截断无法鉴权)。 |
| 状态四态 | 永不过期(灰)/ 有效(绿)/ 即将过期(橙)/ 已过期(红) |
| 空态 | 暂无 API Key,点击"新建 API Key"创建。 |
| 加载态 | 加载中... |
| 加载失败态 | 加载失败:{msg}(+ 按钮 重试) |
| 创建成功提示 | API Key 创建成功,请立即保存明文 |
| 复制成功提示 | 明文 Key 已复制到剪贴板 |
| 复制失败提示 | 复制失败,请手动选中复制 |
| 撤销确认 | 确定撤销 API Key "{name \|\| prefix}" 吗?撤销后使用该 Key 的请求将立即失败。 |
| 撤销成功提示 | API Key #{id} 已撤销 |
| 撤销失败提示 | 撤销失败:{msg} |
| 创建失败提示 | 创建失败:{msg} |
| 加载列表失败提示 | 加载 API Key 列表失败:{msg} |
| 取 me 失败提示 | 获取当前用户信息失败,请手动填写 user_id:{msg} |
| 明文形态 | lap_ + 32 位十六进制(access/api_key.go) |
| 权限点 | apikey:read(列表)/ apikey:write(创建、撤销) |