1. 页面概览
1.1 是什么
「制品与渠道」页(对应前端源码 action/web/src/views/BundlePage.vue,页面标题「Apollo 制品与渠道」)是 LightApollo 的可验签分发中枢。它把发布链路上最关键的三种对象收在同一个页面里:bundle 制品(用户在浏览器选若干文件,经 multipart 上传由后端打包签名、登记成带 digest 与签名者指纹的制品记录)、发布渠道(release channel)(把 bundle 绑到「发布给谁」的语义——渠道指向一条期望状态后,Spoke Agent 的 Pull 目标选择就能以 stable 渠道为准来拉取)、部署策略(deployment policy)(用一段 rules JSON 描述「部署该怎么跑」——是否自动回滚、并发上限、就绪超时、退避参数)。页面上半部是构建 bundle 表单与 bundle 制品列表,下半部是左右并排的渠道卡与策略卡,各自独立做创建、编辑、删除与(渠道独有)指向期望状态。
需要先分清本页与相邻页的对象关系:期望状态(desired state)在部署侧创建与激活(YAML 声明,见 deployments 页);bundle 是本页构建的「带签名、可验签的分发最小单元」,一个期望状态在激活时通常要关联某个 bundle 的 digest(desired_states.digest,G7 连接键);渠道在本页把「发布目标」绑定到某条期望状态上(current_desired_state_id),并把 promotion/approval 两个策略字段作为 JSON 存起来;策略在本页维护部署行为规则,实际生效在部署编排 Advance 阶段按策略名解析。而「制品管理」页(/apollo/artifacts)的制品仓库走另一套轻量元数据登记,与 bundle 通过 artifacts.bundle_id / storage_path="bundle://{digest}" 关联——本页是打包本体所在,制品页只是登记引用。
典型使用链路(演示视角的完整闭环):① 首次构建时后端若发现签名者白名单里还没有 demo-signer 的真实公钥,会自动生成一套 SM2 演示密钥并把它 upsert 进白名单(幂等,指纹 = SM3(公钥)),因此「开箱即可签名、开箱即可验签」;② 在「构建 bundle」表单填应用(如 order-service)、版本(如 1.0.0),点「+ 文件行」追加若干文件行并选定本地文件,文件行的「路径」输入框决定该文件在 bundle 内的路径(留空回退用文件名),点「构建 bundle」提交 multipart;③ 后端打包(逐文件 SM3 校验和进清单)→ 计算整体 digest(SM3 规范化 manifest + 按路径排序的文件字节)→ 用演示 SM2 私钥对 digest 签名 → 落盘到 ./temp/apollo_bundles/<digest> 并登记 bundles 表,页面提示 bundle 构建成功 id=… digest=…;④ 在 bundle 制品列表点「验签」,后端从落盘目录读回制品逐项校验(逐文件 SM3 / digest / 白名单信任锚 / SM2 签名),结果弹窗展示 files_ok / digest_ok / signature_ok / trusted,行内出现「可信/不可信」徽标;⑤ 点「下载 zip」拿到 manifest.json + digest.sig + files/… 归档(文件按 bundle-{id}-{app}-{version}.zip 命名),供真实 Spoke 侧下载后自行 LoadBundleFromDir 校验应用;⑥ 回到页面下半部,为某渠道在「指向期望状态」下拉里选一条 active 期望状态,或新建 stable/dev 渠道并把 promotion/approval 策略 JSON 填进去;⑦ 新建/编辑 default 部署策略控制 auto_rollback 等行为——到此「制品签名 → 渠道指向 → 策略放行」的发布前准备即告完成,之后即可在部署页发起部署,让 Agent 按本页固化的声明去拉取应用。
1.2 核心价值/能力表
| 能力 | 说明 | 对应页面操作 |
|---|---|---|
| 一键构建签名制品 | 多文件行 + 文件键(bundle 内路径),multipart 上传由后端 SM3/SM2 打包签名,返回含 id/digest 的记录 | 「构建 bundle」按钮 |
| 开箱自洽信任锚 | 首次构建惰性生成 demo-signer 的 SM2 密钥并 upsert 白名单,构建即签名、验签即可信 | 首次「构建 bundle」 |
| 逐项验签诊断 | 逐文件 SM3 → digest → 白名单 → SM2 四步校验,结果 JSON 弹窗展示 | 行内「验签」按钮 |
| zip 下载分发 | 任意 bundle 可下载归档(manifest.json + digest.sig + files/...) | 行内「下载 zip」 |
| 渠道管理 | 渠道 CRUD + 软删 + 「指向期望状态」绑定发布目标 | 「发布渠道」卡 |
| 渠道-期望状态绑定 | 渠道下拉把发布目标指向一条 active 期望状态,Agent Pull 目标选择参考 stable 渠道 | 「指向期望状态」下拉 |
| 部署策略即代码 | policies 表存 rules JSON(auto_rollback 等),空规则自动落默认值 | 「部署策略」卡 |
| 并行加载互斥 | 四份数据 Promise.all 并行拉取,任一失败静默降级空数组;busy 互斥防连点 | 页面 onMounted / 全部写操作 |
1.3 一句话总结
2. 访问入口
2.1 路由与菜单
| 项 | 值 |
|---|---|
| 路由 path | /apollo/bundles |
| 路由 name | ApolloBundles |
| meta.title | Apollo 制品与渠道 |
| 父路由 | /apollo(组件 ApolloLayout,meta.title: 'Apollo') |
| 侧边栏入口 | ApolloLayout 侧边栏「制品与渠道」,位于「配置管理」之后、「Spoke Agent」之前 |
| 前端源码 | action/web/src/views/BundlePage.vue |
| API 客户端 | action/web/src/api/apolloClient.js |
| 路由注册 | action/web/src/router/index.js,/apollo 子路由 children 下(约 439-444 行) |
访问方式:登录 Apollo 后从左侧菜单「制品与渠道」进入,或直接访问 /apollo/bundles。相邻页(侧边栏顺序):配置管理 configs.html(前)、Spoke Agent agents.html(后);概念对照:制品管理 artifacts.html(制品仓库 / bundle 概念区分)、部署与漂移 deployments.html(期望状态创建激活、bundle digest 关联、部署推进)。
2.2 认证与权限
- 路由
requiresAuth: true:未登录访问被前端路由守卫拦截并跳转/apollo/login(登录态为独立apollo_token,2026-09-12 修订后不再回退旧aip_token)。 - 全部本页端点位于 server.go 的 protected 组,经
access.Authn解析 Bearer JWT / X-API-Key 后逐条RequirePerm校验权限点:- bundle:读用
PermBundleRead,写/验签/清理用PermBundleWrite; - 渠道:读
PermChannelRead,创建/更新/删除/指向PermChannelWrite; - 策略:读
PermPolicyRead,创建/更新/删除PermPolicyWrite; - 期望状态下拉:读
PermDesiredStateRead。
- bundle:读用
- 401(apollo_token 失效)由响应拦截器清 token 并跳转
/apollo/login;403 统一alert('无权限执行该操作')。注意:验签在语义上是只读诊断,但后端要求PermBundleWrite——只有读权限的只读角色点「验签」会收到 403(见 7.4)。
2.3 端口与 API 前缀
- Apollo 后端端口 18082。Vite 开发代理把
/apollo-api前缀 rewrite 为/api转发(后端路由注册在/api/v1),apolloClient baseURL/apollo-api/v1,即前端请求/apollo-api/v1/bundles→ 后端/api/v1/bundles。 - 统一响应 envelope
{code,message,data,request_id}:拦截器在 HTTP 2xx 且code===0时把response.data解包为业务数据,页面const { data } = await apiClient.get(...)直接拿业务对象。 - blob 例外:bundle 下载走
responseType:'blob',拦截器对 blob/arraybuffer 原样放行、不解析 envelope(下载 zip 场景)。
3. 界面布局
+------------------------------------------------------------------+
| Apollo 制品与渠道(h2 页头) |
| [操作结果提示条 v-if alert.message:{{alert.message}} [关闭]] |
+------------------------------------------------------------------+
| [构建 bundle 卡] |
| 应用(app)* | 版本(version)* | bundle_version * | 签名者(signer)|
| 制品文件(文件键为 bundle 内路径) [+ 文件行] |
| [路径输入][file 选择][已选文件名][删除] ×N |
| [构建 bundle / 构建中...](busy 禁用) |
+------------------------------------------------------------------+
| [bundle 制品卡] |
| ID|名称|应用|版本|bundle_version|digest|签名者指纹|大小|验签|操作 |
| (digest/指纹:shortDigest 悬停全文;名称列 b.name||b.app) |
| 验签列:[验签] [可信/不可信徽标] 操作列:[下载 zip] |
+------------------------------------------------------------------+
| [发布渠道(channels)卡] | [部署策略(policies)卡] ← 左右并排 |
| 创建表单(名称/描述/promo/approval)+[创建渠道] |
| ID|名称|指向期望状态|操作 ID|名称|规则|操作 |
| 指向期望状态=[select: 未指向(0)│#id name...] |
| 操作=[编辑][删除] 操作=[编辑][删除] |
+------------------------------------------------------------------+
| [编辑渠道弹窗] [编辑策略弹窗] [验签结果弹窗] @click.self 可关 |
+------------------------------------------------------------------+
各板块职责:
| 板块 | 职责 |
|---|---|
| 页头 + 提示条 | 标题「Apollo 制品与渠道」、全局操作结果提示(info/success/error,可「关闭」) |
| 构建 bundle 卡 | 收集 app/version/bundle_version/signer 与文件行(路径+本地文件),multipart 构建并刷新列表 |
| bundle 制品卡 | 全量 bundle 记录表格(id 降序),含验签入口、可信徽标、zip 下载 |
| 发布渠道卡 | 渠道创建表单 + 渠道表格(指向期望状态下拉、编辑、删除) |
| 部署策略卡 | 策略创建表单 + 策略表格(rules JSON 截断展示、编辑、删除) |
| 三个弹窗 | 编辑渠道(name 不可改)/ 编辑策略(name 不可改)/ 验签结果 JSON |
页面无独立「加载中」骨架屏:数据未到时表格区为空,写操作进行中由按钮 busy 文案体现(构建中.../保存中...);四份数据任一接口失败被 .catch(() => ({data:[]})) 静默降级,不弹错误(见 7.10)。
4. 交互元素
4.1 页面标题与操作结果提示条
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 页面标题 | 页头 h2 | 页面身份标识 | 常驻 | 文案 Apollo 制品与渠道 | 无 | 与源码 h2 逐字一致 |
| 操作结果提示条 | 页头下方 | 展示最近一次操作结果 | alert.message 非空才显示;alert.type 决定样式(alert-info/alert-success/alert-error) | 成功用 success、失败用 error 展示消息 | 无 | 单槽覆盖:新操作覆盖旧提示,无历史列表 |
| 提示条「关闭」 | 提示条右侧(float:right) | 清空当前提示 | 提示条可见即可用 | alert.message = '' 立即消失 | 无 | 纯本地状态;showAlert(msg, type) 的 type 缺省 alert-info |
页面用一组 ref 维护状态:四个列表 bundles/channels/policies/desiredStates、全局互斥位 busy、提示 alert、验签结果缓存 verifyMap(键 = bundle id)与三个弹窗 verifyModal/channelEditModal/policyEditModal。所有写操作共用 busy:任一进行中,构建按钮、验签、下载、渠道/策略的创建与编辑保存按钮全部 :disabled="busy",防止并发写把列表状态弄乱;只有「删除」用 window.confirm 前置确认。
4.2 「构建 bundle」表单
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 应用(app)* | 表单第一行左一 | bundle 所属应用名 | 必填(required),placeholder 如 order-service | 提交时 trim 校验非空 | multipart 字段 app | 缺省会先被前端拦:app/version 必填且至少需要一个文件 |
| 版本(version)* | 第一行左二 | bundle 版本串 | 必填(required),placeholder 如 1.0.0 | 同上 | multipart 字段 version | 与 app 共同构成 bundle 命名 {app}-{version}-b{version} |
| bundle_version * | 第一行左三 | 构建序号 | 必填,type="number" min="1" 默认 1,v-model.number | 参与 BundleID 与期望状态 per-app 版本比对 | multipart 字段 bundle_version(字符串化) | 清空或非法由后端 parseInt 兜底默认 1 |
| 签名者(signer) | 第一行左四 | 指定签名者名称 | 非必填,默认占位 demo-signer(emptyBuildForm 初始值) | 非空才 append 字段 | multipart 字段 signer | 留空提交时后端 signerID == "" 也会兜底成 demo-signer;白名单找不到该名称时仍用 demo 私钥签名并把 manifest signer_id 记成所填名称(验签时以指纹匹配白名单) |
| 「+ 文件行」 | 「制品文件(文件键为 bundle 内路径)」子标题右侧 | 追加一条文件行 | 常驻 | buildForm.files.push({ path:'', file:null }) | 无 | 行数无上限;每条新行路径为空、文件未选 |
| 路径输入 | 每行左侧 | 该文件在 bundle 内的路径(即 multipart 键) | 非必填,placeholder bundle 内路径,如 config/app.yaml | 提交时 trim,留空回退为本地文件名(f.path.trim() || f.file.name) | 作为 FormData 的文件键 | 重复键会互相覆盖(后者胜,见 7.7);路径会被 sanitizeRelPath 二次清洗防止路径穿越 |
| 文件选择 | 每行中 input[type=file] | 选择本地文件 | 必选:file 为 null 的行在提交时被过滤 | @change 写入 f.file = e.target.files[0] | 经 FormData 上传 | 不选文件的行即使填了路径也不会上传;只有路径/只有文件键二者等价(空路径回退文件名) |
| 已选文件名 | 每行右侧 | 展示已选文件 | 选中后才出现 | 展示 f.file.name | 无 | 只读提示,与 multipart 文件名无关(文件键优先用「路径」输入) |
| 「删除」 | 每行末尾(btn-danger) | 移除当前文件行 | 常驻 | buildForm.files.splice(i, 1) | 无 | 行内删除不影响其他行;至少保留一行由前端保证不了(可删空,提交时校验兜底) |
| 「构建 bundle」 | 表单底部(btn-primary, submit) | 提交构建 | busy=false 才可点;构建中文案 构建中... | 校验通过发 multipart;成功提示 bundle 构建成功 id={id} digest={前16位}(签名者 {已签名/-})、重置表单为空行、loadAll() 刷新;失败提示 构建失败:{msg} | POST /bundles | 前端缺 app/version/文件时提示 app/version 必填且至少需要一个文件 并不发请求;后端另有校验(app/version 不能为空、未上传任何 bundle 文件)兜底 |
构建表单字段 → 后端:FormData 组装顺序为 app、version、bundle_version(String(bundle_version || 1))、可选 signer,随后每个文件以「其 bundle 内路径」为键逐条 fd.append(path, file)。后端 handleCreateBundle 先读 app/version(trim 空 → 400 app/version 不能为空),parseInt(bundle_version, 1) 兜底,再 parseFormFiles 解析全部上传文件——所以「文件键即 bundle 内路径」是该接口的第一语义:multipart 字段名不是固定文件名,而是发布时制品在 bundle 里的相对路径。
4.3 bundle 制品列表与「验签」
bundle 制品卡标题 bundle 制品。bundles.length===0 时居中灰字空态 暂无 bundle,请构建一个。;有数据显示表格,列头依次 ID / 名称 / 应用 / 版本 / bundle_version / digest / 签名者指纹 / 大小 / 验签 / 操作。列表由后端按 id 降序返回(最新构建在最上)。
| 控件/列 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| ID | 首列 | 记录主键 | - | 纯展示 | 无 | 每次构建即使内容一致也会插新行(digest 冲突行 upsert,见 5.4) |
| 名称列 | 第二列 | bundle 展示名 | 取值 b.name || b.app | 纯展示 | 无 | b.name = 后端存的 BundleID({app}-{version}-b{bundle_version});历史行若无 name 回退 app |
| digest | digest 列(cell-code) | 内容哈希 | shortDigest(b.digest) 显示前 16 位加 …,悬停 title 展示完整值;空显示 - | 纯展示 | 无 | digest = SM3(规范化 manifest + 文件字节按路径排序拼接),G7 唯一连接键,也是落盘目录名 |
| 签名者指纹 | 指纹列(cell-code) | 签名者公钥指纹 | 同 shortDigest 规则 | 纯展示 | 无 | 指纹 = SM3(公钥),是白名单匹配键;demo-signer 首次构建后为真实密钥指纹,seed 里的占位指纹会被自动升级 |
| 大小 | 大小列 | 制品字节数 | formatSize(b.size_bytes) | 纯展示 | 无 | 格式化:<1024 → n B;<1MB → x.x KB(1 位小数);否则 x.xx MB(2 位小数);空 → - |
| 「验签」 | 验签列(btn-sm btn-outline) | 触发完整验签链 | busy=false | 见下方验签行为 | POST /bundles/:id/verify | 需要 PermBundleWrite(只读角色会 403,见 7.4) |
| 可信徽标 | 验签按钮右侧 | 展示最近一次验签结论 | 仅 verifyMap[b.id] 存在时渲染 | verifyResultOk(...) 四项全为真 → 绿底 可信(verify-ok);否则红底 不可信(verify-bad) | 无 | 已修复(提交 412e9c48):徽标改为四项合取 trusted && files_ok && digest_ok && signature_ok(BundlePage.vue:355-357),不再单看 trusted(见 8 章 D2);验签失败/未验签该行无徽标 |
| 「下载 zip」 | 操作列(btn-sm btn-primary) | 下载归档 | busy=false | 见 4.4 | GET /bundles/:id/download(blob) | 无二次确认;bundle 被清理后下载会 404 |
验签行为(handleVerify):点「验签」→ busy=true → POST /bundles/:id/verify(无请求体)。成功时把解包后的结果对象存入 verifyMap[b.id] 并打开验签弹窗:标题 验签结果(bundle #{id}),正文 <pre class="code-block">{{ JSON.stringify(result, null, 2) }}</pre> 深色代码块完整展示 {files_ok, digest_ok, signature_ok, trusted, signer_name} 逐项结果。失败时不红条、而是同样打开弹窗:verifyMap[b.id] = { trusted:false, error: ... } 且弹窗内容为 { error: 错误信息 },行内徽标因此显示红底 不可信——即「验签失败也展示」(如 403 VERIFY_SIGNER_NOT_TRUSTED),这不是页面 bug 而是设计取舍:让用户看得见「为什么不可信」。失败后点开弹窗只能看到错误串,看不到四步明细(后端错误分支不返回部分成功项,见 5.4)。
4.4 「下载 zip」
| 控件 | 位置 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 「下载 zip」 | 行操作列 | 把 bundle 落盘目录打包下载 | busy=false;记录存在 | 成功后浏览器触发下载,提示 bundle #{id} 已下载(success);失败提示 下载失败:{msg} | GET /bundles/:id/download(responseType:'blob') | 文件名被前端硬编码覆盖为 bundle-{id}-{app}-{version}.zip(不采用后端 Content-Disposition 的 BundleID 名) |
下载实现:拿 blob → URL.createObjectURL → 构造隐藏 <a> 设 download → 点击 → 移除节点 → revokeObjectURL。后端把落盘目录(manifest.json + digest.sig + files/<path>…)整树 walk 进 zip,Content-Disposition: attachment; filename="{BundleID}.zip";前端 override 后实际保存名总带记录 id,避免同名 bundle 覆盖本地文件。注意:拦截器对 blob 原样放行,因此下载成功与否完全看 HTTP 状态——若记录文件已被后端清理策略删除,后端打包阶段报 打包 bundle 失败: ...(500)。
4.5 渠道创建表单与渠道列表(含指向期望状态)
「发布渠道(channels)」卡顶是创建表单(两行四个输入 + 按钮),下方 channel-table 渠道表格,列头 ID / 名称 / 指向期望状态 / 操作。列表由后端 id 升序返回(软删行自动过滤)。
创建渠道表单字段表:
| 字段 | 位置 | 类型 | 必填 | 校验规则 | 默认 | 保存逻辑 |
|---|---|---|---|---|---|---|
| 渠道名(name) | 首行左一 | 文本框 | 必填 | channelForm.name 为空 → 渠道名必填 拦截 | 无,placeholder 渠道名,如 stable | payload.name 原样提交 |
| 描述(description) | 首行左二 | 文本框 | 选填 | 无 | '' | 非空才进 payload(源码 description || '') |
| promotion_policy | 次行左一 | 文本框 | 选填 | 非空且 trim 后非空 → JSON.parse,非法抛错被 catch | 无,placeholder promotion_policy JSON,如 {"batch_percent":10} | parse 后的对象进 payload |
| approval_policy | 次行左二 | 文本框 | 选填 | 同上 | 无,placeholder approval_policy JSON,如 {"require_approval":false} | parse 后的对象进 payload |
| 「创建渠道」 | 表单下(btn-sm btn-primary) | 按钮 | busy=false | 后端:空名 → CHANNEL_INVALID 400;重名 → CHANNEL_CONFLICT 409 | - | 成功提示 渠道已创建 id={id}(success),清空表单并 loadAll();失败提示 创建渠道失败:{msg} |
渠道行内控件:
| 控件 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|
| ID / 名称 | 主键与加粗名称 | 纯展示 | - | 无 | 名称创建后不可改(编辑弹窗里 disabled) |
| 「指向期望状态」下拉 | 渠道当前发布目标 | :value="ch.current_desired_state_id || 0";选项含固定 未指向(值 0) 与各 active 期望状态 #{{ ds.id }} {{ ds.name }} | 切换即调 handleSetChannelDs | POST /channels/:id/set-desired-state(body {desired_state_id}) | 已修复(提交 412e9c48):选 0(未指向)经 confirm 确认后提交 desired_state_id=0 解除指向、取消则回显当前指向(BundlePage.vue:548-572;后端 hub/channel.go:158-165 支持 0 清空指向);下拉选项仅列 active 期望状态,当前指向已非 active 时保留展示并标注状态(desiredStateOptions,:360-374) |
| 「编辑」 | 打开编辑渠道弹窗 | busy=false | openEditChannel(ch) 预填弹窗(描述与两个策略 JSON.stringify) | 无(仅本地) | 保存见 4.6 |
| 「删除」 | 删除渠道 | busy=false | confirm('确定删除渠道 "{name}" 吗?') 确认后删除 | DELETE /channels/:id | 软删(deleted_at),成功提示 渠道已删除 并刷新;失败 删除渠道失败:{msg};确认框取消则什么都不发生 |
指向期望状态的结果语义:切换下拉 → busy=true → POST。选 未指向(0) 时先 confirm 解除指向;成功提示 渠道 "{name}" 已指向期望状态 #{desired_state_id}(选 0 时提示 已解除指向(未指向任何期望状态))(success)并刷新;失败提示 指向失败:{msg}(选 0 时 解除指向失败:{msg})且也刷新一次(让下拉回到后端实际值)。后端中 desired_state_id=0 直接清空指向(幂等),非 0 要求目标期望状态存在且 status=active(审批流未过审的也不行,见 IsApprovedForDeploy),否则 400 期望状态 N 未激活(当前状态 X),不可作为通道期望状态(hub/channel.go:153-181)。渠道指向变化会直接影响 Agent 的 Pull 目标选择(见 5.4 渠道机制)。
4.6 「编辑渠道」弹窗
channelEditModal.visible 为 true 时以 modal-overlay 覆盖层出现,点遮罩(@click.self)或「关闭」可关。标题 编辑渠道 "{{current.name}}"。
| 字段 | 位置 | 含义 | 可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 渠道名(name,不可修改) | 首行 | 只读展示 | disabled 输入框(:value="current?.name") | 不可输入 | 无 | 改名只能删除重建;payload 仍带原名(后端忽略 name) |
| 描述(description) | 次行 | 渠道描述 | 可编辑,placeholder 渠道描述 | 非空才进 payload | - | 清空描述后保存 = 提交 description:'',后端会更新为空串 |
| promotion_policy(JSON) | 第三行 | 渐进发布策略 JSON | 可编辑,placeholder 如 {"batch_percent":10} | trim 非空才 parse | - | 留空则 payload 不含该字段 |
| approval_policy(JSON) | 第四行 | 审批策略 JSON | 可编辑,placeholder 如 {"require_approval":false} | 同上 | - | 同上 |
| 「保存渠道」 | 弹窗底左 | 提交 | busy=false;进行中文案 保存中... | 成功提示 渠道 "{name}" 已更新(success)、关闭弹窗、loadAll();失败 保存渠道失败:{msg} 且弹窗不关 | PUT /channels/:id | payload {name, description, promotion_policy?, approval_policy?};后端对策略字段做非空才更新的局部更新 |
| 「取消」 | 弹窗底右(btn-outline) | 关闭弹窗 | 常驻 | channelEditModal.visible = false | 无 | 放弃未保存改动,无确认 |
注意与创建的差异:后端 UpdateChannel 只更新描述与两个策略(name 参数在 handler 里根本没传),策略字段传空对象/空串时是幂等跳过而非清空——因此想用编辑弹窗把某个已填的策略清掉是做不到的(源码只对 trim 非空才 parse、后端也只对非空更新),这是「无法清空策略字段」边界(见 8 章)。
4.7 策略创建表单与策略列表
「部署策略(policies)」卡顶创建表单(名称 + rules + 按钮),下方表格列头 ID / 名称 / 规则 / 操作,列表 id 升序。
| 控件 | 含义 | 必填/默认/可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|
| 策略名 | placeholder 策略名,如 default | 必填 | 空 → 策略名必填 拦截 | payload.name | 名称唯一、不可修改 |
| 规则(rules) | placeholder rules JSON,如 {"auto_rollback":true} | 选填 | trim 非空才 JSON.parse | 空则不传 rules 字段,后端落默认规则 | 非法 JSON 抛 SyntaxError → 创建策略失败:... |
| 「创建策略」 | btn-sm btn-primary | busy=false | 成功 策略已创建 id={id}(success)、清空、刷新 | POST /policies | 后端:空名 策略名称不能为空 400;重复名冲突 409;rules 非法 策略规则非法 JSON: ... 400 |
| 规则列展示 | 表格中 <code class="cell-code rules-cell"> | 展示 JSON.stringify(p.rules || {}) | 截断省略 | 纯展示 | 悬停无 title 全文,超宽省略;rules 为空(旧行)显示 {} 但实际解析用默认值 |
| 「编辑」 | btn-sm btn-outline | busy=false | 打开编辑策略弹窗(见 4.8) | - | - |
| 「删除」 | btn-sm btn-danger | busy=false | confirm('确定删除策略 "{name}" 吗?') | DELETE /policies/:id | 成功 策略已删除;失败 删除策略失败:{msg} |
默认规则说明:新建策略不填 rules(或后端发现 rules 为空)时,后端把 DefaultPolicyRules() 序列化落库——即 {"auto_rollback":true,"max_concurrent":2,"readiness_timeout_sec":60,"reconcile_backoff":{"initial_s":5,"multiplier":2,"max_s":300}}。因此本页创建的每条策略保存后都会带一套完整 JSON,而非空对象。
4.8 「编辑策略」弹窗与「验签结果」弹窗
编辑策略弹窗(policyEditModal):标题 编辑策略 "{{current.name}}";字段表:
| 字段 | 位置 | 含义 | 可用条件 | 操作效果 | 触发后端调用 | 边界与细节 |
|---|---|---|---|---|---|---|
| 策略名(name,不可修改) | 首行 | 只读展示 | disabled | 不可输入 | - | 改名需删除重建 |
| rules(JSON) | 次行 | 策略规则 | textarea rows="5" spellcheck=false,编辑时以 JSON.stringify(p.rules, null, 2) 格式化填充 | trim 非空才 parse | PUT /policies/:id | 空 rules 提交 → 后端重置为默认规则(UpdatePolicy 空 rules 会 DefaultPolicyRules().ToJSON()) |
| 「保存策略」 | 底左 | 提交 | busy=false,中文案 保存中... | 成功 策略 "{name}" 已更新、关弹窗、刷新;失败 保存策略失败:{msg} 不关弹窗 | - | 修改在 textarea 里手写,无 schema 校验 |
| 「取消」 | 底右 | 关闭 | 常驻 | 放弃未保存改动 | - | 无确认 |
验签结果弹窗(verifyModal):标题 验签结果(bundle #{id}),正文 <pre class="code-block">{{ JSON.stringify(result, null, 2) }}</pre>,result 为验签成功时的 VerifyResult({files_ok,digest_ok,signature_ok,trusted,signer_name})或失败时的 {error: "..."}。深色等宽代码块 white-space: pre-wrap 会折行,超长 digest/错误串也能完整看到。「关闭」或点遮罩关闭,无二次操作入口。
5. 后端关联
5.1 API 客户端
action/web/src/api/apolloClient.js:axios 实例 baseURL /apollo-api/v1、timeout 30000ms、默认 Content-Type: application/json(构建 bundle 时显式覆盖为 multipart/form-data)。请求拦截器对所有请求附加 Authorization: Bearer <token>(仅取 apollo_token,不回退 aip_token)。响应拦截器:HTTP 2xx 且 code===0 → 把 response.data 解包为业务数据;HTTP 2xx 但 code 非 0 → reject;4xx/5xx → 从 body.message 提取并 reject(401 清 apollo_token 并跳 /apollo/login(登录页自身 401 不跳防死循环),403 alert('无权限执行该操作')),同时把服务端 message 别名写入 error.response.data.error 与 error.message——页面 catch 统一用 err.response?.data?.error || err.message 取文案。blob/arraybuffer 响应(zip 下载)原样放行不解包。
5.2 端点表
| 方法 | 路径 | 权限 | 请求体/Query | 页面触发点 |
|---|---|---|---|---|
| GET | /bundles | PermBundleRead | - | 加载 bundle 制品列表(id 降序) |
| POST | /bundles | PermBundleWrite | multipart:app/version/bundle_version/signer + 以路径为键的文件 | 「构建 bundle」 |
| GET | /bundles/:id/download | PermBundleRead | -(响应 zip blob) | 「下载 zip」 |
| POST | /bundles/:id/verify | PermBundleWrite | Query 可选 environment_id(页面不传) | 「验签」 |
| POST | /bundles/cleanup/run | PermBundleWrite | - | 无 UI 入口(后端/脚本触发,见 5.4) |
| GET | /channels | PermChannelRead | - | 加载渠道列表(id 升序) |
| POST | /channels | PermChannelWrite | JSON {name, description?, promotion_policy?, approval_policy?} | 「创建渠道」 |
| PUT | /channels/:id | PermChannelWrite | JSON 同上(name 忽略,仅更新非空 description/策略) | 「保存渠道」 |
| DELETE | /channels/:id | PermChannelWrite | - | 「删除」(软删) |
| POST | /channels/:id/set-desired-state | PermChannelWrite | JSON {desired_state_id} | 「指向期望状态」下拉 |
| GET | /policies | PermPolicyRead | - | 加载策略列表(id 升序) |
| POST | /policies | PermPolicyWrite | JSON {name, rules?}(空 rules 落默认) | 「创建策略」 |
| PUT | /policies/:id | PermPolicyWrite | JSON {rules?}(空 rules 重置默认) | 「保存策略」 |
| DELETE | /policies/:id | PermPolicyWrite | - | 「删除」 |
| GET | /desired-states | PermDesiredStateRead | Query 可选 app(页面不带,全量) | 「指向期望状态」下拉选项 |
所有端点注册于 action/products/apollo/server/server.go protected 组(约 746-765 行)。返回形状:创建类 201 {id}(bundles 除外——返回完整记录)、修改类 {id}、删除类 {id, deleted:true}、指向类 {id, desired_state_id}。
5.3 响应结构示例
GET /bundles(envelope 解包后为数组,单项 BundleRecord):
[
{
"id": 7,
"name": "demo-app-1.0.0-b2",
"app": "demo-app",
"version": "1.0.0",
"bundle_version": 2,
"digest": "8f3a1c2e5b9d0476aa11cc22dd33ee44ff55aa66bb77cc88dd99ee00ff11aa22",
"signer_id": "demo-signer",
"signer_fingerprint": "c9d8...",
"size_bytes": 4096,
"created_at": "2026-09-07T03:12:00+08:00"
}
]
字段含义(server/models.go BundleRecord):id 主键;name 即 BundleID({app}-{version}-b{bundle_version});digest bundle 内容哈希(size:128;uniqueIndex,G7 唯一连接键);signer_fingerprint = SM3(公钥) 指纹(白名单匹配键,展示列悬停全文);size_bytes 各文件字节和;dir 字段 json:"-" 不外发(落盘路径只在服务端用,下载/验签按 id 反查)。
POST /bundles(201,envelope 解包后即 BundleRecord):响应就是刚登记的完整记录(同列表单项),页面 toast 用其中的 rec.id/rec.digest/rec.signer_fingerprint。成功时 digest 冲突走 upsert:digest 唯一索引 OnConflict 只更新 size_bytes/dir 两列,id 保持首次登记的旧值。
POST /bundles/:id/verify(200):
{
"files_ok": true,
"digest_ok": true,
"signature_ok": true,
"trusted": true,
"signer_name": "demo-signer"
}
字段含义(hub/bundle.go VerifyResult):files_ok 逐文件重算 SM3 校验和与大小对照 manifest(含文件数与路径覆盖检查);digest_ok 用 manifest+文件重算 digest 对照 manifest.Digest;trusted 白名单信任锚命中(指纹存在且 enabled=true)——注意它不隐含其它三项通过;signer_name 命中的白名单签名者名(omitempty)。400/403 错误分支:制品加载失败 400 BUNDLE_INVALID;指纹不在白名单(或 enabled=false)→ HTTP 403,envelope 保留领域码与原始 message(见下,页面 catch 把 message 展示在弹窗):
{
"code": "VERIFY_SIGNER_NOT_TRUSTED",
"message": "签名者指纹 \"c9d8...\" 不在可信白名单(enabled=true)中,拒绝应用",
"request_id": "req_1725600000000abcd"
}
GET /channels(envelope 解包后为数组,单项 ReleaseChannel):
[
{
"id": 1,
"name": "stable",
"description": "演示发布通道(指向 demo-app)",
"current_desired_state_id": 3,
"promotion_policy": { "batch_percent": 10 },
"approval_policy": { "require_approval": false },
"created_at": "2026-09-07T02:00:00+08:00",
"updated_at": "2026-09-07T02:00:00+08:00"
}
]
current_desired_state_id 为 0 或缺失即「未指向」(页面 select 映射成 未指向);两个策略字段 omitempty,未填的渠道行不会出现该键。set-desired-state 响应 {id: 1, desired_state_id: 3}(页面 toast 取 data.desired_state_id)。
GET /policies(envelope 解包后为数组,单项 DeploymentPolicy):
[
{
"id": 1,
"name": "default",
"rules": {
"auto_rollback": true,
"max_concurrent": 2,
"readiness_timeout_sec": 60,
"reconcile_backoff": { "initial_s": 5, "multiplier": 2, "max_s": 300 }
},
"created_at": "2026-09-07T02:00:00+08:00",
"updated_at": "2026-09-07T02:00:00+08:00"
}
]
GET /desired-states(下拉选项数据源):页面只消费每项的 id 与 name(渲染 #{{ ds.id }} {{ ds.name }}),但后端返回整行 DesiredState(含 status/approval_status/digest/version 等,见下方折叠),页面未做任何筛选/状态展示。
GET /desired-states 单项示例(DesiredState 全字段)
{
"id": 3,
"name": "demo-app",
"description": "",
"app": "demo-app",
"version": "1.0.0",
"bundle_version": 1,
"digest": "8f3a1c2e5b9d0476...",
"signer_id": "demo-signer",
"declaration": { "app": "demo-app", "version": "1.0.0", "name": "demo-app", "channel": "stable", "components": [ ... ] },
"status": "active",
"approval_status": "",
"created_by": "system",
"created_at": "2026-09-07T02:00:00+08:00",
"updated_at": "2026-09-07T02:00:00+08:00"
}
5.4 关键机制
① bundle 打包与签名(构建即签名,契约④)。POST /bundles 的完整链路:handleCreateBundle 校验后 parseFormFiles 把 multipart 解成 []BundleSourceFile{Path,Data}(Path = 文件键,空键回退 fh.Filename);bundleStore.build 里若请求 signer 为空自动兜底 demo-signer,随后 ensureSigner() 惰性取演示签名者——首次构建时 crypto.GenerateSM2KeyPair() 生成 SM2 密钥对(私钥仅存进程内存),计算指纹 SignerFingerprint = SM3(公钥字节) 并幂等 upsert trusted_signers(name 冲突更新 public_key_fingerprint/public_key/enabled/description,行名固定 demo-signer、描述「演示签名者(服务器首次构建 bundle 时自动注册,G1 信任锚)」),因此 seed 里那只占位指纹 demo-fingerprint-placeholder 会在第一次构建后被真实指纹顶替,白名单自洽。随后 hub.BuildBundle 四步:① 枚举文件生成清单 Files[](path/size/SM3 校验和);② 由文件路径推导组件清单 Components[](classifyComponentKind:路径含 ontology→ontology_yaml、llm_route/llm-route/llmroute→llm_route、含 eval→eval_set、含 config 或以 .yaml/.yml 结尾→config、否则→binary;组件名取文件基名去扩展名);③ digest = SM3(manifest 规范化 JSON(digest 置空防自引用)+ 全部文件字节按 Path 字典序拼接)(G7 不可变连接键);④ 用 SM2 私钥对 digest 签名,manifest 记 signer_fingerprint 与 signed_at。之后制品落盘:目录 = ./temp/apollo_bundles/<digest>(bundleDirBase,相对进程工作目录),内部结构为 manifest.json(json.MarshalIndent 缩进)+ digest.sig(签名 hex 字符串)+ files/<sanitized路径>(每文件先 sanitizeRelPath 防穿越再 MkdirAll 写盘);记录登记 bundles 表(name=BundleID、digest 唯一索引、size_bytes = 清单各文件字节和)。
② multipart 上传的边界与静默截断。parseFormFiles 先 r.ParseMultipartForm(32 << 20)(整请求内存/磁盘缓冲上限 32MB,超限后端报解析错误),再对每个上传文件 io.ReadAll(io.LimitReader(f, 8<<20))——单文件超过 8MB 会被静默截断到 8MB:字节被砍掉,但后端不比对原长度、不报错,只会构建出一个「manifest 里 size 与你上传的不一致」的制品。前端无法从响应发现截断(页面 formatSize 展示的是截断后清单里的大小)。已加前端前置拦截(提交 412e9c48):选文件时即按 MAX_FILE_SIZE(8MB) 预校验并提示(BundlePage.vue:398-413),从入口避免上传超限文件(见 8 章)。另外 multipart 以「文件键」为字段名,若两个文件行填了相同路径,后 append 的会覆盖先 append 的(同名键最后一次生效),前端行内不提示重复(见 7.7)。
③ 验签链与「可信」语义(G1)。POST /bundles/:id/verify(可选 ?environment_id=,页面不传,走全局白名单)→ store.verify 用 LoadBundleFromDir 从落盘目录读回制品 → hub.VerifyBundle/VerifyBundleScoped 按序执行:1) verifyFiles 逐文件重算 SM3 与大小对照 manifest(文件数、路径覆盖都查)→ files_ok;2) 重算整体 digest 对照 manifest.Digest → digest_ok;3) 用 manifest.SignerFingerprint 在白名单查(环境级 scope 时环境级优先回退全局,需 enabled=true)——命中即 trusted=true 并记 signer_name,未命中/禁用直接返回 HTTP 403(领域码 VERIFY_SIGNER_NOT_TRUSTED、message 含指纹);4) 有签名且 digest 非空才用白名单公钥做 SM2 验签 → signature_ok(无签名时 signature_ok=false 但仍 200 返回)。需要点破的是:后端 trusted 字段只代表「签名者进了白名单」,不等于四步全过——若落盘文件被人为改过,步骤 1/2 会置 files_ok=false/digest_ok=false 但响应仍 200、trusted=true(签名是对 manifest 里存的 digest 验的,digest 字段没变就验得过)。已修复(提交 412e9c48):页面徽标改为四项合取判定(verifyResultOk = trusted && files_ok && digest_ok && signature_ok,BundlePage.vue:355-357),并且验签弹窗把四项分开展示「通过/失败」徽标(BundlePage.vue:284-304),因此这类"内容被篡改但签名者仍可信"的制品不再误显示绿徽标。对本页演示路径(构建即验签)四步恒全过、白名单恒命中,结论与直觉一致;异常只在制品被外部改盘/白名单变动时出现。
④ bundle 生命周期清理(本页无 UI,后端保留策略驱动)。POST /bundles/cleanup/run 会先 ensureDefaultBundlePolicy:若不存在 (project_id=0, scope=bundle) 的清理策略则创建 KeepCount=10(artifact_cleanup_policies 表);再读策略(enabled=false 则跳过)按每 app 分组 CreatedAt DESC 保留最近 N 个,叠加 keep_days/keep_tags 过滤,标记过期 bundle 后删除记录与本地目录。引用保护:desired_states.digest(G7 连接键)与 artifacts.bundle_id 引用中的 bundle 强制保留;删除目录前 dirWithinBundleBase 校验目标在 ./temp/apollo_bundles 内(纵深防御,防 DB 的 Dir 被篡改指向任意目录),trusted_signers 白名单不随 bundle 删除。该端点显式写审计 BUNDLE_CLEANUP_RUN(scanned/deleted/freed_bytes)。演示中若发现 bundle「自己不见了」,多半是有人调过清理或按保留策略过期,本页没有恢复入口。
⑤ 渠道机制:软删、唯一名、指向须 active、策略是存储而非执行。release_channels 用 GORM DeletedAt 软删,列表自动过滤;name 唯一索引,重名创建返回 409(CHANNEL_CONFLICT),空名 400(CHANNEL_INVALID)。SetChannelDesiredState 在 desired_state_id=0 时直接清空当前指向并落库(幂等),非 0 时校验期望状态存在且 status=active(RequiresApproval/审批态见下)(hub/channel.go:153-181)——页面选「未指向」经确认即解除指向,前后端两处均已打通。promotion_policy/approval_policy 在此仅作 JSON 存储:审批 gate(approval_status 驱动 Spoke Pull 拦截)在期望状态审批端点(G15)执行,渐进发布的 rollout 百分比在编排侧消费,本页不触发执行,只负责把它填进渠道。Seed 里自动创建渠道 stable(描述「演示发布通道(指向 demo-app)」)并指向 demo 期望状态,若其指向已被清/弃用则 seed 会重置指向 demo——即 demo 环境里 stable 渠道永远是「有指向」的,演示 Pull 目标选择因此恒能命中第二优先级。
⑥ 部署策略机制:空规则落默认、按名解析兜底。policies 表 rules 为 JSON;CreatePolicy/UpdatePolicy 遇到空 rules 都会 DefaultPolicyRules().ToJSON()(auto_rollback:true、max_concurrent:2、readiness_timeout_sec:60、reconcile_backoff{initial_s:5,multiplier:2,max_s:300}),非法 JSON 400(策略规则非法 JSON: ...);重名创建冲突(名称唯一索引)。部署编排按策略名解析:ResolveRulesByPolicyName 找到则 ResolvePolicyRules(字段级默认值兜底),找不到或解析失败直接读 DefaultPolicyRules()——策略被删不会让部署崩溃,只是退回默认行为。Seed 创建 default 策略供部署引用。
⑦ 前端并行加载与互斥。loadAll 用 Promise.all 并行拉 GET /bundles、/channels、/policies、/desired-states 四份,每份 .catch(() => ({data:[]}))——任一接口失败静默降级为空数组,不提示(例如无 PermDesiredStateRead 权限时下拉会空、但其余三卡正常)。页面 onMounted 与每个写操作成功/失败(部分)回调都会 loadAll();busy 全局互斥保证「构建/验签/下载/渠道写/策略写」不会并发交叠,避免提示条互相覆盖与列表竞态。因为没有独立的列表刷新按钮,若四份数据之一在别处被改,需要在本页做一次写操作或刷新页面才能看到新值。
6. 权限与安全
- 认证分层:全部端点 protected(Bearer JWT / X-API-Key),401 由 apolloClient 清 token 跳
/apollo/login;未登录直接访问路由被前端守卫拦到登录页。不存在公开写端点,bundle 下载也要求登录。 - 权限点细化:bundle 读写分离(读
PermBundleRead、写/验签PermBundleWrite);渠道与策略各带读写分离;期望状态下拉只读。值得注意的是验签属只读诊断动作却落在 Write 权限,只读运维角色会因此 403(7.4)。 - 写操作防护:删除渠道/删除策略均前置
window.confirm;JSON 输入提交前JSON.parse(非法不入 payload);表单校验(name 必填、app/version/文件至少一行)在前端完成,后端同规则兜底。渠道/策略名不可改(编辑弹窗 disabled)降低误改名风险。 - 制品侧纵深防御(后端):落盘路径固定
./temp/apollo_bundles/<digest>,删除前dirWithinBundleBase校验;bundle 目录内的文件写盘前sanitizeRelPath防路径穿越;multipart 表单字段名被当作路径但不会逃逸目录——这些属于后端加固,页面侧不暴露原始路径。 - 审计覆盖:本页 CRUD 均落在 HTTP 审计中间件(
http_audit.go)全量记录范围(body 按 content-type 白名单捕获、multipart/下载跳过 body 但记录请求);cleanup/run另有显式BUNDLE_CLEANUP_RUN审计行。在「审计日志」页可按此检索本页操作。
7. 常见问题与排错
以下问题均从 BundlePage.vue 提示分支、后端 handler/服务层错误码与既有测试归纳,可按步骤复现。
问题一:点「构建 bundle」提示 app/version 必填或缺少文件
现象:点「构建 bundle」无反应或提示 app/version 必填且至少需要一个文件。
原因:app 或 version 为空,或所有文件行都没选中本地文件(file 为 null 的行在 handleBuild 里被 filter((f) => f.file) 过滤掉)。
处理:填全 app/version,至少给一行点选真实文件;路径可留空(后端回退文件名),但不能只填路径不选文件。
问题二:构建成功但提示里签名者是「-」,或验签报 VERIFY_SIGNER_NOT_TRUSTED
现象:构建成功提示文案的签名者取记录 signer_fingerprint 是否存在(已签名/-);若为 - 通常指该记录没带指纹。
原因:验签 403 的最常见诱因是白名单里没有该指纹的 enabled 签名者——例如 seed 重建过、或有人在签名者白名单页删/禁用了 demo-signer。
处理:到「签名者白名单」页确认 demo-signer 存在且 enabled;用默认 signer 重建一个 bundle 即可(重建会触发 ensureSigner 重新注册)。注意验签 403 时点「验签」会弹窗展示错误而非绿徽标,这是预期的失败展示路径。
问题三:「验签」按钮点了没有提示、列表整块空
现象:验签失败也会弹窗+出红徽标,不会无声;若 bundle 卡直接空,多半是列表数据没加载到。
原因:GET /bundles 401/403 被静默降级成空数组(loadAll 里 .catch(() => ({data:[]})) 不提示)。
处理:打开浏览器 Network 看 /apollo-api/v1/bundles 响应;401 会先跳登录页,403 会有 alert('无权限执行该操作'),确认账号具备 PermBundleRead。
问题四:点「验签」弹 alert「无权限执行该操作」
现象:点「验签」被 403。
原因:验签端点后端注册为 PermBundleWrite(POST /bundles/:id/verify,server/server.go:767),而账号只有读权限。
处理:这是后端路由设计(验签被归为写类动作),用具备 bundle:write 权限的角色操作;若认为读角色也该能验签,需后端把路由改为 PermBundleRead(见 8 章 D5,前端无法改变)。
问题五:渠道「指向期望状态」报「期望状态 N 未激活(当前状态 draft)」
现象:选了状态后提示 指向失败:期望状态 3 未激活(当前状态 draft),不可作为通道期望状态。
原因:下拉通常只列 active 状态,但当前指向若已非 active 仍会保留展示,或后端状态在打开下拉后被改变。
处理:到期望状态管理侧激活目标状态(需其审批 approval_status 为空或 approved)再回来重选;选「未指向」经确认即解除指向(BundlePage.vue:548-572,后端 hub/channel.go:158-165 支持 0 清空)。
问题六:渠道/策略 JSON 保存时报英文 SyntaxError 类错误
现象:提示 ...:Unexpected token 'x', "..." is not valid JSON 一类英文报错。
原因:promotion_policy/approval_policy/rules 输入框里的内容不是合法 JSON,JSON.parse 抛 SyntaxError,被 catch 拼进失败提示(创建渠道失败:/保存渠道失败:/创建策略失败:/保存策略失败: + 原始解析器报错)。
处理:已修复(提交 412e9c48):前端改用 tryParseJSON(text, label)(BundlePage.vue:378-396),解析失败返回中文提示并按 position 定位换算成 第 N 行第 M 列附近语法错误(如 promotion_policy 不是合法 JSON:第 1 行第 5 列附近语法错误),不再透传英文 SyntaxError。按 placeholder 的完整对象格式填写(如 {"batch_percent":10}、{"auto_rollback":true})。
问题七:同路径文件互相覆盖
现象:同一个 bundle 里放了两个同名路径文件,构建出的制品只有一个。
原因:multipart 以「文件键」为字段名,相同路径会互相覆盖(后 append 的生效),后端 digest 也是按清单路径唯一计算的。
处理:构建前检查文件行路径不重复;这不是报错场景,后端不提示。另外若你期望文件放进子目录,路径应写 config/app.yaml 这类相对路径(会落盘到 files/config/app.yaml 并出现在 zip 的对应子目录)。
问题八:下载失败或 zip 里缺文件
现象:提示「下载失败」,或下载下来的 zip 里文件不完整。
原因:下载失败通常是记录 id 对应的落盘目录已不在(bundle 被清理策略删除),后端打包抛 500 打包 bundle 失败: ...。缺文件则要怀疑上传时就超了 8MB 单文件上限被静默截断(见 5.4-②)——zip 里文件是截断后的内容,manifest size 也对不上你的原文件。
处理:已修复(提交 412e9c48):前端选文件时即按 MAX_FILE_SIZE(8MB)预校验,超限文件会清空选择并提示「…超过后端单文件 8MB 上限,会被静默截断,请拆分后再上传」(BundlePage.vue:398-413),从入口拦住截断。若仍见文件缺失,确认构建成功提示里大小列是否与本地文件一致;被清理的 bundle 只能重新构建,本页无恢复入口。
问题九:编辑渠道想清空策略字段清不掉
现象:清空 promotion_policy 保存后再打开它还在。
原因:前端只对 trim 非空的 JSON 才 parse 进 payload,后端 UpdateChannel 也只在字段非空时更新——两个策略字段都没有「传空清除」通道。
处理:属后端契约边界(见 8 章「无法清空策略字段」),临时办法是删除渠道重建(不填策略);注意这与「解除渠道指向」不同,后者已支持(下拉选「未指向」)。
问题十:页面某一块列表为空但其它正常
现象:四个卡中某一个列表为空,其它正常。
原因:loadAll 四接口任一失败都会静默降级为对应空数组,且页面没有手动刷新按钮。
处理:刷新整页重拉(onMounted 会再次 loadAll);仍空则看 Network 定位失败的接口与状态码——例如缺 PermDesiredStateRead 时「指向期望状态」下拉为空,但渠道卡仍正常(静默降级属已知边界,见 5.4-⑦)。
问题十一:改动渠道/策略后另一处没生效
现象:策略改了但部署表现不变,或渠道改名后 stable 链路不认。
原因:策略在部署发起/Advance 时按名称实时解析(ResolveRulesByPolicyName);渠道指向变化要在 Agent 下一次 Pull 时才体现。Pull 目标选择只认名为 stable 的渠道(见 5.4-⑤ 与 agents 页 5.4),改渠道名需要删除重建。
处理:策略改完保存立即生效,无需重启;需要新渠道进入发布链路时保持名称为 stable。
8. 已知缺陷与边界
| 缺陷/边界 | 说明 |
|---|---|
| D1「未指向」选项不可用(已修复) | 已修复(提交 412e9c48):前端选「未指向」经 confirm 后提交 desired_state_id=0 解除指向、取消则回显当前指向(BundlePage.vue:548-572),后端 SetChannelDesiredState 对 0 直接清空指向并落库(hub/channel.go:158-165)——渠道现可在本页解除指向 |
| D2 验签徽标只取 trusted(已修复) | 已修复(提交 412e9c48):徽标改为四项合取 trusted && files_ok && digest_ok && signature_ok(BundlePage.vue:355-357),验签弹窗分项展示四项「通过/失败」(:284-304);后端 trusted 语义(仅白名单命中)不变,但页面不再单看它 |
| D3 期望状态下拉不过滤(已修复) | 已修复(提交 412e9c48):下拉仅列 status==='active' 期望状态(当前指向已非 active 时保留展示并标注状态,desiredStateOptions,BundlePage.vue:360-374);后端仍只允许指向 active(hub/channel.go:173-177) |
| D4 JSON 输入无友好校验(已修复) | 已修复(提交 412e9c48):tryParseJSON(text, label) 解析失败返回中文 {label} 不是合法 JSON:第 N 行第 M 列附近语法错误(BundlePage.vue:378-396),替代裸 JSON.parse 的英文 SyntaxError |
| D5 验签挂在写权限(现实边界) | POST /bundles/:id/verify 后端注册为 PermBundleWrite(server/server.go:767),语义是只读诊断却要求写权限,最小权限的只读角色无法验签。属后端路由契约边界,需后端改 PermBundleRead 才能收口(前端无法改变) |
| 无法清空策略字段(现实边界) | 创建/编辑渠道时两个策略 JSON 为空即不传字段,后端 UpdateChannel 只更新非空——不存在「显式清空 promotion_policy/approval_policy」的通道;编辑渠道描述与策略互相独立,保存渠道只发 description 或策略的局部更新(后端契约边界) |
| 单文件 8MB 静默截断(前端已加预校验) | 后端 io.LimitReader(f, 8<<20) 静默截断超大文件且不比对原长度;multipart 整请求 32MB 上限超限则直接解析失败(后端边界)。前端已加前置拦截(提交 412e9c48):选文件时按 MAX_FILE_SIZE(8MB) 预校验,超限清空并提示(BundlePage.vue:398-413) |
| 无列表刷新/分页 | 四列表均一次性全量(bundle 数大时表格会很长),页面无手动刷新按钮、无分页、无搜索/过滤;期望状态「按 app 过滤」能力未暴露 |
| 空态缺指引 | bundle/渠道/策略/期望状态各自空态文案不一(bundle 有 暂无 bundle,请构建一个。,渠道与策略空表体无文案),首次进入对「怎么建渠道、怎么建策略」无引导 |
| 并发约束 | 全部写操作共用 busy 串行;删除渠道/策略虽有 confirm,但渠道被删除时若有部署/Agent 依赖其指向,指向随软删清空,无预警 |
附录 A:速查——页面文案与关键常量清单
| 场景 | 源码取值/文案(逐字) |
|---|---|
| 页面标题 | Apollo 制品与渠道 |
| 卡片标题 | 构建 bundle / bundle 制品 / 发布渠道(channels) / 部署策略(policies) |
| 构建表单标签 | 应用(app)* / 版本(version)* / bundle_version * / 签名者(signer) |
| 构建 placeholder | 如 order-service / 如 1.0.0 / 如 demo-signer |
| 文件区标题 | 制品文件(文件键为 bundle 内路径) |
| 路径 placeholder | bundle 内路径,如 config/app.yaml |
| 构建按钮 | 构建 bundle / 构建中... / + 文件行 / 删除 |
| 构建成功提示 | bundle 构建成功 id={id} digest={前16位}(签名者 {已签名|-}) |
| 构建失败提示 | 构建失败:{msg} |
| 前端校验文案 | app/version 必填且至少需要一个文件 |
| 空态 | 暂无 bundle,请构建一个。 |
| 验签按钮/徽标 | 验签 / 可信(verify-ok 绿)/ 不可信(verify-bad 红) |
| 下载 | 下载 zip / 成功 bundle #{id} 已下载 / 失败 下载失败:{msg} |
| 验签弹窗标题 | 验签结果(bundle #{id}) |
| 渠道 placeholder | 渠道名,如 stable / 描述 / promotion_policy JSON,如 {"batch_percent":10} / approval_policy JSON,如 {"require_approval":false} |
| 渠道按钮/提示 | 创建渠道 / 渠道已创建 id={id} / 渠道已删除 / 渠道名必填 / 请选择有效的期望状态 |
| 渠道指向提示 | 渠道 "{name}" 已指向期望状态 #{desired_state_id} / 指向失败:{msg} |
| 渠道编辑弹窗 | 标题 编辑渠道 "{name}" / 标签 渠道名(name,不可修改) / 保存渠道 / 保存中... / 取消 / 关闭 |
| 策略 placeholder | 策略名,如 default / rules JSON,如 {"auto_rollback":true} |
| 策略按钮/提示 | 创建策略 / 策略已创建 id={id} / 策略已删除 / 策略名必填 / 编辑标题 编辑策略 "{name}" / 保存策略 |
| 默认策略规则 | {"auto_rollback":true,"max_concurrent":2,"readiness_timeout_sec":60,"reconcile_backoff":{"initial_s":5,"multiplier":2,"max_s":300}} |
| bundle 落盘目录 | ./temp/apollo_bundles/<digest>(bundleDirBase) |
| BundleID 命名 | {app}-{version}-b{bundle_version}(manifest.schema_version=1.1) |