Flowan GEO · 系统架构
System Architecture · 2026-09

在六个国内大模型平台上
真实提问的一台机器

Flowan GEO 雷达平台接收客户下发的批量提问任务,由部署在云服务器上的浏览器 RPA Worker 在元宝、豆包、通义千问、文心一言、DeepSeek、安塔富等平台上真实点击、真实提问, 采集答案、引用来源、截图与 MP4 录屏,最后合并成 Excel 回交客户。 整套系统由一个 Go 控制面、一个 PostgreSQL 真相源和一支多槽位 Worker 机队组成。

6接入的大模型平台
5 × NWorker 机器 × 执行槽位
13PostgreSQL 核心表
57HTTP 路由(三套鉴权)
0任务队列副本

本页为架构说明,所有生产 IP、域名、Token、密钥与供应商凭据均已脱敏,仅保留结构与算法。

01先看全貌

六层,从客户下发一批问题,一路到把 Excel 送回去。鼠标停在图中任一层,会高亮那一层。

LAYER 1 · 接入 客户批量任务系统 X-Provider-Token · /ai/batch/* 运营管理台(内嵌 SPA) Session / Basic / 飞书 OAuth 飞书告警群 / 值班 NEEDS_HUMAN 推送 noVNC 人工接管 直连槽位桌面 模型中转网关 /v1/* 透传 + 归档 LAYER 2 · 控制面(Go,单二进制,内嵌前端) 鉴权网关层 三套互相隔离的凭据平面 admin / provider / worker 原子调度事务 任务 + 账号 + 出口 + 租约 FOR UPDATE SKIP LOCKED 状态机 · 重试 · 取消 Worker 只报事实,判定全在此 fencing token 拒绝迟到上报 制品签名 · 飞书告警 对客证据链接临时授权 HMAC 签名 URL 后台 ticker ① 过期租约回收 ReapExpiredTaskLeases · 每分钟 · 批 200 后台 ticker ② 账号自动重登恢复 RecoverAutomaticLogins · 每 10 分钟 后台 ticker ③ 出口池库存维护 MaintainTarget · 每 2 分钟 · advisory lock 读写唯一真相源 发通知 / 心跳(可丢失) LAYER 3 · 状态 PostgreSQL — 唯一真相源 tasks · task_leases · account_leases · platform_accounts · proxy_inventory external_batches · human_incidents · task_lease_reports · …(13 表 / 20 索引) Redis — 可丢失的加速层 唤醒通知 · 心跳 · 事件 · 告警分发 不承载任务队列、不决定调度正确性 下发执行参数 空闲槽位主动 pull ↑ LAYER 4 · Worker 机队(Python,常驻多槽位进程) geo-1(白名单机) slot-01 slot-02 :99 / :100 独立 Xvfb SSH SOCKS 出口跳板 geo-2 / geo-3 slot-0x slot-0y 动态出口白名单内 静态出口凭据(WORKER) geo-4 / geo-5 slot-0x slot-0y 不在白名单 → 借道 geo-1 flowan-exit-relay 一个槽位 = 一整套隔离的桌面环境 Xvfb :N → Chrome(独立 profile + CDP 端口) x11vnc / noVNC 观察面 · xdotool + xclip 输入 ffmpeg x11grab 全程录屏 · Tesseract OCR 兜底 槽位是并发单位:槽位数 = 全机队最大并发任务数 LAYER 5 · 出口网络 动态出口 · FLEET 源 IP 鉴权 · TTL 短 · 全队可用 静态出口 · WORKER 账密只装在单机 · 绑定该机 出口中继 exit-relay ssh -D 借白名单机身份 六个国内大模型平台(真实网页) 腾讯元宝 · 豆包 · 通义千问 文心一言 · DeepSeek · 安塔富 LAYER 6 · 证据与交付 答案 + 来源 + 截图 + MP4 录屏 → 私有对象存储 → 单批合并 Excel → 客户 OSS 预签名 PUT → 完成回调
图 1 · 六层全景。绿色箭头是唯一的状态写入路径;橙色箭头是 Worker 主动拉取,方向永远由下向上发起。

唯一真相源

任务、优先级、重试时间、执行租约、账号、出口、审计全部只存在于 PostgreSQL 一处。Redis 挂掉 30 分钟不丢任务。

拉取式调度

控制面不推任务。Worker 的每个空闲槽位主动申请下一项工作,分配在一个数据库事务里原子完成。

Worker 只执行

选任务、去重、算重试、选账号与出口——全都不归 Worker。它只负责开浏览器、点页面、采证据、报事实。

02为什么整套系统只能有一个真相源

这是这个项目最重要的一条设计决定,也是它被推倒重来过一次的原因。

一句话:「这个任务现在到底是什么状态」这个问题,全系统只允许有一个地方能回答,那就是 PostgreSQL。

改造之前:三个地方都说自己知道答案

早期的链路是这样走的:控制面把任务写进数据库,然后往 Redis 的消息流里塞一条消息,Worker 从消息流里取走, 再存进自己进程里的一份本地任务表,然后开始跑。看起来像一条流水线,其实是三份互相独立、无法一起提交的状态副本

PostgreSQL 「这条该跑」 Redis 消息流 「我这儿没消息」 Worker 本地任务表 「我还在跑这条」 状态漂移 重复执行 / 槽位空转 任何一步超时、崩溃或重试,三边就会各说各话;为了兜住漂移又加了幂等 Key、消费组、ACK、认领超时、重投定时器、卡住救援…… 这些机制层层叠加,但没有一个能消除「任务所有权被复制到了多个系统」这个根因。
图 2 · 事故的根因不是某个定时器写错了,而是任务的所有权跨组件复制了三份。

改造之后:所有权只有一份,其他人只能来问

现在 Redis 里没有任务。它只剩三件事:告诉 Worker「有新活了,别傻等」、收 Worker 的心跳、转发事件和告警。 这三件事丢了都不影响正确性——通知没收到,Worker 下一轮短轮询照样会来问;心跳丢了,租约到期照样能回收。

PostgreSQL 负责

  • 任务本身、优先级、重试时间
  • 执行租约与 fencing token
  • 账号占用、出口库存与绑定
  • 批次交付记录、人工事件、审计

Redis 绝不负责

  • 任务队列、任务状态
  • 取消状态
  • 任何影响「这条任务该不该跑」的判断

铁律:数据库里没有对应状态的 Redis 数据,不得触发任何执行。

库里都有些什么

一共 13 张核心表。值得注意的是没有独立的迁移文件——建表语句就写在控制面启动时执行的一段幂等 SQL 里, 所以改表结构只能加向后兼容的语句,不能改已有语句的语义。

它回答什么问题
tasks一条提问的全部身世:属于哪个批次、问哪个平台、开不开深度思考和联网、现在什么状态、跑到第几阶段、进度多少、结果和错误是什么
task_leases这条任务现在被哪台机器的哪个槽位拿着、租约什么时候到期、防重放的 fencing token 是多少
task_lease_reports上报回执,保证同一次上报重复提交不会被算两次
platform_accounts / account_leases各平台的账号池,以及「这个账号此刻被谁占着」
account_usage_policies某个平台的账号一天/一小时最多能启动几次,平台是否被临时暂停
proxy_inventory / account_proxy_bindings出口 IP 库存(只存 host:port 和能力范围,不存密码),以及账号与出口的绑定关系
external_batches客户下发的批次,以及交付到哪一步了
human_incidents / human_incident_tasks需要人来处理的事件(验证码、登录失效),以及它卡住了哪些任务
alert_notifications已经发过的告警,用来做去重和冷却,避免同一个问题刷屏
worker_snapshots每台 Worker 最近一次上报的机器状态

一条任务会经历的状态

QUEUED 被槽位领走 RUNNING SUCCEEDED RETRY_WAIT 到点后自然成为下一次分配的候选,不需要任何人重投 FAILED NEEDS_HUMAN → 飞书告警 @ 值班人 → noVNC 里人工处理 CANCELLED PAUSED
图 3 · 状态判定完全在控制面。Worker 只上报「我看到了什么」,不上报「所以这条该重试」。

03控制面在做什么

一个 Go 二进制,前端页面用 go:embed 打进去了,所以部署就是扔一个文件上去重启。

网关层:三套互不相通的钥匙

同一个 HTTP 服务对外开了三类完全不同的门,走的是三套独立的鉴权,互相之间不能借用身份。 这不是为了好看,而是因为这三类调用方的信任级别差了一个数量级。

① 运营管理台

给自己人用

Session Cookie / Basic Auth / 飞书扫码 OAuth 三选一。飞书登录还能限定只有白名单里的人能进。

/api/v1/*

② 对外批次接口

给客户系统用

只认一个 X-Provider-Token 请求头。没配 Token 时直接返回 503,绝不降级成匿名可访问——这是刻意的,宁可停摆不可裸奔。

/api/v1/ai/batch/*

③ Worker 内部接口

给自家机器用

一个 Bearer Token,同一个值要在控制面和每台 Worker 的配置里同步存在。换它要四处一起改再重启三个服务。

/api/internal/v1/*

调度:一次分配,要么全拿到,要么什么都不拿

这是整个控制面最核心的一段逻辑。Worker 有个槽位空出来了,它来问「给我点活干」。控制面要在 同一个数据库事务里把五件事一次性做完:

BEGIN 一个事务 ① 选任务 按优先级、重试时间、 批次公平性排序后锁定 ② 锁账号 符合平台策略、不在 冷却和限额中的那个 ③ 配出口 选一个这台机器真能 用的 IP,建立绑定 ④ 发租约 带一个随机 fencing token 和到期时间 ⑤ 置 RUNNING 写下 Worker、槽位、 账号、出口、第几次 任何一步不满足 → 整个事务回滚,什么都不发生。 不允许出现「占住了账号却没有出口」「任务已经是 RUNNING 但 Worker 手里没有租约」这类中间态。 没有合适的活、或者资源不够,都返回 204 空手而归,并在控制面记下结构化的空闲原因——不让 Worker 自己猜。 COMMIT → 把完整执行参数交给槽位
图 4 · 并发的多个槽位同时来抢同一条任务,数据库保证只有一个事务能成功。
为什么买 IP 这件事被踢出了事务? 因为向外部供应商买出口是一次网络请求,可能要几秒、也可能超时。把它放进事务里,等于让一把数据库锁去等一个不受控的外部系统。 所以库存不够时就老实返回「没活给你」,由后台的采购器异步补货。

fencing token:防止「诈尸」的上报

设想一台 Worker 卡住了,租约到期,控制面把任务收回来交给了另一台。这时候第一台突然缓过来, 把它那份(早就过期的)结果报了上来。如果没有防护,它会覆盖掉新的执行结果。

办法是:每次发租约都附一个随机 token,之后的续租、进度上报、最终上报都必须带着它。 token 对不上的上报一律拒绝,已经进入终态的任务也不会被迟到的消息重新打开。

三个后台定时器

过期租约回收

每分钟扫一遍,把到期没续上的租约释放掉,账号和出口一并归还,任务放回可执行状态。

它只是在修数据库里已经过期的行,不向任何外部系统重投——这是单真相源下的正常清理,不是跨系统对账。

账号自动重登

平台会话是会自己过期的。这个定时器做的事,就是运维看到某个账号掉线后手动点「重新登录」的自动版本。

曾经有账号在「需要登录」状态里躺了两个星期没人发现,平台账号池被悄悄抽干,直到某个批次的吞吐明显塌了才被注意到。

出口库存维护

每两分钟看一眼池子够不够,不够就按缺口补货。整个动作在一把数据库咨询锁下进行,所以即使控制面开了两个副本,对外也只表现为一个买家。

04对外提供哪些接口

57 条路由,按调用方分成三堆。切换看每一堆分别是给谁用的。

这是唯一暴露给公司外部的一组接口。客户把一整批问题打包扔进来,我们跑完把 Excel 送回去; 中间万一回调没送达,客户还可以自己来查状态和结果文件——这两条查询接口就是为「补偿」准备的。

接口谁调做什么
POST /api/v1/ai/batch/create客户下发一个批次,最多 5000 条问题。每条问题带平台、关键词、是否深度思考、是否联网
GET /api/v1/ai/batch/external/{id}客户查这个批次跑到哪儿了,逐条问题的状态
GET /api/v1/ai/batch/external/{id}/files客户查结果文件在哪儿
GET /api/v1/tasks/{id}/artifacts/{name}客户 / 邮件收件人凭签名链接取一份证据(截图、录屏),链接带时效

反向的两条:我们去调客户的

结果 Excel 做好之后,我们先向客户申请一个对象存储的预签名上传地址,PUT 上去,再回调告诉他们这批完成了。

接口做什么
POST /external/oss/{providerCode}/upload-url申请一个一小时有效的上传地址
POST /external/callback/{providerCode}通知批次完成

05Worker 和「槽位」到底是什么

这是最容易被问到的一块:槽位是干啥的?

槽位(Slot)就是一个完整的、与世隔绝的电脑桌面。 一台 Worker 机器上跑着一个常驻的 Python 进程,这个进程里开了若干个槽位; 每个槽位有自己的虚拟屏幕、自己的浏览器、自己的浏览器用户目录、自己的远程观察端口。 槽位的数量 = 这台机器能同时跑几个任务,全机队的槽位总数就是系统的最大并发。

slot-01  (另一个槽位是完全一样的一套,只是编号不同,两者互不可见) 虚拟屏幕 Xvfb :99 一块没有物理显示器的屏幕。 所有点击、截图、录屏都发生 在它上面。 浏览器 Chrome 独立的用户目录(登录态、 Cookie 都在里面)+ 独立的 调试端口,供程序问它问题。 输入 xdotool + 剪贴板 不是往输入框里塞字符串, 而是真的移动鼠标、真的按 Ctrl+V、真的敲回车。 观察面 x11vnc / noVNC 运维在浏览器里就能看到这块 屏幕,遇到验证码可以直接 伸手过去点。 录屏 ffmpeg 从任务开始到结束,整块屏幕 全程录成 MP4。 兜底 OCR 页面结构读不到时,就把屏幕 当成一张图来认字。 槽位循环 要活 → 没有就等一会儿再要 → 拿到就开浏览器执行 → 边跑边续租 → 报结果 → 回到开头。整个循环里没有一行「该不该重试」的判断。 槽位自己维护的状态:现在在跑哪条任务、用的哪个账号、跑到什么阶段(配了个中文的阶段名给前端显示)、 进度百分比、当前是自动驾驶还是被人接管了、有没有配代理、最近一次的错误是什么。 槽位知道自己正在开哪个账号——否则运维点「打开这个账号」时,系统会另起一个浏览器去和正在跑的那个抢登录态。
图 5 · 一个槽位就是一整套桌面环境。两个槽位之间彻底隔离,一个崩了不影响另一个。

为什么不用无头浏览器?

因为这些平台的页面是给人用的,很多控件(深度思考开关、联网开关、来源面板)只有在真实渲染、真实点击下才有正确的行为; 而且交付物里要有「我们真的操作过」的录屏证据。无头模式既录不出东西,也更容易被识别。

为什么槽位数不能随便加?

每个槽位都要吃一份显存/内存、一个浏览器进程,还要独占一个出口 IP。 加槽位前得先把出口池的目标数量一起调大——曾经机队从 8 个槽位扩到 12 个,出口池目标还写着 10, 结果两个槽位干等着没 IP 可用,而队列里堆着几百条任务。

06浏览器怎么像人一样提问

这一节讲的是「一条任务落到槽位之后,那个浏览器里到底发生了什么」。

每个平台都有一份「说明书」

六个平台的页面长得完全不一样,所以系统给每个平台维护一份规格说明,新接一个平台就是从写这份说明开始。里面写着:

去哪儿

聊天页地址、开新会话的按钮在哪、打开后要等几秒页面才算稳。

点哪儿

输入框的坐标、点哪里能让页面获得焦点而不误触其他控件。

怎么认

答案从哪句话开始、页面上哪些是要裁掉的噪音(「内容由 AI 生成」「相关视频」之类)、来源面板叫什么名字。

怎么判断掉线

页面上出现哪些字样就说明登录失效了,比如「微信扫码登录」「请登录后输入内容」。

真实踩过的坑:某平台改版后,登录弹窗从「微信扫码」换成了「手机号 + 扫码」的覆盖层,旧的判断词一个都匹配不上。 页面底下的会话列表还在、地址也没跳转,会话检查看起来一切正常,Worker 只看到「没有回答文本」—— 于是十二个槽位一起对着一堵墙反复重试。所以这份说明书里的「掉线特征」,比坐标更重要。

提问的动作顺序

敲地址栏,打开聊天页Ctrl+L → 输入网址 → 回车 → 等页面稳

用真实的键盘操作,而不是脚本跳转。

先确认「我还登着」,再谈别的

顺序是刻意的。一个已经掉线的页面同样会渲染出一个(禁用的)输入框和空空的工具栏, 如果先去检查开关状态,就会把「登录过期」误判成「页面布局变了」这种无害问题。

把深度思考、联网这两个开关拨到任务要求的状态

点击是真的点,但验证靠页面结构:点之前读一次状态,点之后再读一次,确认它真的变了。 有些平台不支持某个组合(比如关掉联网就没法深度思考),这种情况会当场把任务判成「这个要求它做不到」,而不是硬跑出一个假结果。

把问题粘进去,回车

走剪贴板粘贴而不是逐字输入——问题往往很长,逐字敲又慢又容易被中途的自动补全打断。

记下问之前页面上有什么

先存一份「基线」文本和一份基线的来源链接。之后新增的部分才是这次的回答。

最难的一步:怎么知道它说完了

大模型是流式吐字的,页面上没有一个「回答完毕」的信号灯。系统的判断办法是看页面文字还在不在变

每隔几秒 抓一次整页文字 和上一次一样长吗? 一样 → 稳定计数 +1 连续几次都没变 → 判定回答结束 抄下答案、抓来源、停录屏 变长或变短 → 计数清零,接着等 三条从事故里长出来的补丁: 页面可以变短。搜索中的进度条消失后整页文字会缩水;如果拿「历史最长」当基准,它永远等不到稳定,好好的答案会被判成超时。 有的平台会「假装不动」。某平台深度搜索期间页面几秒不变,只看长度会截出半截答案——所以额外要求等到它自己吐出结尾的免责声明。 空白页要早点认输。验证码是盖在浏览器层的遮罩,底下页面是空的、复制出来也是空的,普通检查看不见它。 所以当「一个字都没出现」持续一段时间后,就改用 OCR 直接读遮罩上写了什么,当场失败、把槽位让出来,而不是白白占着五分钟。
图 6 · 判断「说完了」比想象中难得多,这段逻辑里的每一条例外都对应一次真实的线上问题。

来源链接:比答案还难抓

交付要求不只是答案,还要有这次回答引用了哪些网页。这块的麻烦在于各平台的引用卡片做法五花八门:

抓回来的每一条还要清洗:跳过跳转中间页、补全相对地址、按标题相似度去重, 最后只保留标题和绝对地址都齐全的那些——宁可少给,不给残缺数据。

07录屏与证据:怎么证明我们真的问过

这是交付物里最有分量的一部分——客户买的不只是答案,还有「这答案确实是从那个平台上问出来的」。

① 开录 任务一开始就对着这个槽位的 虚拟屏幕录,15 帧、快编码。 ② 收尾 温和地结束进程,让它把 MP4 的索引写完——粗暴杀掉会得到 一个放不出来的坏文件。 ③ 打水印 左上角烧进「平台 + 题号 + 生成时间」,并优化成边下边播。 ④ 上传归档 传进私有对象存储, 校验大小一致才算数。 水印这步万一失败了怎么办?保留那份没打水印的原片,把失败写进日志。 已经录完的原片本身就是合格的交付物,没有理由因为转码出问题就交出一个空文件或坏文件。
图 7 · 录屏管线。每一步的错误处理都遵循同一个原则:宁可交一份朴素但真实的东西,不要交一份看起来完整实际打不开的东西。

答案原文

裁掉页面噪音后的正文

引用来源

标题 + 绝对地址,去重后

页面截图

关键节点的画面

MP4 录屏

从开始到结束的全程

这些文件都存在私有的对象存储里,不公开。要给客户看的时候,由控制面签发一条带时效的链接—— 链接本身带签名,过期就打不开,不需要把桶开成公开读。

08出口网络:为什么要不停地换 IP

「出口」就是我们的请求最终从哪个 IP 地址发出去。这是整个系统里最脏、最容易出事的一块。

平台限流不认账号,认 IP。三十多个账号如果都从十来个地址出去, 每个地址上的会话频率就高得离谱,一半的请求会撞上人机验证。 所以「多买地址」是唯一能在不降吞吐的前提下压低单 IP 频率的办法。

两种出口,能力范围完全不同

动态出口 · FLEET

供应商按来源 IP 放行——只有事先加了白名单的机器才连得上,不需要账号密码。 它的寿命很短,控制面每两分钟就在补货换血。因为鉴权不依赖凭据,所以全机队任何一台白名单机都能用

静态出口 · WORKER

靠账号密码鉴权,而密码只装在某一台机器上。所以它只能分配给持有凭据的那台, 别的机器拿到也用不了。数据库里只存地址和范围,不存密码

为什么这个区分必须写进调度:账号预检和最终选出口必须用同一个条件, 否则控制面会签发一张这台 Worker 根本兑现不了的许可,任务拿到手就死。 还出过一次更隐蔽的:某台机器有 10 个专属静态出口,正好等于全池的目标数量 10, 采购器一看「够了」就再也不买动态出口,于是其他机器的地址全部过期、吞吐归零,仪表盘上的数字却好看得很。

白名单不够用时的办法:借一个身份

供应商的白名单只有五个名额,早就被占满了。后来新加的两台机器直连出口是秒拒——带账号密码也一样, 因为账密只对静态那批有效。它们的解法是:

槽位里的 Chrome geo-4 / geo-5 本机的中继端口 按需开、闲置自动回收 一条常驻 SSH 隧道 目的地由每个连接自己带 白名单机 geo-1 只多了一行免登公钥 供应商出口 → 目标网站 供应商看到的来源是 geo-1,于是放行;目标网站看到的仍然是供应商的出口 IP。业务语义一点没变。 用一条常驻隧道而不是给每个 IP 单独开转发,是因为出口寿命只有几分钟,逐个开会几分钟就攒出一堆僵尸进程。
图 8 · 出口中继。被借身份的那台机器不装任何服务、不重启任何进程。

09账号池:系统里最稀缺的资源

出口可以花钱买,账号不行——账号要养、会掉线、有日限额,而且掉了没人告诉你。

账号不绑定槽位,而是每次租用

早期的做法是「某个槽位就固定登录某个账号」,这样一台机器坏了,它上面那些账号就一起停摆。 现在改成账号池:账号只属于账号池,任务分配时临时租一个,用完归还。 这样账号的可用性和机器的可用性解耦了。

登录态怎么在机器之间搬

每个账号的浏览器登录态(Cookie、本地存储)会被加密后存成快照。 下次不管在哪台机器上用这个账号,都能把快照解开、种进一个新的浏览器目录里,直接是登录状态。

那把加密密钥是全系统最不能动的东西之一。 所有账号的登录态都靠它解密——换掉就等于全部账号重新登录一遍。真要轮换,只能先用旧钥匙解开、再用新钥匙加封。

账号会经历的状态

空闲 登录 / 检查中 可调度 需要人工 撞上验证码 密码失效 限额冷却 今天用够了
图 9 · 自动化只做确定性的那一段(填账号密码);验证码、二次验证、平台风控一律留给人在 noVNC 里完成。

限额是双刃剑

可以给每个平台设「一天最多启动几次、一小时最多几次」,防止账号被平台盯上。 但设得太低会造成一种很难查的故障现象:账号全是健康的,吞吐却是零——因为它们全都在冷却里。 遇到「一切正常但没产出」,先去看这两个数字。

亲和性

同一个账号尽量固定从同一台机器、同一类地址出去,突然换城市换运营商更容易触发风控。 但这个绑定必须能被解除,否则那台机器坏了账号就跟着陪葬——所以管理台上有一个「解除亲和」的按钮。

10从一个批次到一个 Excel

客户不看我们的后台,他们只看到一个文件。

批次里的最后一条任务跑完控制面

整批标记为待交付。

某台 Worker 认领这次交付Worker

认领是排他的,所以不会两台机器同时交付同一批。认领失败或中途出错,认领会被释放,另一台可以接着来。

把每条任务的小表格合并成一个大工作簿Worker

严格按客户下发时的顺序排列,答案、来源、录屏链接分别填进对应的列。表头是校验过的——模板对不上直接报错,而不是错位写进去。

先留一份自己的副本Worker

在临时目录被清掉之前,先把合并好的文件存进我们自己的存储。否则一旦上传出问题,重跑整批的代价是天文数字。

记下文件位置,再上传Worker → 控制面

顺序很关键:先把位置写进数据库,再去上传和回调。这样即使回调丢了,客户来查「结果文件在哪」时我们答得上来。

向客户申请上传地址,PUT 上去Worker → 客户

用客户给的预签名地址,一字不差地带上他们要求的请求头——这类签名对头部很敏感,多一个少一个都会被拒。

回调通知这批完成Worker → 客户

回调失败会一直重试到成功为止。

一个容易被忽略的细节:如果一个已经交付的批次里,有任务后来又被重试了(比如运维发现某条答得不对), 这批的交付状态会被重新打开,走一遍新的交付流程。交付不是一次性的单向门。

11一条任务的完整生命线

把前面所有部分串起来,看一条问题从进门到出门都经过了谁。

客户 控制面 PostgreSQL Worker 槽位 平台网页 下发一批问题(带 Token) 逐条写入,幂等,状态 = 排队中 我空了,给我点活 一个事务:锁任务 + 锁账号 + 配出口 + 发租约 + 置运行中 给你:问题 + 账号登录态 + 出口 + 租约凭证 开录屏 → 起浏览器 → 打开聊天页 浏览器真的起来了(这之后失败才算账号头上) 确认登录 → 拨开关 → 粘贴问题 → 回车 流式吐字……轮询到不再变化为止 期间持续续租(顺便听有没有人喊停) 抄答案 → 展开来源 → 截图 → 停录屏打水印 上报:我看到的事实(带租约凭证,token 不对就拒收) 一个事务:判定状态 + 释放账号和出口 + 落库 整批跑完 → 合并 Excel → 上传 → 回调完成
图 10 · 注意每一条跨越到控制面的箭头都是 Worker 主动发起的,控制面从不推送。

12这套系统的几条铁律

每一条背后都有一次真实的线上事故。新加任何能力之前,先对照这几条。

① 任务所有权不许复制

加新调度能力时,先问它属于哪一格:调度规则在控制面,持久状态在数据库,Worker 只执行,Redis 只加速通信。 把任务所有权抄一份到 Redis 或 Worker 本地,是这个项目专门被重构掉的问题,不要退回去。

② 资源要么一起给,要么都不给

任务、账号、出口、租约在一个事务里同生共死。不允许存在「占了账号没有出口」这种中间态。

③ 重启成功 ≠ 部署成功

部署脚本不认「服务是 active 的」,它要求主进程号真的变了、而且进程的启动时间晚于它所运行的那份代码。 曾经出现过「修好了」但进程跑着两天前代码的事故。

④ 失败要分类,不能一律归给「资源不够」

浏览器起不来和出口不可用是两回事。曾经把前者报成后者,结果运维照着提示去买代理, 而真正的原因是页面上某个控件超时了。

⑤ 宁可少给,不给残缺

来源缺地址就不收,水印转码失败就交原片,表头对不上就报错。交付物的可信度比完整度重要。

⑥ 没配凭据就停摆,不降级

对外接口在没有配置 Token 时返回 503,而不是变成匿名可访问。安全配置缺失必须是显性故障。

⑦ 密钥只记名字和位置,不记值

凭据清单文档里写的是「这个 Token 存在哪、谁在用它、换它会连带影响谁」,真实值走服务器权限或密码管理器。

⑧ 扩容是一整套动作

加机器 → 加槽位 → 同步调大出口池目标 → 确认新机器进得了供应商白名单。漏掉任何一步, 新增的槽位就是在那儿空转。