测试环境发布控制台
统一管理测试环境发布、任务状态和版本回退。
- 当前操作人
- 示例用户
- 发布分支
dev- 最新 commit
a1b2c3d- 当前任务
- 暂无进行中的任务
发布控制
a1b2c3d最近被催发版催得有点烦。
发版本身不复杂,麻烦的是群里隔一会儿就有人来一句:
“帮忙发一下测试。”
然后我就得停下手里的事情,确认分支,确认 commit,确认发哪个站点,确认有没有人在发,发完还要再去看一下是不是成功。中间哪一步没说清楚,过一会儿还得补一句:
“刚才发的是哪个版本?”
这类事情做一次两次没什么,天天做就很像人工流水线。
后来我懒得再当这个人工中转站了,就做了一个飞书里的发布控制台:@ 一下机器人,选范围、看 commit、确认发布,后面还能查任务和回退版本。
这篇就记一下我是怎么做的,以及中间遇到的几个问题。
先说明一下:文中的服务器地址、GitLab 地址、用户 ID、commit、任务 ID、应用凭据都已经脱敏,截图里的敏感内容也做了遮挡。
统一管理测试环境发布、任务状态和版本回退。
deva1b2c3da1b2c3d可以直接点击卡片中的按钮。
表面上就是几个按钮,后面还连着权限、任务队列、日志和回退。后面我遇到的问题,基本也都从这几个地方冒出来。
我最开始画出来的流程其实很短:
flowchart LR
A["@ 机器人"] --> B["打开发布控制台"]
B --> C{"选择发布范围"}
C -->|全部| D["全部站点"]
C -->|江苏| E["江苏站点"]
C -->|安徽| F["安徽站点"]
D --> G["核对 commit 并确认"]
E --> G
F --> G
G --> H["小黑盒构建并上传"]
流程画完,问题也就跟着出来了。
发布机器在内网里,仓库地址、FTP 地址、目标目录、账号信息都不能扔到群里。
如果直接让大家登录服务器发版,服务器信息会慢慢扩散。今天是一个人知道,过几天就变成群里所有人都知道。更麻烦的是,大家知道怎么发以后,谁都可能顺手发一下。
测试环境虽然不是生产环境,但它同样是共享环境。一次不受控的发布,也可能把别人正在验证的东西覆盖掉。
所以群里只放判断这次发布是否合理需要的信息,能直接碰到服务器和仓库的东西,都留在小黑盒里。
这样大家能看清楚发的是什么、发到哪里、现在跑到哪了,但拿不到可以直接登录服务器的东西。
“帮忙发一下”还有个麻烦:发完以后很容易只剩一句“应该发过了”。
如果我直接在服务器上执行命令,最后很可能只剩一句“应该发过了”。谁发的、什么时候发的、发了哪个 commit、发了哪些站点,全部靠回忆。
现在每次发布都要生成一条任务记录:
任务 ID、发起人、创建时间
分支、commit、提交内容
发布范围、目标站点
当前阶段、完成时间、失败原因
至少以后不用靠回忆来拼这次发布发生了什么。
群里所有人都能看到卡片,不代表所有人都应该能操作卡片。
我定的规则也不复杂:
谁 @ 机器人打开的发布卡片,谁才能操作这张卡片。
群聊白名单和用户白名单是第一层权限。卡片本身还会绑定:
确认发布、取消任务、重新发布、回退这类会产生副作用的按钮,只能消费一次。即使有人把按钮参数复制出来,也不能绕过服务端校验。
既然已经记录了每次成功发布的版本,就没理由只做“往前发”,不做“往后退”。
所以每个站点成功上传后,会写入版本记录。回退时选择历史成功版本,直接恢复构建产物再上传,不需要重新拉代码和重新构建。
发错了不至于只能干等着下一次发布覆盖回来。
我一开始其实已经有一个飞书群机器人了。它可以通过 Webhook 发消息,发文本、发卡片都没问题。
但它只能解决一件事:服务端往群里发东西。
我想要的流程是反过来的:
sequenceDiagram actor User as 群成员 participant Feishu as 飞书 participant Service as 小黑盒发布服务 User->>Feishu: @ 机器人 Feishu->>Service: 推送消息事件 Service-->>Feishu: 回复发布卡片 Feishu-->>User: 展示控制台 User->>Feishu: 点击卡片按钮 Feishu->>Service: 推送卡片回调 Service->>Service: 校验群聊、用户和卡片会话 Service-->>Feishu: 返回下一步卡片
这就不是单向 Webhook 能解决的了。机器人得先收到群消息,用户点卡片以后还要再收到一次回调。
所以最后换成了飞书自建应用,用 App ID 和 App Secret 建长连接。小黑盒主动连出去,飞书不用从公网打进我的内网。
这点对内网小主机很关键:
flowchart LR
User["群成员"] -->|"@ 机器人 / 点击卡片"| Feishu["飞书"]
subgraph Intranet["公司内网"]
Box["小黑盒发布服务"]
Target["测试环境"]
Box -->|"构建与上传"| Target
end
Box -->|"主动建立长连接"| Feishu
Feishu -->|"消息事件 / 卡片动作"| Box
Public["不需要公网 IP<br/>不开放入站回调端口"] -.-> Box
飞书卡片交互文档可以看这里:
这里很容易混:能发消息的机器人,不等于能接收并处理卡片操作。
飞书负责让人操作,小黑盒负责真正干活,这两个地方分开比较合适。
飞书适合做入口。大家在群里就能看到发布范围、commit 和任务状态,也能留下是谁发起的。小黑盒跟测试环境在一个内网,能拉代码、构建项目、上传 FTP,也不用把服务器暴露到公网。
我最后把它拆成了几块:
flowchart LR
A["飞书群"] <-->|长连接| B["Node.js 发布服务"]
B --> C["权限与卡片会话"]
B --> D["串行任务队列"]
D --> E["同步 Git 仓库"]
E --> F["pnpm 构建"]
F --> G["FTP 上传测试环境"]
D --> H[("SQLite<br/>任务与版本记录")]
I["PM2"] -.守护运行.-> B
发布脚本没有推倒重写,还是原来的脚本。Node.js 服务做的是把飞书里的点击转换成任务,再交给单 Worker 按顺序执行。这样大家不用登录服务器,群里也不会出现一条可以随便执行的命令。
它只提供发布流程需要的几个动作:
全部站点
江苏站点
安徽站点
任务状态
最近日志
历史成功版本
一键回退
至少比在群里传命令,让服务器照着执行靠谱。
前面点的是示例,真实卡片还得考虑一个问题:信息不能太少,也不能把服务器细节全摊出来。首页先放环境状态、最新 commit 和最近任务,至少不用点进去以后再猜现在是什么情况。

点“发布测试”以后,先选择范围:

这里没有只写“全部、江苏、安徽”三个模糊按钮,而是直接把站点数量写出来。因为“全部”到底影响几个站点,最好不要让人靠猜。
选择范围后,服务端会读取当前 dev 分支的最新 commit,并展示:
确认卡片大概是这样:

确认按钮点击后,卡片会立即失效,服务端先做一次消费校验,然后再创建任务。
这里真正要看的不是按钮怎么跳页面,而是服务端处理这次操作的顺序:
sequenceDiagram
participant Card as 卡片回调
participant Queue as 任务队列
participant Worker as Worker
Card->>Card: 校验发起人、群聊、卡片状态
alt 校验失败
Card-->>Card: 拒绝操作或提示卡片失效
else 校验通过
Card->>Queue: 抢占一次性消费权
alt 已经处理过
Queue-->>Card: 返回已有任务
else 抢占成功
Card->>Queue: 创建发布任务
Queue-->>Card: 立即返回“任务已提交”
Queue->>Worker: 串行领取任务
Worker->>Worker: 检查仓库、构建、上传
Worker-->>Card: 更新成功或失败结果
end
end
发布任务进入任务中心以后,可以看到最近一次任务、commit、提交内容、任务阶段和时间:

帮助卡片也会说明权限和重复发布规则:

这类信息看起来有点啰嗦,但比“大家默认知道规则”靠谱。尤其是发布这种有副作用的动作,规则最好直接写在入口里。
卡片能点以后,先遇到的是超时。飞书弹了一句:
目标回调服务超时未响应。
我当时也以为发布失败了。结果去看任务,发现任务已经创建,后台还在继续构建和上传。
原因很简单,两个动作根本不是一个耗时:
如果按钮回调一直等到构建和上传结束,超时基本跑不了。两个动作得拆开:
sequenceDiagram participant Feishu as 飞书 participant Callback as 卡片回调 participant Queue as 任务队列 participant Worker as Worker Feishu->>Callback: 确认发布 Callback->>Queue: 创建任务 Callback-->>Feishu: 立即返回“任务已提交” Queue->>Worker: 异步领取任务 Worker->>Worker: 拉代码、构建、FTP 上传 Worker-->>Feishu: 发送成功或失败结果
按钮回调只校验权限、创建任务,然后马上告诉飞书“已提交”。构建和上传交给后台 Worker。
我那次发 5 个站点,前后花了一分多钟。中间只盯着飞书上的红色提示,肯定会以为失败了。
后来我又把构建和上传进度写进任务状态,不能只扔一个“执行中”在那儿:
flowchart LR A["同步代码"] --> B["构建站点"] B --> C["上传 1/5"] C --> D["上传 2/5"] D --> E["上传 3/5"] E --> F["上传 4/5"] F --> G["上传 5/5"] G --> H["发布完成"]
超时解决了,新的麻烦也来了:飞书已经收到“已提交”,那最后到底算成功还是失败,听谁的?
这个问题很快就出现了。
有一次任务先显示失败,后面又显示成功,日志里还能看到一个错误:
column index out of range这不是业务代码编译失败,而是任务数据处理过程中的参数绑定问题。简单说,就是数据库语句需要的参数和实际传入的参数没有对齐。
这个错误本身不算大,麻烦的是它会把人带偏:
卡片只是展示,不能拿它当最终结果。现在一条任务至少留三份东西:
成功必须满足:
succeeded。任务创建成功,只能说明它进队列了,不能说明发布成功。
状态能说清以后,还得让任务真的跑起来。Worker 要先拿到代码。GitLab CI 里这一步有人替你做了,放到小黑盒上可没人管。
我们原来的自动化测试脚本一直在 GitLab CI 里跑得好好的,所以我一开始也想当然了:脚本能用,发布服务照着调用不就行了?
GitLab CI 里的 Job 通常由 Runner 自动完成仓库 checkout。Job 启动以后,代码已经在工作目录里了,脚本直接运行即可。
自动创建工作目录
自动 checkout 当前项目
自动注入 CI 变量
脚本直接开始执行
独立的 PM2 进程
没有 CI_JOB_TOKEN
没有 CI_PROJECT_DIR
需要单独配置仓库权限
CI 里能跑,只能说明 Runner 有权限,不能证明小黑盒也有。
小黑盒的仓库访问必须独立配置,而且只需要读取权限。当前实际使用的是 HTTPS 凭据。SSH 部署密钥也测试过,但遇到了两个问题:
这时候不能凭经验说“理论上应该可以”,必须在小黑盒上实际执行仓库读取测试。
长期更稳定的做法,是给发布服务配置一套独立的只读仓库凭据,权限只给 read_repository,并且不把凭据写进仓库、不放进飞书卡片、不打印到日志。
仓库权限配好以后,任务才算有了跑起来的条件。但还得能分清:是这次发布失败了,还是发布服务自己挂了。
相关文档:
我一开始就看 PM2 输出,里面主要是:
这些只能说明服务还活着,不能说明某一次发布做了什么。真正的构建、FTP 上传和站点结果,在单独的任务日志里。
这两个日志解决的问题不同:
| 日志 | 负责什么 |
|---|---|
| PM2 日志 | 服务有没有活着、有没有重启、有没有启动级错误 |
| 任务日志 | 某一次发布到底拉了什么、构建了什么、上传了什么 |
后来一次真实发布的任务日志里,可以看到完整过程:
dev 分支commitlogin-weblogin-webah-admin这才是判断发布是否完成的证据。
所以现在如果卡片和直觉冲突,排查顺序应该是:
不要反过来。
前面几个问题解决以后,才轮到另一个问题:它和原来的 GitLab CI 怎么配合?
GitLab CI 负责跑检查,飞书负责确认什么时候把版本发到共享测试环境。
flowchart LR
A["提交代码"] --> B["GitLab CI"]
B --> C["单元测试"]
B --> D["构建检查"]
B --> E["E2E / 冒烟测试"]
C --> F{"CI 结果"}
D --> F
E --> F
F -->|失败| G["修复后重新提交"]
F -->|通过| H["飞书查看结果"]
H --> I["人工确认 CD"]
I --> J["小黑盒构建并上传测试环境"]
简单说就是:
多人共用测试环境时,我不太想让每次提交都自动覆盖。代码过了检查,不代表现在就适合发。
现在就是先看 CI 结果,再到飞书确认 CD。以后真要联动,也应该先拿 CI 结果做门禁,别一提交就直接覆盖测试环境。
这样 CI 继续负责检查,测试环境也还在人的控制里。
今天点了一杯瑞幸新品,第一口真给我喝懵了,一股油漆味。

这味道简直就是路边一条~ 后边的味道是一点抹茶味,但第一口的印象实在太深,已经救不回来了。
兄弟们避雷吧,真不好喝。生椰拿铁YYDS。
顺便祝我生日快乐。大家中秋节快乐~