User feedback integrated:
- LLM monitoring scope: 3 → 5 providers (add Hunyuan + Kimi).
Keep-alive table in §4.4 gets 2 rows (default 8 min heartbeat,
re-evaluate on integration). Block B effort 2-3w → 4-5w; total
22-26w → 24-28w; Plan B scope updated.
- New §6.8 "admin-web coordination" covering:
- Publish modal with *account-level* selection (not platform-level):
one workspace has many accounts per platform (e.g., 5 WeChat);
one job expands to N desktop_tasks each bound to (platform, account).
- Media data dashboards at account-level granularity (overview,
publish history, monitoring results).
- New /api/tenant/* aggregation + SSE event bus (separate channel
from /api/desktop/events).
- Offline degradation UX when client(s) are offline.
- Account data consistency: client is SoT for account existence,
server stores projection; startup resync on upsert/delete.
- georankly:// deep links with installed-client detection fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 KiB
Electron 桌面客户端设计(全面替换 WXT 浏览器插件)
状态:Draft v2 · 待实施(v1 经 Claude + Codex + Gemini 三方交叉审查后修订)
作者:@liangxu + Claude(codex/gemini 交叉审校)
日期:2026-04-18
范围:新增 apps/desktop-client/、/api/desktop/* 服务端路由、相关 DB schema;删除 apps/browser-extension/ 与 /api/plugin/* 路由及相关代码。
v2 主要修订:修正 CDP 流式抓取方法(C1)、Patch 通道沙箱机制(C2)、SSE 多实例协调(C3)、workspace 独占 DB 落地(C4)、任务租约协议(C5)、老适配器工作量重估(C6);以及 12 条 Warning 逐项落入文档。详见 §13 变更记录。
1. 背景与动机
现有 apps/browser-extension/(WXT MV3 Chrome 扩展,"GEO Publisher")同时承担两类工作:
- 向 13 家中国媒体平台做内容分发(微信公众号、头条号、百家号、搜狐号、网易号、ZOL、懂车帝、知乎、企鹅号、简书、掘金、B 站、什么值得买)。
- 监控主流 LLM(豆包、千问、DeepSeek、混元、Kimi)在给定查询下的输出,判定品牌 是否被引用。
当前问题:
- LLM 抓取不稳定。监控模块依赖 DOM 选择器与
webRequest.onBeforeRequest在 MV3 下受限,无法可靠拿到 SSE 响应 Body;平台 UI 一变就挂。 - 适配器受 MV3 约束。后台 service worker 频繁休眠,长任务难以保证完成;跨平台复杂登录(QR、滑块、SMS)在扩展内体验差。
- 不可扩展到真正的系统级自动化。系统托盘、开机自启、多账号并发 session 隔离、进程级 CDP 调试,这些 MV3 都做不到。
- 现有适配器实现覆盖不全。仓库实测:发布侧 13 家里
weixin_gzh / juejin / dongchedi / smzdm / wangyihao / bilibili / zol共 7 家 仍返回unsupportedPublishResult(detect-only 占位);监控侧 DeepSeek 是defaultAdapter占位没实现 query。这部分要从零实现,不是迁移。
因此:全面替换为一个 Electron 桌面客户端,承担上述全部职责。浏览器扩展停止维护并下线。
2. 设计硬约束(已确认)
- 全面替换,不做"先 monitoring 后 publishing"的分阶段替换。
- 不做向后兼容。因为是开发版本阶段,服务端协议、DB schema、认证字段按桌面客户端的最佳形态重新设计,不为插件保留旧字段或旧路由。
- 登录走嵌入式 Web View。用户在 Electron 真实平台登录页里自己完成扫码/密码/短信/滑块;我们只保存 cookie,不保存账号密码。
- Cookie 本地存储 并加密(Electron
safeStorage)。服务端永不存储 Cookie 或任何平台凭据。Linux 上safeStorage可能退化为basic_text,必须启动时检测并显式降级告警(见 §4.5)。 - LLM 监控一律走网页 + CDP 拦截,不调用任何 LLM 官方 API(成本导向;多账号 = 多配额,线性成本比 token 计费友好)。后端
llm.api_key是后端自用管线,与客户端监控完全解耦。 - 不打包 Playwright。复用 Electron 自带 Chromium,通过
webContents.debugger直接用 CDP。包体越小越好。 - 支持 5 目标:Windows x64/arm64、macOS Intel+Apple Silicon、Linux x64/arm64。
- 一个平台账号只绑定一个 workspace(不支持跨租户共享账号)。该约束必须落到 DB 唯一约束与 API 级冲突校验,不依赖客户端自觉(见 §6.6
platform_accounts的platform_uid字段 + 唯一索引)。
3. 架构总览
3.1 进程拓扑
单 Electron 进程树(方案 A)。所有视图宿主统一用 WebContentsView(Electron 已把 BrowserView 标记为 deprecated)。
- Main 进程(Node):任务调度器、Session 注册表、加密凭据仓库、CDP debugger 管理、租约状态机、与后端 REST/SSE 通信、Tray & autoUpdater。
- Renderer 进程(托盘窗口 / 登录窗口 / 发布预览窗口):Vue UI,基于
packages/ui-shared(见 §3.3)。禁用nodeIntegration,通过contextBridge暴露窄接口。 - Utility 进程:为 Patch 可执行代码沙箱化而预留(见 §5.5)。
utilityProcess不开 Node 能力,仅以 IPC 与主进程通信。 - 工作 WebContentsView:每个登录账号一个
persist:<account-id>session,挂在离屏BaseWindow上。- 生命周期:两档。Hot-tier(最近 N 分钟有任务或保活的账号)常驻 WebContentsView;Cold-tier 账号只保留 session partition 在磁盘,WebContentsView lazy-create。阈值默认 30 min,可配置。避免 10 账号场景下 RAM 被固定占用。
- 仅在实测 CDP 抓流阻塞 UI 线程时,才把 debugger 交互迁到
utilityProcess,其他模块保持不动。
3.2 模块分层
新增 apps/desktop-client/,作为 pnpm workspace 的新包:
apps/desktop-client/
main/
bootstrap.ts # app ready, single-instance-lock, tray, autoUpdater
session-registry.ts # account-id → (WebContentsView|null) + session
view-pool.ts # Hot/Cold 两档 WebContentsView 生命周期
vault.ts # safeStorage 包 cookie/token + SQLite(本机恢复缓存)
vault-export.ts # 用户交互式导出(口令二次加密)用于手工迁机器
scheduler.ts # 本地 cron + 服务端 SSE/pull 合流
lease-manager.ts # 任务租约:ack→attempt_id+lease_token→extend/cancel/result
rate-limiter.ts # 每平台/账号 token bucket,共享 business/keep-alive/probe 预算
circuit-breaker.ts # 平台级熔断
keep-alive.ts # 探活 / 保活 / resume warm-up 错峰
crash-recovery.ts # 启动时按 task.kind 分派恢复策略
patch-loader/
registry.ts # 已加载 patch 版本表
signer.ts # manifest + Ed25519 + hash + 回滚序号校验
parser-dsl.ts # 受限 DSL 解释器(默认通道)
vm-host.ts # utilityProcess 沙箱启动 + 冻结 global(备用通道)
tracing/
recorder.ts # 结构化日志 + 关键截屏(Alpha 范围)
trace-writer.ts # 轻量 zip(Alpha 仅 JSON 日志 + 截屏)
redactor.ts # Cookie / Authorization / password 字段自动脱敏
# Playwright 兼容导出作为 Phase 2 能力,见 §5.6
transport/
api-client.ts # 复用 packages/http-client,注入 client_token
sse-client.ts # /api/desktop/events 长连接 + 60s pull 兜底
preload/
bridge.ts # contextBridge 窄 API
renderer/ # 极简 Vite 项目,仅消费 packages/ui-shared
main.ts
routes.ts
views/ # 桌面端专属视图(账号管理/任务中心/事件中心/诊断)
3.3 关键复用(更务实的口径)
可复用:
packages/shared-types:平台枚举、任务类型、DTO。需要扩展:加入租约、platform_uid、patch manifest 等新类型。packages/http-client:HTTP 基座直接用。- 新建
packages/ui-shared:从apps/admin-web抽出通用组件、i18n、样式、utils。admin-web 与 desktop-client 两边都只消费共享包。Renderer 是一个极简 Vite 项目,不是克隆+删页面。 - 老适配器里的选择器字符串和业务流程描述("打开编辑器 → 填标题 → 粘正文 → 上传封面 → 点发布"这种语义)。
不可复用、必须重写或新建:
- 所有适配器代码——老代码硬编码
chrome.tabs / chrome.storage / chrome.runtime / chrome.webRequest / wxt框架依赖,全部换成 Electronsession / WebContentsView / webContents.debugger / Fetch domain。 - 发布适配器中 7 家 detect-only 的平台(微信公众号、掘金、懂车帝、什么值得买、网易号、B 站、ZOL)需从零实现 publish。
- DeepSeek 监控需从零实现 query。
- Trace 基建、Patch 通道、租约管理器、LeaseManager + sandbox-loader、ui-shared 抽取——全部新建。
4. 账号 & 会话模型
4.1 数据结构
type Account = {
id: string // uuid,本地生成
platform: PlatformKind // 'wechat' | 'toutiao' | ... | 'doubao' | 'qwen' | 'deepseek'
platformUid?: string // 平台侧真实账号 ID / OpenID / UIN;登录探针抓取
accountFingerprint?: string // 额外防伪:昵称/手机号尾号/账号主页 URL hash
purpose: 'publish' | 'monitor'
displayName: string // 用户自填,仅作本地展示
partitionKey: string // 'persist:acc-<id>',Electron session 隔离
proxy?: ProxyConfig // 可选 session-level 代理
createdAt: Date
lastSeenAt: Date
verifiedAt?: Date // 上次成功 probe 的时间
health: 'live' | 'expired' | 'captcha' | 'risk'
tenantId: string
workspaceId: string
keepAlive: {
mode: 'probe-only' | 'heartbeat' | 'disabled'
probeIntervalMs: number // 默认 30 min
heartbeatIntervalMs?: number // 默认 8 min,仅 heartbeat 模式
lastProbeAt?: Date
lastHeartbeatAt?: Date
failureStreak: number
}
quota: {
hourlyBudget: number // 默认 60/小时,含业务+保活+探活合计
usedThisHour: number
resetAt: Date
}
}
type ProxyConfig = {
scheme: 'http' | 'socks5'
host: string
port: number
auth?: { user: string; pass: string } // 存入 vault,不明文落盘
}
4.2 登录流程
- 用户在 UI 点「新增账号 → 选平台」。
- 主进程
session-registry.create(platform)分配新 partition,打开一个可见BrowserWindow(登录专用),加载该平台登录页。 - 用户自己用扫码 / 密码 / 短信 / 滑块登录。
- 登录探针(preload 脚本 + URL 规则 + 成功态 DOM 标记)检测到已登录态后,在窗口顶部叠加"登录成功"遮罩层 1.5 s,然后关窗。
- 探针在关窗前抓取平台侧
platformUid(如微信公众号的fakeid/ B 站的mid/ 知乎的hash_id),写入Account.platformUid。 - Electron 自动把 cookies 持久化到
userData/Partitions/<key>/;同时主进程用safeStorage加密写本地 SQLite 作为本机恢复缓存(跨账号解绑、重装应用时 restore 用;非迁机器备份)。 - 启动时强制
safeStorage.isEncryptionAvailable();若在 Linux 上safeStorage.getSelectedStorageBackend()返回basic_text,UI 显著告警、默认关闭加密快照(避免把平台 cookie 明文写盘)。 - 账号进入"常驻池",hot-tier 分配 WebContentsView,cold-tier 仅保留 partition;按需激活 CDP。
4.3 多账号并存
同一平台多账号完全隔离——partition 不同、cookies 不同、UA 不同、可通过 session 级 proxy 走不同 IP。代理来自 Account.proxy 字段,由 _shared/proxy-helper.ts 统一调用 ses.setProxy(§5.4)。调度器按账号维度限流(默认同一账号并发 1、同一平台并发 3,可配置)。
4.4 Cookie/Session 保活策略
三个档位:
| 档位 | 动作 | 频率 | 代价 |
|---|---|---|---|
| 探活 | 加载主页,检查 DOM 登录标志 | 30 min | 低 |
| 保活 | 访问平台私有 API 触发 Set-Cookie/token 刷新 | 8 min(豆包/千问) | 中 |
| 按需激活 | 任务到来前 pre-check | 任务驱动 | 低 |
| 平台类型 | 账号示例 | 保活方式 | 节奏 |
|---|---|---|---|
| 媒体发布 | 微信/头条/B 站/知乎 等 13 家 | 只探活 | 30 min |
| 豆包 | Doubao | 主动保活 | 8 min + 30 min 探活 |
| 千问 | Qwen | 主动保活 | 8 min + 30 min 探活 |
| DeepSeek | DeepSeek | 只探活 | 30 min |
| 混元 | Hunyuan(腾讯) | 主动保活(SSE / 短期 token,待接入时复测) | 8 min + 30 min 探活 |
| Kimi | Moonshot | 主动保活(SSE / 短期 token,待接入时复测) | 8 min + 30 min 探活 |
实施细节:
- 错峰:同一平台 N 个账号按
hash(accountId) mod period分布时间槽 + ±30s 抖动;当 N > 槽数时使用随机顺延。 - 统一 token bucket:每个
(platform, account)维度一只 leaky bucket,桶容量 =hourlyBudget。业务任务、保活、探活同桶扣减——避免保活把业务流量配额吃掉或反之。 - 优先级:业务任务 > 保活 > 探活;同槽冲突由优先级决定是否放行。
- 失败升级:保活失败 1 次立即重试;连 2 次失败降级为探活一次确认状态;探活失败则
health: expired,托盘红点 + OS 通知 → 用户点击弹登录窗(partition 复用,不需要重建账号)。 - 429/验证码退避:任何响应识别为 429/风控页/captcha 时,该账号进入指数退避(基础 30s,因子 2,上限 30 min),期间完全暂停保活,只保留"按需激活"(业务任务到来时才 pre-check)。
- 静音时段:默认 0:00–7:00(用户系统本地时区)不保活,可配置关闭。
- 休眠感知:
powerMonitor.on('suspend'|'resume'),休眠暂停全部保活;唤醒后错峰 warm-up——按hash(accountId)分片,每 10s 一批探活(每批 ≤3 账号),避免同步流量尖峰。 - 配额池:同一 LLM 多账号时,调度器按"最近使用 + 剩余配额"挑账号。
4.5 凭据粒度与备份
- 不保存账号密码。
- 只保存 Cookie + 平台侧必要 token(如 Doubao 的
ms_token、fp)。 safeStorage+ SQLite 是本机恢复缓存:userData 被清/重装应用可恢复,不保证可迁机器。- 跨机器迁移走独立的用户交互式导出流程(
vault-export.ts):本机解密后用用户输入的口令二次加密导出为.vault文件;新机器同样输入口令解密、用新环境的safeStorage重加密落盘。导出的.vault文件不会自动上传服务端。 - 服务端永远不持有任何形式的 cookie 备份(硬约束 4)。
5. 适配器模型
5.1 接口定义
interface AdapterContext {
accountId: string
session: Session
view: WebContentsView // 统一使用 WebContentsView
debugger: DebuggerClient
rateLimit: RateLimitHandle // 业务调用前 acquire
logger: ScopedLogger
signal: AbortSignal
reportProgress: (stage: string) => void // 供状态机向 lease-manager 报进度
}
interface PublishAdapter {
platform: PlatformKind
capabilities: { images: boolean; video: boolean; markdown: boolean; /* ... */ }
probe(ctx: AdapterContext): Promise<HealthState>
keepAlive(ctx: AdapterContext): Promise<void> // 默认 no-op
publish(ctx: AdapterContext, task: PublishTask): Promise<PublishResult>
}
interface MonitorAdapter {
provider: LLMProvider
probe(ctx: AdapterContext): Promise<HealthState>
keepAlive(ctx: AdapterContext): Promise<void>
query(ctx: AdapterContext, task: MonitorQueryTask): Promise<MonitorResult>
}
5.2 监控适配器:流式 SSE 抓取技术路径(核心修正)
不要用 Network.responseReceivedExtraInfo + getResponseBody——前者是响应头/Cookie 元信息,后者只能在响应整段收完后取完整 body,对长连接 SSE 拿不到流式增量。
按传输类型分流:
| 页面使用的机制 | 正确的 CDP 抓法 |
|---|---|
new EventSource(url) |
Network.enable → Network.eventSourceMessageReceived 监听每条 message |
fetch(url) + text/event-stream + streaming body |
Fetch.enable({patterns}) → Fetch.requestPaused → Fetch.continueResponse → Fetch.takeResponseBodyAsStream + IO.read 流式拼接 |
fetch(url) + chunked transfer + 自定义协议 |
同上(或 Chromium ≥ 120 的 Network.streamResourceContent + Network.dataReceived,需先确认 Electron 打包的 Chromium 版本支持) |
Chromium 版本兼容矩阵由 docs/superpowers/specs/electron-chromium-cdp-matrix.md(本 spec 落地后新建)维护;每次升 Electron 大版本检查一次上面这些 domain 是否仍可用。
实施步骤:
1. ensureLoggedIn(ctx) → probe / 必要时触发保活
2. openChat(ctx) → 在 WebContentsView 里导航或复用 tab
3. detect protocol → 通过 DevTools Protocol 注入一小段探测脚本,返回 EventSource / fetch-stream
4. attach CDP Fetch or Network → 按 §5.2 表格选 domain
5. submit query → 触发网页发出请求
6. collect streaming body → eventSourceMessageReceived 或 IO.read
7. parse provider-specific payload → normalized MonitorResult
8. detach CDP(finally)
Provider-specific parser:每家 LLM 的 payload schema 不同(豆包 {event: 'data', data: {text: ...}} 这种结构),放 adapters/doubao.ts 等私有文件。Schema 变化通过 Patch 通道下发新 parser(§5.5)。
5.3 发布适配器状态机 + 租约绑定
每个发布任务或每个账号带 PublishMode:
type PublishMode =
| { kind: 'manual'; requireUserReview: true }
| { kind: 'auto' }
type PublishState =
| 'filling' // adapter 正在往编辑器填内容
| 'readyToReview' // manual 模式下等待用户点发布
| 'submitting' // 已经点了发布按钮,等平台回应
| 'done' // 平台明确返回成功
| 'failed' // 明确失败(可重试)
| 'aborted' // 用户或系统取消
| 'unknown' // 崩溃/超时/无法判断(人工对账,见 §7.5)
状态机与租约的绑定(见 §6 协议):
- 进入
filling / readyToReview / submitting时,适配器必须每 60 s 调lease-manager.extend(lease_token)续租。 - 进入
readyToReview时,lease-manager 同步把服务端任务切到waiting_user状态,服务端 lease 续期 = 30 min(长于默认 10 min),避免等用户审核过程中被抢任务。 - 用户取消或 signal abort →
aborted。 - adapter 抛异常 →
failed,带错误码。 - 进程崩溃 → 启动时按 §7.5 分派为
unknown。
文件上传:protocol.handle('file') + CDP Input.dispatchDragEvent 或直接喂 <input type=file>,不经服务端中转。
5.4 公共能力
adapters/_shared/:
debugger-helper.ts:封装 CDP attach/detach、Fetch/Network domain 选择、SSE 流式拼接、超时。dom-helper.ts:语义化选择器、重试、容错。upload-helper.ts:文件上传统一入口。captcha-pause.ts:遇滑块/点击验证时把 WebContentsView 转前台让用户过,通过后续跑。proxy-helper.ts(新):统一走Session.setProxy(Account.proxy),支持 socks5/http + 凭据存 vault。
5.5 双轨更新:局部热更 + 全量(安全收紧)
| 通道 | 内容 | 节奏 | 形式 |
|---|---|---|---|
| Patch 通道(默认·纯数据 DSL) | 适配器的 selectors.json + parser.dsl(受限的声明式解析 DSL,非 JS) |
分钟级按需 | 服务端签名 manifest + 补丁包;客户端 DSL 解释器运行,无任意 JS 执行 |
| Patch 通道(备用·隔离执行) | 个别场景确需写 JS parser 时的"逃生舱" | 仅在评审后发布 | 加载到 utilityProcess(无 Node 能力)+ isolated-vm 或 vm.createContext + 冻结 global + 禁用 eval/Function/WebAssembly/dynamic import + 仅允许消息传递返回结构化结果 |
| Release 通道 | 二进制、主进程、UI、适配器宿主框架 | 周/双周 | electron-updater 差分包 |
默认走纯数据 DSL 通道——安全、可审、无 RCE 面。仅当平台响应结构复杂到 DSL 难以表达时,才考虑升级到备用通道,且每个 parser 必须经过安全评审。
签名链(TUF 思路简化版):
每个 patch 打包为 manifest.json + patch.data,manifest 至少包含:
{
"platform": "doubao",
"patch_seq": 47, // 单调递增,防回滚
"hash": "sha256:<of patch.data>",
"expires_at": "2026-05-18T00:00:00Z",
"min_client_version": "1.2.0",
"max_client_version": null, // 可选
"key_id": "root-2026-01", // 支持根密钥轮换
"revokes": [], // 撤销之前的 patch_seq 列表
"sig": "<Ed25519 over manifest+hash>"
}
客户端约束:
- 内置 root key(可通过 Release 通道更新),支持
key_id轮换。 - 下载时校验签名;每次加载前再次校验 hash + 过期时间 + 未被
revokes列表覆盖。 patch_seq必须严格 > 已加载值,否则拒绝(防降级重放)。- 服务端下发一个
revoke <platform> <patch_seq>指令 → 客户端停用对应 patch、降级为前一版本,直到收到下一批 manifest。 - UI 每个适配器显示:
client 1.2.0 · doubao patch #47 (via DSL)。
5.6 Trace 基础设施(Alpha 范围 + Phase 2 兼容 Playwright)
Alpha 范围(MVP):
- 结构化日志(pino JSON)滚动写入
userData/logs/。 - 任务失败时自动截屏 3–5 张 + 存 Network 响应摘要。
- 本地内置极简 viewer(Vue 单页,离线),能读
.trace.json文件。 - 脱敏:Cookie / Set-Cookie / Authorization / x-xsrf-token / password / 手机号 / email →
<redacted>。 - 存储:
userData/traces/<accountId>/<taskId>.trace.zip,配额默认 50 份 或 500 MB(先到先清)。 - 默认不上传服务端;用户手动"发送诊断包"才上传。
Phase 2(Beta 之后):
- Playwright Trace Viewer 格式兼容导出(为方便外部开发者用
npx playwright show-trace打开)。因格式私有、跟随 Playwright 版本演进,仅作为导出选项。 - CDP 事件流完整记录 + 2fps 截屏 + DOM 快照(内存 rolling buffer 30s,失败时 flush)。
6. 服务端协议
6.1 命名(全部重命名)
老 plugin_installations / /api/plugin/* / installation_token → 新 desktop_clients / /api/desktop/* / client_token。因为是开发版本、无生产流量,DB 迁移直接 drop 旧表、create 新表,不做导入或双写。
sqlc 协同:重命名会让 server/internal/tenant/repository/generated/ 里所有相关 struct 和 query 方法名重生成——Service 层也要改。实施计划必须显式列出:
server/internal/tenant/repository/queries/*.sql里要改的查询文件清单- 所有持有
PluginInstallation/InstallationToken类型的server/internal/tenant/app/*.goservice 文件 server/internal/tenant/transport/*.gohandler- 对应 repo 层单元测试
6.2 认证
| Token | 代表 | 发放 | 用途 |
|---|---|---|---|
| User JWT | 人类用户 | /api/auth/login |
用户在 Electron 内的交互(新增账号、创建监控计划、看报表) |
| Client Token | 一台 Electron 安装(与 client_version 解耦) | 登录后 /api/desktop/clients/register 换取 |
后台任务回调、任务拉取、心跳 |
Client Token 绑定字段:client_id + tenant_id + workspace_id(稳定维度)。client_version / os / cpu_arch 仅作为 heartbeat metadata 上报,不入 token 绑定——autoUpdater 升版后 token 仍有效。
Token TTL = 30 天,支持滚动刷新:POST /api/desktop/clients/rotate 用旧 token 换新 token(剩余有效期 < 7 天时自动调用)。
撤销:POST /api/desktop/clients/revoke 由 Web 端用户主动撤销(如丢失设备)或管理员强制;被撤销的 client 任何接口都返 401 + 引导用户重新登录换新 client。
6.3 任务分发:SSE + 60s pull 兜底 · 多实例协调
SSE 仅作为通知信道,不直接承载任务归属。
多实例部署下:
Web UI / Scheduler creates task
↓
DB insert desktop_tasks(queued)
↓
publish task_available event
↓
RabbitMQ fanout → every server instance
↓
each instance looks up its local SSE hub:
"do I hold any client that can handle this task's platform+workspace?"
↓
if yes: push {type: 'task_available', task_id} via SSE
↓
client POST /api/desktop/tasks/lease → server atomically claims task
↓
server returns {task, attempt_id, lease_token, lease_expires_at}
要点:
- SSE 只推
task_available信号,携带task_id而非任务正文,避免跨实例推送导致 payload 在错误客户端落地。 - 任务归属由
POST /tasks/lease原子性决定(DBUPDATE ... WHERE status='queued' RETURNING或 Postgres advisory lock)。多个 client 同时收到通知也只会有一个 lease 成功。 - 60 s pull 作为 SSE 失联兜底;和 SSE 都打同一个 lease endpoint,天然幂等。
- presence registry:每个 server 实例维护本地
client_id → SSE connection,并把(client_id, instance_id)写到 Redis(TTL 30 s,SSE 心跳续期)。Scheduler 根据 presence 决定是否要广播。
6.4 路由表
# 配对
POST /api/auth/login
POST /api/desktop/clients/register # 换 client_token
POST /api/desktop/clients/rotate # 滚动刷新 client_token
POST /api/desktop/clients/revoke # 主动撤销
POST /api/desktop/clients/heartbeat # 心跳 + 上报 client_version/os/arch
POST /api/desktop/clients/metrics # 轻量非敏感聚合指标
# 事件 & 任务租约
GET /api/desktop/events # SSE, client_token
POST /api/desktop/tasks/lease # {task_id?} 原子领取;kind=publish|monitor 过滤
POST /api/desktop/tasks/{id}/extend # body: {lease_token},续租
POST /api/desktop/tasks/{id}/pause # body: {lease_token, reason: 'waiting_user'|'captcha'}
POST /api/desktop/tasks/{id}/resume # body: {lease_token}
POST /api/desktop/tasks/{id}/cancel # body: {lease_token}
POST /api/desktop/tasks/{id}/result # body: {lease_token, status, payload}
POST /api/desktop/tasks/{id}/trace # 用户主动上传诊断包
# 账号元数据(不传 cookie)
POST /api/desktop/accounts # upsert:以 (workspace_id, platform, platform_uid) 为键
PATCH /api/desktop/accounts/{id} # 改 displayName / health / quota
DELETE /api/desktop/accounts/{id}
# Patch 通道
GET /api/desktop/patches/manifest # 返回当前适用的 manifest 列表 + revoke 指令
# 管理 UI(Web / Electron UI 共用)
GET /api/tenant/monitoring-plans
POST /api/tenant/publish-jobs
# ...
幂等契约:所有写操作(lease/extend/pause/resume/cancel/result)必须携带 lease_token;服务端校验 lease_token 与当前 active_attempt_id 绑定,否则返 409 Conflict。客户端收到 409 = 租约已失效,停止动作 + 提示人工对账。
6.5 服务端边界
服务端只存任务意图("账号 A 去豆包问 Q")与结果("答案文本 + 品牌是否出现")。永不存 Cookie、永不直连平台帮客户端跑。客户端失联 = 任务排队;长时间拿不到 = 超时失败。硬边界,防止 Cookie 从客户端外泄。
6.6 DB Schema(关键表)
-- 客户端身份
CREATE TABLE desktop_clients (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
workspace_id UUID NOT NULL,
user_id UUID NOT NULL,
token_hash BYTEA NOT NULL,
device_name TEXT,
os TEXT,
cpu_arch TEXT,
client_version TEXT, -- 仅作 metadata,不入 token 绑定
channel TEXT, -- alpha/beta/stable
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ,
last_rotated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- 平台账号(workspace 独占约束落地)
CREATE TABLE platform_accounts (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
workspace_id UUID NOT NULL,
client_id UUID NOT NULL REFERENCES desktop_clients(id),
platform TEXT NOT NULL,
platform_uid TEXT NOT NULL, -- 平台侧真实 ID(必填)
account_fingerprint TEXT,
display_name TEXT NOT NULL,
health TEXT NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ,
UNIQUE (platform, platform_uid) -- 硬约束 8 的 DB 落地
);
-- 任务 + 租约
CREATE TABLE desktop_tasks (
id UUID PRIMARY KEY,
kind TEXT NOT NULL, -- 'publish' | 'monitor'
payload JSONB NOT NULL,
status TEXT NOT NULL, -- queued | in_progress | waiting_user | succeeded | failed | unknown | aborted
dedup_key TEXT,
-- 租约
active_attempt_id UUID, -- null 表示无租约持有者
lease_token_hash BYTEA,
lease_client_id UUID REFERENCES desktop_clients(id),
lease_expires_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
-- 结果
result JSONB,
error JSONB,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- 历次 attempt 的审计(调试用,可选保留 30 天)
CREATE TABLE desktop_task_attempts (
id UUID PRIMARY KEY,
task_id UUID NOT NULL REFERENCES desktop_tasks(id),
client_id UUID NOT NULL REFERENCES desktop_clients(id),
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
final_status TEXT,
error JSONB
);
CREATE TABLE desktop_task_traces (
task_id UUID NOT NULL REFERENCES desktop_tasks(id),
attempt_id UUID,
size BIGINT,
uploaded_at TIMESTAMPTZ,
object_key TEXT
);
6.7 任务生命周期(含租约细节)
Web UI 创建 MonitoringPlan("brand=X on doubao,qwen daily 9am")
↓
Scheduler (server cron) 展开成 N 条 desktop_tasks (status=queued)
↓
SSE push 'task_available' → 任意实例 → 对应 SSE 连接 → client
↓
Client POST /tasks/lease → DB atomic claim →
返回 {task, attempt_id, lease_token, lease_expires_at=+10min}
↓
Client 执行(filling→submitting),每 60s POST /extend
↓
如果 manual 等审核 → POST /pause(reason=waiting_user) → lease_expires_at=+30min
审核通过 → POST /resume
↓
POST /result(lease_token, status=succeeded|failed) → server 校验 lease_token 后存结果
↓
Web UI 可视化
超时处理:lease_expires_at 到期后 scheduler 把 task 放回 queued,active_attempt_id 置空;若老 client 恢复后再 POST /result(lease_token=过期值),返 409 → client 本地记为 unknown 提示人工。
6.8 admin-web 协同(发布 Modal + 媒体数据面板 + 离线降级)
桌面客户端是执行者与凭据持有者,但用户日常操作的入口仍然是 apps/admin-web(SaaS Web 端)。/api/desktop/* 面向桌面端(§6.4),/api/tenant/* 面向 Web 端;server 内部共用 §6.3 的 RabbitMQ + DB。
6.8.1 角色分工
| 角色 | 职责 | 数据主权 |
|---|---|---|
| admin-web(Web SaaS) | 内容编辑、创建任务、展示结果、总览媒体数据 | Task 定义与结果的 SoT |
| desktop-client | 凭据持有、页面自动化、执行任务、上报结果 | Account existence + cookie 的 SoT |
6.8.2 发布 Modal 交互流(账号级选号,非平台级)
核心前提:一个 workspace 下同一平台通常有多个账号(例如 5 个微信公众号 + 3 个头条号 + 2 个知乎号)。Modal 的勾选粒度是账号,用户可以挑"平台 A 的账号 1/2/3 + 平台 B 的账号 5"这种跨平台跨账号组合;一个 publish job 展开为 N 条 desktop_tasks,每条一个 (platform, account) 组合。
流程:
- 打开:
GET /api/tenant/accounts?kind=publish&workspace_id=...拉回 workspace 下所有发布账号。响应按(platform, account)两层结构组织,每条带:online(由 server 根据该账号所属client_id的last_seen_at < 5 min判定)health(live / expired / captcha / risk)lastPublishAt(上次成功发布时间)
- Modal UI:
- 平台折叠块,标题显示"选中 X / 共 Y"
- 每账号一行:
checkbox · displayName · health badge · online badge · 最近发布时间 - 全选 / 反选(按平台或全局)
- 选模式(manual / auto)、时间(立刻 / 定时)、素材策略(封面默认 / 指定)
- 提交:
POST /api/tenant/publish-jobs,body 示例:server 展开成 3 条{ "title": "2026 Q2 新品发布", "content_ref": "draft:1234", "accounts": [ {"account_id": "acc-a1", "mode": "auto"}, {"account_id": "acc-a2", "mode": "manual"}, {"account_id": "acc-b5", "mode": "auto"} ], "schedule_at": null }desktop_tasks(kind=publish),共享同一job_id;每条绑定一个account_id与其所属client_id。 - Modal 切到"进度面板",订阅面向 Web 的 SSE
GET /api/tenant/events?topic=job:<job_id>;接收每子任务状态变化(queued → in_progress → waiting_user → succeeded/failed/unknown/aborted)。 - manual 模式任务进入
waiting_user后,面板上显示"账号 X 等待桌面端审核",带"前往桌面端"按钮 →georankly://tasks/<id>/review(未装客户端时跳下载页)。 - 全部终态后 Modal 汇总:每账号结果 URL / 错误码;对失败子任务点"重试" →
POST /api/tenant/publish-jobs/{job_id}/retry?task_ids=...。
6.8.3 媒体数据面板(账号级粒度)
admin-web 的"账号总览 / 发布历史 / 监控结果"三个页面全部按 (platform, account) 两层粒度:
| 页面 | API | 行粒度 |
|---|---|---|
| 账号总览 | GET /api/tenant/accounts |
(platform, platform_uid) |
| 发布历史 | GET /api/tenant/publish-jobs?... |
job 分组,展开后每行 (job, account, status) |
| 监控结果 | GET /api/tenant/monitoring-results?plan_id=&range= |
每行 (plan, query, llm_account, answer, hit) |
统计卡片:workspace 在线桌面端数、各平台账号健康率、近 7 天发布成功率(按账号)、近 7 天监控完成率、过去 24h 异常账号数 / 异常平台数。
6.8.4 admin-web 新增 / 扩充 API
GET /api/tenant/accounts # 带 online + health + lastPublishAt 聚合
GET /api/tenant/accounts/{id}
GET /api/tenant/clients # 本 workspace 的桌面端在线情况
GET /api/tenant/publish-jobs # 发布批次列表
GET /api/tenant/publish-jobs/{id} # 批次详情含子任务
POST /api/tenant/publish-jobs # 创建(见 §6.8.2 step 3)
POST /api/tenant/publish-jobs/{id}/retry # 重跑失败子任务
GET /api/tenant/monitoring-results # 支持 filter / range / 按 llm / 按账号
GET /api/tenant/events?topic=job:<id>|account:<id>|workspace # 面向 Web 的 SSE
关键:/api/tenant/events 与 /api/desktop/events(§6.4)是两条独立通道,订阅 topic 不同——前者推 task 状态给 Web UI,后者推 task 分派给 desktop client。
6.8.5 离线降级 UX
- 某桌面端离线(
last_seen_at > 5 min):- Modal 里所属账号标"离线(上次 X 分钟前)",仍可勾选,顶部提示"任务将在该端上线后自动执行"。
- 账号总览 / 监控结果用灰色 badge。
- workspace 所有桌面端都离线:
- Modal 顶部 banner:"当前 workspace 无在线桌面端。任务会排队等待。[下载桌面端]"
- 监控计划若连续 N 次未执行,站内通知 + 邮件给 workspace owner。
- 桌面端重新上线:
- desktop-client 启动
POST /api/desktop/clients/heartbeat+ §6.8.6 resync;queued 任务通过 SSE 被自然分派。 - admin-web 的 SSE 收到
client_online事件自动刷新 badge,不需 reload。
- desktop-client 启动
6.8.6 Account 数据一致性(client ↔ server)
client 是 account existence 的 SoT,server 存投影:
- 登录完成:client
POST /api/desktop/accounts(upsert,以(workspace_id, platform, platform_uid)为键,唯一索引见 §6.6)。 - 健康 / 昵称变化:client
PATCH /api/desktop/accounts/{id}。 - 删除:client
DELETE /api/desktop/accounts/{id}→ server 级联把该账号未完成的 task 置为aborted。 - 启动时 resync:client
GET /api/desktop/accounts?client=self比对本地清单;server 有而本地没的(用户在别处删过)→ 本地删;反之 upsert;platform_uid冲突走 §6.6 唯一索引处理。
admin-web 看到"账号不存在"(Modal 里某账号消失)= client 已删;秒级一致性延迟可接受。
6.8.7 深链接 georankly://
| 深链 | 场景 | Fallback |
|---|---|---|
georankly://accounts |
"前往桌面端管理账号" | 未装 → 下载页 |
georankly://accounts/add?platform=wechat |
"一键添加某平台账号" | 同上 |
georankly://tasks/<id>/review |
Manual 模式等审核 | 同上 |
admin-web 点深链前先查 /api/tenant/clients 判断是否有该 workspace 的在线桌面端;没有直接弹下载引导,不让用户点空链接。
7. 错误处理与可观测性
7.1 错误分层
| 层 | 例子 | 处理 |
|---|---|---|
| 适配器内部(可重试) | 选择器暂未命中、SSE 断流、CDP 瞬时断开 | 适配器内部指数退避重试,最多 3 次,不升级 |
| 账号级 | Cookie 过期、风控滑块、封号 | health=expired/risk;托盘红点 + OS 通知;任务返回 ACCOUNT_UNAVAILABLE;服务端不对该账号 retry,派给池里其它账号 |
| 平台级 | 全账号连续失败 N 次、Cloudflare 5xx | 熔断 15 分钟;新任务返回 PLATFORM_CIRCUIT_OPEN;到时半开探活 |
| 客户端级 | 主进程 uncaughtException、渲染进程崩溃 | 落本地 crash log → 可选上报 → 优雅重启 |
7.2 结构化日志
pino JSON,字段约定:ts, level, module, accountId, taskId, attemptId, leaseToken, stage, event, durationMs, bytes。本地 rolling file(10 MB × 20 份)。不默认上传。脱敏字段:Cookie / Set-Cookie / Authorization / x-xsrf-token / password / 手机号 / email。
7.3 指标上报
5 min 一次上报 /api/desktop/clients/metrics,只含非敏感聚合:CPU/内存/事件循环延迟、每平台每账号的任务成功率 + 平均耗时、SSE 连接状态、pull 回退次数、token bucket 拒绝率。不含查询内容、回答内容、Cookie。
7.4 用户可见反馈
- 托盘图标三色(绿/黄/红)表示账号健康。
- OS 通知只在需要用户动作时弹(登录失效、捕获到验证码、租约失效导致 UNKNOWN 需人工对账)。
- UI "事件中心":列最近 50 次失败,点进去直接"重新运行"或"启用录屏再跑"。
- "发送诊断包"按钮:最近 1 小时日志 + trace 打包脱敏上传,返回
diagnostic_id。
7.5 崩溃恢复(按 task.kind 分治)
启动时扫描 desktop_tasks 里 lease_client_id = self AND status IN (in_progress, waiting_user) 的任务:
Monitor 任务(幂等只读,可安全重跑):
- 任何阶段崩溃 → 丢弃本地状态 → 放回队列 → 下次被分派重跑。日志里记录
previous_attempt_aborted_by_crash用于对账。 - 不标 UNKNOWN,不骚扰用户。
Publish 任务(不可逆副作用,保守处理):
- 未进入
submitting阶段(即还在filling或readyToReview):重新 ack → 把 state 重置到filling从头跑,因为还没点过"发布"按钮。 - 已进入
submitting:放弃任务,标unknown。UI 提示"上次崩溃时尚不确定是否发布成功,请去平台确认后手动标注 succeeded 或重新创建任务"。避免重复发布。
8. 打包、自动更新、签名
8.1 工具链
- electron-builder 多平台打包 + 差分更新 + 签名。
- electron-updater 跑在 Main 进程,
latest-*.yml+ blockmap 差分下发。 - 渠道:
stable/beta/dev,用户可切。Patch 热更通道独立,不走 electron-updater。
8.2 目标矩阵
| OS | Arch | 产物 | 签名 |
|---|---|---|---|
| macOS | universal(Intel + Apple Silicon) | .dmg + .zip |
Apple Developer ID + notarytool 公证(已有 Apple Dev 账号,CI 直接接入) |
| Windows | x64 | .exe (nsis) |
暂无,后期购买(正式发布前阻塞项) |
| Windows | arm64 | .exe (nsis) |
同上 |
| Linux | x64 | .AppImage + .deb |
GPG 可选 |
| Linux | arm64 | .AppImage + .deb |
GPG 可选 |
产物大小目标:单平台 ≤ 120 MB。
native deps 多架构注意:better-sqlite3 / keytar 等在 universal Mac build 里需通过 electron-builder 的 buildDependenciesFromSource 或 pre-built multi-arch binaries 处理,CI 会增加一步 @electron/rebuild 针对每个 target。
8.3 CI
GitHub Actions,runner 矩阵:macos-14 (arm64) + macos-13 (x64) 合并 universal;windows-latest 出 x64;Windows arm64 用 cross-compile;Linux x64 ubuntu-latest;Linux arm64 优先用 ubuntu-22.04-arm 原生 runner,不可用时退化到 QEMU(2026-04 GitHub arm64 runner 仍在限量可用)。
发布 tag v1.2.3 → 打包 → 上传对象存储 + 生成 latest-*.yml → autoUpdater.checkForUpdates() 看到。
8.4 更新 UX
- 默认"自动下载 + 手动安装"。下好后 Tray 角标提示"新版本可用,点击重启安装"。不打断正在跑的任务。
- 强制更新:服务端下发
minClientVersion;低于该版本拒绝启动,引导升级(配合 30 分钟宽限期 + 本地可见的手动下载链接兜底)。 - 降级:使用
electron-updater的allowDowngrade: true(注意正确拼写,旧 spec 写的downgrade属性不存在)。服务端可下发"建议版本号",客户端比建议版本新时允许一键回滚。 - N-1 回退:对象存储长期保留上一版安装包 + manifest,UI 提供"手动下载上一版本"链接,作为 autoUpdater 失败的人工兜底。
- pre-release 通道每次打包都跑一次updater smoke 自动化(见 §9.7)。
8.5 应用标识
app.requestSingleInstanceLock():禁止同用户开两个副本。- Protocol handler:
georankly://,用于 Web 端深链接到客户端。 - macOS 关闭窗口 ≠ 退出;Windows/Linux 最小化到托盘;退出必须从 Tray 菜单。
8.6 安全加固
contextIsolation: true、nodeIntegration: false、sandbox: truefor renderer。- CSP 严格模式;renderer 不允许加载远程 JS。
webContents.setWindowOpenHandler拦截所有 window.open → 系统浏览器。- 禁用
<webview>tag,第三方平台一律用WebContentsView。
9. 测试策略
9.1 金字塔
┌──────────────┐ 手工 + 冒烟 Playbook(每 release)
│ E2E 烟雾 │
├──────────────┤ Playwright @ Electron + fake server + fake platforms(每 PR)
│ 集成 / 行为 │
├──────────────┤ Vitest(每 commit)
│ 单元(adapter)│
├──────────────┤ tsc --noEmit + eslint + typecheck(每保存)
│ 类型 / 静态 │
└──────────────┘
9.2 单元
- 对象:适配器的 SSE parser(纯函数)、发布状态机 + 租约交互逻辑、lease-manager 本地状态机、autoUpdater 核心逻辑(版本比较、签名验签、
allowDowngrade决策、minClientVersion校验)、patch-loader 签名与序号校验。 - Fixtures:
tests/fixtures/sse/doubao-2026-04.txt、tests/fixtures/manifest/valid-*.json+invalid-*.json。 - 平台 payload 变动 → 补 fixture → 测试跟着更新。
9.3 集成
用 Playwright _electron.launch 驱动 Electron,对 本地 fake server + 本地 fake 平台(HTTP server 喂 fixture SSE)。13+5 适配器全跑一遍,并行约 3–5 分钟。
9.4 集成 · 协议
客户端 + 真实 server + fake 平台:验证任务分发 / 租约 (lease/extend/pause/resume/cancel) / 多实例广播 / 幂等 / 熔断 / 断线重连。
9.5 人工冒烟 Playbook
每次 release 用测试账号:
- 每发布平台跑一个 draft/private publish → 立即删。
- 每 LLM 跑 3 条标准查询,对比上版本抓取结构一致性。
记成 md 文档由 QA 跟着跑,半小时完成。
9.6 Trace 回归套件
工具 pnpm dev:replay <trace.zip>:把真实 trace 里的 network responses 重放到 fake 平台,跑 adapter。历史错过的输入永远不再破解析器。
9.7 autoUpdater Smoke(pre-release 必跑)
不是"完整升级 E2E",但必须覆盖:
- 旧版本打包 + 新版本打包 + 从旧版本启动 → 拉 manifest → 下载 → 安装 → 启动新版本(headless 跑通即 pass)。
- 签名不一致、manifest 过期、
minClientVersion不满足 → 各一条负面用例。 allowDowngrade: true与false各一条正向用例。
9.8 门禁
- PR:单元 + 集成(fake)全过。
- Release tag:+ 冒烟 Playbook 签字 + updater smoke 全过。
- 每周:trace 诊断报告 review。
10. 上线节奏与回滚
10.1 阶段
| 阶段 | 受众 | 签名 | 退出门槛 |
|---|---|---|---|
| Alpha(内部) | 开发 + QA(≤5 人) | Mac 公证 / Win 无签名 / Linux 裸 AppImage | 13+5 适配器 fake + 真实账号成功率 ≥ 90%;lease 协议在混沌测试下无脏写 |
| Beta(邀请制) | 公司内部 + 合作客户(≤30 人) | 同上 | 连续 7 天无 P0;保活命中率 > 95%;patch 通道应急修复跑通 ≥ 1 次;autoUpdater smoke 每 release 全过 |
| GA 候选 | 放开注册 | Win 签名必须到位 | 安装成功率 > 99%;crash rate < 0.1%/session |
客户端和服务端同加 channel(alpha/beta/stable),更新按 channel 拉不同 latest-*.yml。"关于"里可切。
10.2 硬切换落地顺序(开发版本无生产用户)
因为当前还是开发版本,没有真实生产用户在跑旧 /api/plugin/*,不需要过渡期:
- 同一个 PR 里:新增
/api/desktop/*路由 + 新 DB 迁移 + 删除/api/plugin/*路由与相关 handler。 - 同一个 PR 里:删除
apps/browser-extension/整个目录。 - 废弃文档
docs/media-publisher-extension-runtime-v1.md,新写桌面客户端运行时文档。 - 发 Alpha 客户端;内部账号全部用 Electron 重登。
如果未来发现有内部测试账号遗留在老表里:手动导出账号元数据后 drop 老表,不写自动迁移。
10.3 工作量重估(4 块)
粗估(单人工程周):
| 块 | 范围 | 估时 |
|---|---|---|
| A. Desktop runtime 基座 | Main 进程、session-registry、view-pool、vault、lease-manager、rate-limiter、circuit-breaker、crash-recovery、tracing(Alpha 版)、transport (SSE + pull + RabbitMQ presence) | 3–4 周 |
| B. LLM 监控适配器 | Doubao + Qwen + DeepSeek(从零) + 混元(从零) + Kimi(从零) | 4–5 周 |
| C. 已有真实发布能力的平台迁移 | 约 6 家(头条 / 百家 / 搜狐 / 知乎 / 企鹅 / 简书)从 chrome.* 迁到 Electron 抽象 | 3 周 |
| D. Detect-only 平台从零实现 publish | 7 家(微信公众号 / 掘金 / 懂车帝 / 什么值得买 / 网易 / B 站 / ZOL) | 5–7 周 |
| E. 服务端协议 + DB + sqlc 重生 + Service 层重构 | /api/desktop/*、租约、presence、patch manifest |
2 周 |
| F. 打包 5 arch + Mac 公证 + autoUpdater + updater smoke | — | 1.5 周 |
| G. Patch 通道(DSL 解释器 + sandbox 备用 + 签名链) | — | 2 周 |
| H. packages/ui-shared 抽取 + desktop renderer | — | 1.5 周 |
| I. 测试基建(fake server + fake 平台 + replay) | — | 1.5 周 |
合计约 24–28 工程周(LLM 从 3 家扩到 5 家增加约 2 周)。这份 spec 因此应拆 3 个 plan(见 §12)。
10.4 三层回滚
| 情景 | 动作 |
|---|---|
| 某 patch 导致单平台适配器坏 | 服务端下发 revoke patch_seq + 上一版本可用 manifest;客户端下次任务自动降回前一版 patch(秒级) |
| 某 release 中等问题 | 下发"建议版本 ≤ X";electron-updater 用 allowDowngrade: true 降级(分钟级);+ 手动 N-1 下载链接 |
| 某 release 严重安全问题 | 下发 minClientVersion = Y;低于 Y 的客户端 30 分钟宽限后拒绝启动,UI 引导升级 |
所有回滚只在服务端配置,不动客户端代码。
10.5 运营指标
- 每平台适配器每日成功率(target:发布 > 95%、监控 > 90%)
- 每次 crash 的 top 堆栈
- SSE 长连接平均保持时长(target > 30 min)
- Patch 推送后"多久 80% 客户端加载新 patch"(target < 30 min)
- 租约冲突率(409 / 百次 lease;target < 0.5%)
11. 已知风险
- Win 无签名期间(Alpha/Beta),用户首次安装会遇 SmartScreen 警告,且有机率被 Defender / 360 / 国内安全软件主动隔离。Alpha 期间通过白名单与用户手动放行缓解。
- 豆包/千问网页结构变,最坏情况紧急手搓 patch,SLA 按小时算。DSL 能力有限时退化到备用隔离执行通道。
- 用户跨 OS 切换(Mac → Win 再回来)不迁移登录态(local-only cookies),需全部重登。或使用
vault-export手工带密口令迁移。 - 账号被平台风控无法自动恢复;UI 引导用户换号。
- 开机自启 + 托盘常驻可能被企业 EDR 误报恶意软件;需准备白名单说明材料。
- Electron 基础 RAM 占用~300 MB + 每 hot-tier WebContentsView ~50 MB。默认 Hot/Cold 分档后,10 账号活跃场景约 600–800 MB。
- Electron CVE 维护节奏:Electron 约每 4–6 周发安全版本,需对应 release 通道的补丁节奏。滞后发版 > 2 周的 Electron 主线安全问题需主动升级。
- 平台 ToS 风险:部分媒体平台(尤其微信公众号)明文禁止自动化操作。Alpha/Beta 阶段限定给明确授权账号;商业化前评估法务暴露面。
- CDP domain 可用性跟随 Chromium 版本(如
Network.streamResourceContent≥ 120)。升 Electron 大版本前必须跑一遍electron-chromium-cdp-matrix.md回归。
12. Plan 拆分建议
本 spec 包含约 22–26 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship:
Plan A · 骨架穿透(5–6 周,Alpha 上限)
- 块 A(Desktop runtime 基座)
- 块 E(服务端协议 + DB)
- 块 H(ui-shared + renderer)
- 1 个 监控适配器(Doubao)+ 1 个 发布适配器(头条号,它已有 publish 能力)
- 块 I(测试基建)
- 块 F 但仅 Mac 公证
退出目标:端到端能跑一条 Doubao 监控任务 + 一条头条发布任务,Mac 公证版可安装,lease 协议在 fake 多实例下无脏写。
Plan B · 广度铺开(10–12 周,Beta 上限)
- 块 B(Qwen + DeepSeek + 混元 + Kimi 从零)
- 块 C(其余 5 家已有真实发布能力的平台迁移)
- 块 D(7 家 detect-only 从零实现 publish)
- 块 F 完整:Win/Linux 全架构 + 签名(Win 签名到位)+ autoUpdater 完整
- 块 I 补齐:多适配器回归 + 冒烟 Playbook
退出目标:13+5 适配器全量可用,autoUpdater smoke 每 release 全过,5 arch × 3 OS 全量打包签名。
Plan C · 运维闭环(4–5 周,GA 上限)
- 块 G(Patch 通道 DSL + 沙箱 + 签名链)
- Phase 2 Trace 基建(CDP 全事件流 + Playwright 兼容导出)
- Metrics dashboard + 报警规则
- 回滚剧本与演练
退出目标:patch 热更经过混沌演练,trace 能被外部 Playwright tooling 打开,运营指标全套接入报警。
13. 变更记录
- v2.1(2026-04-18):用户增量反馈后的小修订。
- LLM 监控 3 家 → 5 家:新增混元(Tencent Hunyuan)、Kimi(Moonshot),§4.4 保活表增行(两家默认走 8 min 主动保活,实际节奏接入时复测);§10.3 块 B 估时 2–3 周 → 4–5 周;总工期 22–26 周 → 24–28 周;§12 Plan B 范围更新。
- 新增 §6.8「admin-web 协同」:覆盖发布 Modal 账号级多选语义、媒体数据面板(账号级粒度)、
/api/tenant/*聚合与 SSE 通道、离线降级 UX、Account 数据一致性(client = existence SoT / server = 投影)、georankly://深链接。明确/api/tenant/events与/api/desktop/events是两条独立 SSE 通道。 - 发布 Modal 选号粒度固化为账号级(非平台级):一个 job 展开为 N 条 desktop_tasks,每条绑定一个
(platform, account)。
- v2(2026-04-18):Claude + Codex + Gemini 三方交叉审查后的修订版。主要修订:
- C1 修正 CDP 流式 SSE 抓取方法(§5.2),改为按 EventSource / fetch-stream 分流;维护 Electron Chromium CDP 版本矩阵。
- C2 Patch 通道默认改为纯数据 DSL(无 JS 执行),可执行代码作为备用通道并限定在
utilityProcess + isolated-vm沙箱;引入 TUF 风格 manifest + patch_seq + 根密钥轮换 + 撤销机制(§5.5)。 - C3 补齐 SSE 多实例协调方案:RabbitMQ 广播 + Redis presence registry,SSE 仅推通知、任务归属由 lease endpoint 原子决定(§6.3)。
- C4
platform_accounts新增platform_uid+ 唯一索引(platform, platform_uid),落实硬约束 8(§6.6)。 - C5 任务协议引入
attempt_id + lease_token+ extend/pause/resume/cancel;manual 模式进入waiting_user状态并延长租约(§5.3/§6.4/§6.6/§6.7)。 - C6 §3.3 重写工作量与可复用性口径,显式列出 7 家 detect-only 发布适配器 + DeepSeek 监控占位;§10.3 拆 9 个工作块重估总时;§12 新增 3-plan 拆分建议。
- W1
BrowserView全局替换为WebContentsView。 - W2 autoUpdater 配置名修正
downgrade→allowDowngrade;加 N-1 手动下载链接 + pre-release updater smoke(§8.4/§9.7)。 - W3 Client Token 解绑
client_version/os/cpu_arch,新增/rotate和/revoke(§6.2/§6.4)。 - W4 Patch 签名链补全 manifest 字段、根密钥轮换、运行前 hash 二次校验、revoke 指令(§5.5)。
- W5
safeStorage启动时检测可用性;Linuxbasic_text降级告警;跨机迁移改为独立的vault-export用户交互式流程(§4.5)。 - W6 崩溃恢复按
task.kind分治:monitor 幂等重跑,publish 按submitting边界决定人工对账(§7.5)。 - W7 保活引入 per-(platform,account) token bucket(业务+保活+探活同桶);429/captcha 指数退避;resume 错峰 warm-up(§4.4)。
- W8
apps/admin-web不再被 renderer 直接复用;抽packages/ui-shared作为共享包;renderer 是极简 Vite 项目(§3.3)。 - W9 发布状态机加
failed / aborted / unknown终止态(§5.3)。 - W10 §6.1 显式列出 sqlc 重生成与 Service/Handler 受影响面。
- W11 Trace 拆 Alpha(结构化日志 + 失败截屏)vs Phase 2(CDP 全事件流 + Playwright 兼容导出)(§5.6)。
- W12 §9.2 + §9.7 新增 autoUpdater 核心逻辑单元测试 + updater smoke 自动化覆盖。
- Info 合并:
view-poolHot/Cold 分档、proxy-helper新建、登录 1.5s 遮罩层、时区明确为本地、配额默认值、vault-export备份流程、universal Mac 原生依赖、arm64 runner 兜底、平台 ToS / Electron CVE / Win Defender 三条新风险(§3.1/§4.2/§4.4/§4.5/§5.4/§8.2/§8.3/§11)。
14. 术语表
- Patch 通道:仅下发适配器的声明式数据与受限 DSL(默认)或隔离沙箱执行的纯函数(备用),分钟级生效,不重启。
- Release 通道:完整二进制 + 差分升级,通过 electron-updater。
- 探活(probe):加载主页 DOM 判断登录态,低代价。
- 保活(keep-alive / heartbeat):主动访问平台私有 API 触发 token 刷新。
- Client Token:一台 Electron 安装的后台身份,与 User JWT 并列;解耦 client_version,支持 rotate/revoke。
- Partition:Electron
session.fromPartition('persist:<id>'),每个账号一个隔离的 cookie/storage 容器。 - Lease:任务租约。领取任务时服务端返回
attempt_id + lease_token + lease_expires_at;后续所有写操作必须带lease_token验证。 - Waiting-user 状态:manual 发布模式下,adapter 暂停等用户审核时,服务端将任务置为此状态并延长租约;在此状态下不会被重分配。
- Platform UID:平台侧真实账号 ID(如微信 fakeid、B 站 mid、知乎 hash_id);
(platform, platform_uid)在 DB 层唯一,落实硬约束 8。 - DSL(Patch 默认通道):一套受限的声明式解析语言,只能做字符串/JSON 解析与模式匹配,无 JS 执行能力。
- Presence registry:多实例服务端共享的
client_id → (instance_id, conn_id)注册表,用 Redis TTL 30 s 维护;用于跨实例路由 SSE 通知。