Files
geo/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md
T
root c41222706b docs(desktop-client): spec v2.1 adds admin-web coordination + 2 LLMs
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>
2026-04-18 21:22:38 +08:00

974 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Electron 桌面客户端设计(全面替换 WXT 浏览器插件)
**状态**Draft v2 · 待实施(v1 经 Claude + Codex + Gemini 三方交叉审查后修订)
**作者**@liangxu + Claudecodex/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")同时承担两类工作:
1. 向 13 家中国媒体平台做内容分发(微信公众号、头条号、百家号、搜狐号、网易号、ZOL、懂车帝、知乎、企鹅号、简书、掘金、B 站、什么值得买)。
2. 监控主流 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. 设计硬约束(已确认)
1. **全面替换**,不做"先 monitoring 后 publishing"的分阶段替换。
2. **不做向后兼容**。因为是开发版本阶段,服务端协议、DB schema、认证字段按桌面客户端的最佳形态重新设计,不为插件保留旧字段或旧路由。
3. **登录走嵌入式 Web View**。用户在 Electron 真实平台登录页里自己完成扫码/密码/短信/滑块;我们只保存 cookie,不保存账号密码。
4. **Cookie 本地存储 并加密**Electron `safeStorage`)。服务端**永不存储** Cookie 或任何平台凭据。Linux 上 `safeStorage` 可能退化为 `basic_text`,必须启动时检测并显式降级告警(见 §4.5)。
5. **LLM 监控一律走网页 + CDP 拦截**,不调用任何 LLM 官方 API(成本导向;多账号 = 多配额,线性成本比 token 计费友好)。后端 `llm.api_key` 是后端自用管线,与客户端监控完全解耦。
6. **不打包 Playwright**。复用 Electron 自带 Chromium,通过 `webContents.debugger` 直接用 CDP。包体越小越好。
7. **支持 5 目标**Windows x64/arm64、macOS Intel+Apple Silicon、Linux x64/arm64。
8. **一个平台账号只绑定一个 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 分钟有任务或保活的账号)常驻 WebContentsViewCold-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 # 轻量 zipAlpha 仅 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` 框架依赖,全部换成 Electron `session / WebContentsView / webContents.debugger / Fetch domain`
- **发布适配器中 7 家 detect-only 的平台**(微信公众号、掘金、懂车帝、什么值得买、网易号、B 站、ZOL)**需从零实现 publish**。
- **DeepSeek 监控**需从零实现 query。
- Trace 基建、Patch 通道、租约管理器、LeaseManager + sandbox-loader、ui-shared 抽取——全部新建。
---
## 4. 账号 & 会话模型
### 4.1 数据结构
```ts
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 登录流程
1. 用户在 UI 点「新增账号 → 选平台」。
2. 主进程 `session-registry.create(platform)` 分配新 partition,打开一个**可见** `BrowserWindow`(登录专用),加载该平台登录页。
3. 用户自己用扫码 / 密码 / 短信 / 滑块登录。
4. 登录探针(preload 脚本 + URL 规则 + 成功态 DOM 标记)检测到已登录态后,在窗口顶部叠加"登录成功"遮罩层 1.5 s,然后关窗。
5. 探针在关窗前抓取平台侧 `platformUid`(如微信公众号的 `fakeid` / B 站的 `mid` / 知乎的 `hash_id`),写入 `Account.platformUid`
6. Electron 自动把 cookies 持久化到 `userData/Partitions/<key>/`;同时主进程用 `safeStorage` 加密写本地 SQLite 作为**本机恢复缓存**(跨账号解绑、重装应用时 restore 用;**非迁机器备份**)。
7. 启动时强制 `safeStorage.isEncryptionAvailable()`;若在 Linux 上 `safeStorage.getSelectedStorageBackend()` 返回 `basic_text`,UI 显著告警、默认关闭加密快照(避免把平台 cookie 明文写盘)。
8. 账号进入"常驻池"hot-tier 分配 WebContentsViewcold-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 接口定义
```ts
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 CDPfinally
```
**Provider-specific parser**:每家 LLM 的 payload schema 不同(豆包 `{event: 'data', data: {text: ...}}` 这种结构),放 `adapters/doubao.ts` 等私有文件。Schema 变化通过 Patch 通道下发新 `parser`(§5.5)。
### 5.3 发布适配器状态机 + 租约绑定
每个发布任务或每个账号带 `PublishMode`
```ts
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 至少包含:
```json
{
"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 2Beta 之后)**
- 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/*.go` service 文件
- `server/internal/tenant/transport/*.go` handler
- 对应 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` 原子性决定**DB `UPDATE ... 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)` 写到 RedisTTL 30 sSSE 心跳续期)。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 指令
# 管理 UIWeb / 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(关键表)
```sql
-- 客户端身份
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-webWeb 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)` 组合。
流程:
1. 打开:`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`(上次成功发布时间)
2. Modal UI
- 平台折叠块,标题显示"选中 X / 共 Y"
- 每账号一行:`checkbox · displayName · health badge · online badge · 最近发布时间`
- 全选 / 反选(按平台或全局)
- 选模式(manual / auto)、时间(立刻 / 定时)、素材策略(封面默认 / 指定)
3. 提交:`POST /api/tenant/publish-jobs`body 示例:
```json
{
"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
}
```
server 展开成 3 条 `desktop_tasks(kind=publish)`,共享同一 `job_id`;每条绑定一个 `account_id` 与其所属 `client_id`。
4. Modal 切到"进度面板",订阅面向 Web 的 SSE `GET /api/tenant/events?topic=job:<job_id>`;接收每子任务状态变化(`queued → in_progress → waiting_user → succeeded/failed/unknown/aborted`)。
5. manual 模式任务进入 `waiting_user` 后,面板上显示"账号 X 等待桌面端审核",带"前往桌面端"按钮 → `georankly://tasks/<id>/review`(未装客户端时跳下载页)。
6. 全部终态后 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 resyncqueued 任务通过 SSE 被自然分派。
- admin-web 的 SSE 收到 `client_online` 事件自动刷新 badge,不需 reload。
#### 6.8.6 Account 数据一致性(client ↔ server
**client 是 account existence 的 SoTserver 存投影**
- 登录完成: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 file10 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 | universalIntel + 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 Actionsrunner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 universal`windows-latest` 出 x64Windows arm64 用 cross-compileLinux x64 `ubuntu-latest`Linux arm64 优先用 `ubuntu-22.04-arm` 原生 runner,不可用时退化到 QEMU2026-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: true` for 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 Smokepre-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/*`,不需要过渡期:
1. 同一个 PR 里:新增 `/api/desktop/*` 路由 + 新 DB 迁移 + 删除 `/api/plugin/*` 路由与相关 handler。
2. 同一个 PR 里:删除 `apps/browser-extension/` 整个目录。
3. 废弃文档 `docs/media-publisher-extension-runtime-v1.md`,新写桌面客户端运行时文档。
4. 发 Alpha 客户端;内部账号全部用 Electron 重登。
如果未来发现有内部测试账号遗留在老表里:手动导出账号元数据后 drop 老表,不写自动迁移。
### 10.3 工作量重估(4 块)
粗估(单人工程周):
| 块 | 范围 | 估时 |
|---|---|---|
| **A. Desktop runtime 基座** | Main 进程、session-registry、view-pool、vault、lease-manager、rate-limiter、circuit-breaker、crash-recovery、tracingAlpha 版)、transport (SSE + pull + RabbitMQ presence) | 34 周 |
| **B. LLM 监控适配器** | Doubao + Qwen + **DeepSeek(从零)** + **混元(从零)** + **Kimi(从零)** | 45 周 |
| **C. 已有真实发布能力的平台迁移** | 约 6 家(头条 / 百家 / 搜狐 / 知乎 / 企鹅 / 简书)从 chrome.* 迁到 Electron 抽象 | 3 周 |
| **D. Detect-only 平台从零实现 publish** | 7 家(微信公众号 / 掘金 / 懂车帝 / 什么值得买 / 网易 / B 站 / ZOL| 57 周 |
| **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 周 |
合计约 **2428 工程周**(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 / 百次 leasetarget < 0.5%
---
## 11. 已知风险
1. **Win 无签名期间**Alpha/Beta),用户首次安装会遇 SmartScreen 警告,且有机率被 Defender / 360 / 国内安全软件主动隔离。Alpha 期间通过白名单与用户手动放行缓解。
2. **豆包/千问网页结构变**,最坏情况紧急手搓 patch,SLA 按小时算。DSL 能力有限时退化到备用隔离执行通道。
3. **用户跨 OS 切换**(Mac → Win 再回来)不迁移登录态(local-only cookies),需全部重登。或使用 `vault-export` 手工带密口令迁移。
4. **账号被平台风控**无法自动恢复;UI 引导用户换号。
5. **开机自启 + 托盘常驻**可能被企业 EDR 误报恶意软件;需准备白名单说明材料。
6. **Electron 基础 RAM 占用**~300 MB + 每 hot-tier WebContentsView ~50 MB。默认 Hot/Cold 分档后,10 账号活跃场景约 600–800 MB。
7. **Electron CVE 维护节奏**Electron 约每 4–6 周发安全版本,需对应 release 通道的补丁节奏。滞后发版 > 2 周的 Electron 主线安全问题需主动升级。
8. **平台 ToS 风险**:部分媒体平台(尤其微信公众号)明文禁止自动化操作。Alpha/Beta 阶段限定给明确授权账号;商业化前评估法务暴露面。
9. **CDP domain 可用性跟随 Chromium 版本**(如 `Network.streamResourceContent` ≥ 120)。升 Electron 大版本前必须跑一遍 `electron-chromium-cdp-matrix.md` 回归。
---
## 12. Plan 拆分建议
本 spec 包含约 22–26 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship:
### Plan A · 骨架穿透(56 周,Alpha 上限)
- 块 ADesktop runtime 基座)
- 块 E(服务端协议 + DB
- 块 Hui-shared + renderer
- **1 个** 监控适配器(Doubao)+ **1 个** 发布适配器(头条号,它已有 publish 能力)
- 块 I(测试基建)
- 块 F 但**仅 Mac 公证**
**退出目标**:端到端能跑一条 Doubao 监控任务 + 一条头条发布任务,Mac 公证版可安装,lease 协议在 fake 多实例下无脏写。
### Plan B · 广度铺开(1012 周,Beta 上限)
- 块 BQwen + DeepSeek + 混元 + Kimi 从零)
- 块 C(其余 5 家已有真实发布能力的平台迁移)
- 块 D7 家 detect-only 从零实现 publish
- 块 F 完整:Win/Linux 全架构 + 签名(Win 签名到位)+ autoUpdater 完整
- 块 I 补齐:多适配器回归 + 冒烟 Playbook
**退出目标**13+5 适配器全量可用,autoUpdater smoke 每 release 全过,5 arch × 3 OS 全量打包签名。
### Plan C · 运维闭环(45 周,GA 上限)
- 块 GPatch 通道 DSL + 沙箱 + 签名链)
- Phase 2 Trace 基建(CDP 全事件流 + Playwright 兼容导出)
- Metrics dashboard + 报警规则
- 回滚剧本与演练
**退出目标**:patch 热更经过混沌演练,trace 能被外部 Playwright tooling 打开,运营指标全套接入报警。
---
## 13. 变更记录
- **v2.12026-04-18**:用户增量反馈后的小修订。
- **LLM 监控 3 家 → 5 家**:新增混元(Tencent Hunyuan)、KimiMoonshot),§4.4 保活表增行(两家默认走 8 min 主动保活,实际节奏接入时复测);§10.3 块 B 估时 2–3 周 → 4–5 周;总工期 22–26 周 → 2428 周;§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)`。
- **v22026-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 registrySSE 仅推通知、任务归属由 lease endpoint 原子决定(§6.3)。
- **C4** `platform_accounts` 新增 `platform_uid` + 唯一索引 `(platform, platform_uid)`,落实硬约束 8(§6.6)。
- **C5** 任务协议引入 `attempt_id + lease_token` + extend/pause/resume/cancelmanual 模式进入 `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` 启动时检测可用性;Linux `basic_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 2CDP 全事件流 + Playwright 兼容导出)(§5.6)。
- **W12** §9.2 + §9.7 新增 autoUpdater 核心逻辑单元测试 + updater smoke 自动化覆盖。
- **Info 合并**`view-pool` Hot/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。
- **DSLPatch 默认通道)**:一套受限的声明式解析语言,只能做字符串/JSON 解析与模式匹配,无 JS 执行能力。
- **Presence registry**:多实例服务端共享的 `client_id → (instance_id, conn_id)` 注册表,用 Redis TTL 30 s 维护;用于跨实例路由 SSE 通知。