Files
geo/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md
T
root a617d39a4a feat(desktop): drop parked review flow, add publish management
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
2026-04-20 09:52:48 +08:00

1393 lines
103 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.6 · 已按 2026-04-20 当前实现口径修订
**作者**@liangxu + Claudecodex/gemini 交叉审校)
**日期**2026-04-18
**范围**:新增 `apps/desktop-client/``/api/desktop/*` 服务端路由、相关 DB schema;删除 `apps/browser-extension/``/api/plugin/*` 路由及相关代码。
> **v2.6 当前实现口径**:平台侧二次人工审核设计已废弃。SaaS 完成人工审核后才创建 publish jobdesktop client 只负责执行发布与回写结果。`manual / waiting_user / parked / lease?from_parked=true / review-window` 相关设计全部作废。桌面端新增“发布管理”视图,用于查看待发布队列、历史发送结果,并允许对成功/失败/unknown/aborted 的发布任务手动再次发送。文档中若在历史修订记录里仍出现 parked/manual,均以本段为准。
---
## 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` 字段 + 唯一索引)。**前置条件**:workspace 必须先升成后端一等安全边界(见 §12 Plan 0);仅靠新增 middleware 不够,因为现有代码里 `Actor/Claims/cache/monitoring` 多处仍按 tenant 粒度(v2.2 三轮审查发现的底座问题)。
9. **演进缝预留**:短期 `一账号 = 一 workspace = 一台 client` 是产品边界,不是代码硬写。本版 DDL 继续使用 `workspace_id` / `client_id` / `target_client_id` 命名(不提前引入 `credential_holder_*` / `account_home_*` 命名以避免 v2.4 前 spec 与 DDL 不一致);未来真正做"共享运营账号池 / 桌面端主备"时,通过 rename migration + 兼容视图平滑演进。
10. 要求最小 electron 体积包
---
## 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 基座直接用;**v2.3 新增** `SseClient` 类——内置指数退避重连、`Last-Event-ID` 处理、首帧 snapshot 消费、事件去重。Web 和 Desktop 两端共用。[apps/admin-web/src/lib/api.ts](apps/admin-web/src/lib/api.ts) 现有的简陋 `subscribeSSE` 迁移到这里并补能力。
- **新建 `packages/ui-shared`**:从 `apps/admin-web` 抽出**通用组件、i18n、样式、utils**。admin-web 与 desktop-client 两边都只消费共享包。Renderer 是一个极简 Vite 项目,不是克隆+删页面。**必须抽出**的组件:
- `MarkdownPreview.vue` + `markdown-it` 渲染器(让 Web 发布前预览和 Desktop manual-review 用同一套渲染,避免"Web 看到的"和"桌面端审核看到的"不一致)。
- `AccountCard.vue` / `TagMultiSelect.vue` / `HealthBadge.vue`(账号管理 + 发布 Modal 共用)。
- `SseSubscription.vue` composable(封装 `SseClient` 给 Vue 用)。
- i18n 文案、主题变量、toast/dialog 系统。
- 老适配器里的**选择器字符串**和**业务流程描述**("打开编辑器 → 填标题 → 粘正文 → 上传封面 → 点发布"这种语义)。
**不可复用、必须重写或新建**
- 所有适配器代码——老代码硬编码 `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
}
tags?: string[] // 用户可自定义分组标签(如"品牌号"/"矩阵号"/"测试号"),Modal 按 tag 批量勾选
syncVersion: number // 单调递增,每次写入 +1,用于 client-server 一致性 CAS
deletedAt?: Date // tombstone,软删除,resync 时用于判断 "server 已删"
}
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 |
| **元宝**(腾讯)| Yuanbao · 入口 `yuanbao.tencent.com`**不是** hunyuan.tencent.com,后者是开发者/模型页不是对话入口)· 登录方式:微信扫码 / QQ / 邮箱(微信涉及 `open.weixin.qq.com` iframe + 本地 loopback 检查)| 接入时按 §5.2 spike 矩阵复测;默认主动保活 | 8 min + 30 min 探活 |
| Kimi | Moonshot · 入口 `www.kimi.com` · 登录方式:微信扫码 / 手机号快捷登录。实测请求是 `POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat`gRPC-Web binary-framed**非 EventSource**parser 需先 framing 解包,见 §5.2 | 接入时按 §5.2 spike 矩阵复测;默认主动保活 | 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 抓取技术路径(**先 spike 后定案**)
**v2.2 review 发现 `Fetch.takeResponseBodyAsStream` 与 `Fetch.continueResponse` 在 CDP 语义上互斥**:前者独占响应 body,后者让页面继续消费。因此**不能**写成"连续调用链"。v2.3 改为"分流 + 实测矩阵 + spike gate"。
**总方针**:不同平台用不同 domain,**不劫持能不劫持**(劫持会阻塞页面继续跑),只在必须看响应 body 时才用 Fetch 劫持。
**按传输类型分流**(每条路线的可用性需在 Plan A 先做 **spike 矩阵**确认):
| 页面使用的机制 | 推荐 CDP 路径 | 约束 |
|---|---|---|
| `new EventSource(url)` | `Network.enable``Network.eventSourceMessageReceived` 监听每条 message | 仅监听、不劫持。最干净。 |
| `fetch(url)` + `text/event-stream` + streaming body | `Network.enable``Network.streamResourceContent(requestId)` + `Network.dataReceived` 事件流式拼接 | **Chromium ≥ 120**Electron 41+ 满足)。仅监听、不阻断页面。|
| `fetch(url)` + chunked transfer + 自定义协议 | 同上 `streamResourceContent + dataReceived` | 同上 |
| **gRPC-Web / binary-framed fetch-stream**Kimi `/apiv2/kimi.gateway.chat.v1.ChatService/Chat`| 同上 `streamResourceContent + dataReceived`,拿到 raw bytes 后 **provider parser 先做 gRPC-Web framing 解包**5-byte 前缀:1B compressed flag + 4B message length)再解 payload | 引入 `adapters/_shared/framer.ts` 公共工具 |
| 必须**劫持 / 替换** 响应(一般不需要,只在改写响应或注入时)| `Fetch.enable({patterns})``Fetch.requestPaused``Fetch.fulfillRequest`**不再调 continueResponse**);若需读 body 再处理,用 `Fetch.getResponseBody`**等响应结束**)后统一返回 | `takeResponseBodyAsStream` 后页面**不能继续正常消费该响应**,故仅用于"必须替换响应"场景,监控场景不选 |
**Spike 矩阵(Plan A 必须先跑)**
```
provider × transport × Electron 版本 × domain 可用性 × 实际效果
豆包 千问 DeepSeek 元宝/混元 Kimi
EventSource ? ? ? ? ?
streamResource ? ? ? ? ?
dataReceived ? ? ? ? ?
```
每个 cell 在开工前用 `electron/chrome://devtools` 实跑一次登录后的真实聊天请求,记录实际的 request 种类与可抓到的 body 行为。**没有 spike 结果就不能定任何 adapter 的实现细节**。
**Chromium/CDP 兼容矩阵文件**`docs/superpowers/specs/electron-chromium-cdp-matrix.md`(Plan A 阶段新建),记录每次升 Electron 大版本的 domain 可用性回归结果。截至 2026-04-18 Electron stable 41.2.0Chromium 146),上述三类 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 PublishState =
| 'filling' // adapter 正在往编辑器填内容
| 'submitting' // 已经点了发布按钮,等平台回应
| 'done' // 平台明确返回成功
| 'failed' // 明确失败(可重试)
| 'aborted' // 用户或系统取消
| 'unknown' // 崩溃/超时/无法判断(人工对账,见 §7.5)
```
**状态机与租约的绑定**(见 §6 协议):
- SaaS 中的文章审核在创建 publish job 之前完成;desktop client **不再承接二次人工审核**
- 进入 `filling / submitting` 时,适配器必须每 60 s 调 `lease-manager.extend(lease_token)` 续租(lease TTL 默认 10 min)。
- 用户取消或 signal abort → `aborted`
- adapter 抛异常 → `failed`,带错误码。
- 进程崩溃 → 启动时按 §7.5 分派为 `unknown`(仅限 publish 任务的 `submitting` 阶段)。
- 需要再次发送的文章,不走 parked/resume,而是在桌面端“发布管理”里基于原 publish task 重新创建一次新的 publish job。
文件上传:`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` 文件。**加载与隔离要求**
- Viewer Vite 构建用 **hash 路由**`createWebHashHistory`),避免 `file://` 协议下 history 路由 404。
- Viewer 跑在 **独立的 sandboxed `<iframe>`**CSP `sandbox="allow-scripts"`,不开 `allow-same-origin`),挂在主 renderer 的"开发者"菜单下;trace 数据通过 `postMessage` 从主进程 → 主 renderer → iframe 单向传递。
- 防御目标:trace 里如果夹带了恶意 HTML/script(比如平台响应里注入的脚本),也无法从 iframe 逃到主 renderer 或主进程。
- 脱敏: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.1.1 监控域存量改造清单(不是 rename 能完成的)
v2.2 review 发现监控子系统存量耦合比插件本身深。Plan A/B 前必须 spike 这张表:
| 层次 | 文件 / 表 | 现状 | 改造动作 |
|---|---|---|---|
| 迁移 | `server/migrations/20260410103000_create_monitoring_tables.up.sql` | `tenant_monitoring_quotas``collection_mode='plugin'` + `primary_installation_id` | 改 `collection_mode='desktop'` + `primary_client_id`(引用 `desktop_clients`);加 `workspace_id` 列 |
| Repo | `server/internal/tenant/repository/queries/monitoring*.sql` | 多处 JOIN `plugin_installations` | 全量改 JOIN `desktop_clients` + 加 `workspace_id` 条件 |
| Service | `server/internal/tenant/app/monitoring_service.go` / `monitoring_callback_service.go` | 直接查 `plugin_installations`、按 tenant 选 primary installation | 改查 `desktop_clients``primary` 概念在 workspace 粒度(Plan 0 完成后)|
| Transport | `server/internal/tenant/transport/monitoring_handler.go` | `/api/plugin/monitoring/*` callback | 对应 `/api/desktop/monitoring/*`(或合并进 `/api/desktop/tasks/*` result 回写,具体在 Plan A spike 决定)|
| Projection | `monitoring_query_results` / `monitoring_callback_logs` | 按 installation_id 索引 | 按 `(workspace_id, client_id)` 重索引;旧数据因开发版本无需保留,直接 drop |
| Dashboard | admin-web 监控结果页 | 按 tenant 汇总 | 改 workspace 汇总 |
| Seed | `server/cmd/dev-seed/main.go` 等 | 生成 plugin_installation 示例 | 生成 desktop_client 示例 |
| 定时清理 | (目前没有 tombstone / unknown / trace 清理)| — | 新建:unknown 任务对账提醒与归档策略、`desktop_task_traces` 超期清理、tombstone > 30 天的 account 清理 |
**行动**:Plan A 启动前用 0.5 周做一次 impact spike,核对以上每一行都有具体 owner、估时、依赖;**没有确认就不开工**。监控域存量改造是 Plan A 工期的隐藏成本。
### 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 target_client_id is online: push {type: 'task_available', task_id} via SSE to *that* client only
client POST /api/desktop/tasks/lease → server atomically verifies (target_client_id == requester) + claims task
server returns {task, attempt_id, lease_token, lease_expires_at}
```
要点:
- **任务是定向的**。每条 `desktop_tasks` 都有 `target_client_id + target_account_id`(§6.6);SSE 只推给 `target_client_id`,其他 client 看不到、lease 不到。
- **SSE 只推 `task_available` 信号**,携带 `task_id` 而非任务正文,避免跨实例推送导致 payload 在错误客户端落地。
- **任务归属由 `POST /tasks/lease` 原子决定**DB `UPDATE ... WHERE status='queued' AND target_client_id = :requester_client_id RETURNING ...`,或 Postgres advisory lock)。即便被错误广播,非目标 client 的 lease 会被 `target_client_id` 检查拒绝。
- **60 s pull** 作为 SSE 失联兜底;和 SSE 都打同一个 lease endpoint,天然幂等。
- **presence registry**:每个 server 实例维护本地 `client_id → SSE connection`,并把 `(client_id, instance_id)` 写到 RedisTTL 30 sSSE 心跳续期)。Scheduler 按 `target_client_id` 查 presence 决定是否广播或挂队。
- **offline→online 主动补领**server 检测到某 `client_id` 从 offline 转 onlineRedis TTL 从无到有),扫描 `desktop_tasks WHERE target_client_id = $1 AND status = 'queued'`,对命中的每条重推 `task_available`client 侧也会在 heartbeat 后进入 `lease(kind?, limit=20)` 循环到空,两头夹逼避免任务"永远待领"。
### 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
GET /api/desktop/publish-tasks # 当前 client 的发布队列 + 历史发送结果
POST /api/desktop/publish-tasks/{id}/retry # 基于历史 publish task 重新创建一次 publish job
POST /api/desktop/tasks/lease # {task_id?} 原子领取;kind=publish|monitor 过滤
POST /api/desktop/tasks/{id}/extend # body: {lease_token},续租(短时占用用)
POST /api/desktop/tasks/{id}/cancel # body: {lease_token}(或无 lease 时带 client_token 由 target_client 主动取消)
POST /api/desktop/tasks/{id}/result # body: {lease_token, status, payload}
POST /api/desktop/tasks/{id}/trace # 用户主动上传诊断包
# Web 端对账(UNKNOWN 任务 / 账号删除意图,见 §7.5 和 §6.8.6)
POST /api/tenant/tasks/{id}/reconcile # body: {status: 'succeeded'|'failed'|'aborted'|'retry', external_url?, reason?} · 仅 Web 用户可调、需 WorkspaceScope
# retry 语义:原 task status → queued + attempts + 1(不复制、不新建 job);与 §15.1 状态图一致
POST /api/tenant/tasks/{id}/cancel # body: {reason?} · 对 queued 任务主动取消 → status=abortedWorkspaceScope + 审计
POST /api/tenant/accounts/{id}/request-delete # body: {} 或 ?undo=true · 写 delete_requested_at
# 账号元数据(不传 cookie
GET /api/desktop/accounts?client=self # resync 用:返回 {id, platform, platform_uid, display_name, health, client_id, sync_version, deleted_at, delete_requested_at, tags}
POST /api/desktop/accounts # upsert:以 (workspace_id, platform, platform_uid) 为键;body 带 if_sync_version 做 CAS
PATCH /api/desktop/accounts/{id} # 改 displayName / health / quota / tagsbody 带 if_sync_version 做 CAS
DELETE /api/desktop/accounts/{id}?if_sync_version=<n> # 软删除写 tombstone,非 hard delete
# Patch 通道
GET /api/desktop/patches/manifest # 返回当前适用的 manifest 列表 + revoke 指令
# 管理 UIWeb / Electron UI 共用)
GET /api/tenant/monitoring-plans
POST /api/tenant/publish-jobs
# ...
```
**幂等契约**:所有持有活动 lease 的写操作(`extend` / `cancel` / `result` / `trace`**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。`POST /tasks/lease` 本身是 lease 发放者,不需要 `lease_token`。客户端收到 409 = 租约已失效 / 已被抢占 / 任务已取消,**停止动作 + 触发 resync + 提示人工对账**。
**中间件与 workspace 一等化要求****先决条件**,不是实施细节):
v2.2 review 发现现有 `server/internal/shared/auth/claims.go` 只有 `user_id/tenant_id/role``server/internal/shared/middleware/tenant_scope.go` 只注入 `tenant_id``server/internal/tenant/app/cache_support.go` workspace cache key 按 tenantID 生成,仓库**没有 workspaces 表迁移**,监控表 `tenant_monitoring_quotas` 按 tenant 存 `primary_installation_id`。**单加一个 `WorkspaceScope` middleware 解决不了底座问题**。
正确顺序:**Plan 0(§12)必须先完成 workspace 底座一等化**——
1. `workspaces` 表 + `workspace_memberships` 表 DB 迁移。
2. `Actor/Claims` 结构扩 `workspace_id``client_token` 也绑定 `workspace_id`(稳定维度)。
3. `TenantScope` 之上新加 `WorkspaceScope` middleware,派生 topic/过滤条件从**服务端 Actor 派生**,不接受 query 参数自由选择。
4. 所有 repo 层 query 注入 `workspace_id = :actor_workspace_id`
5. `cache_support.go` 的所有 workspace-sensitive key 加 `workspace_id`
6. 监控域 `tenant_monitoring_quotas` / `monitoring_service.go` / `monitoring_callback_service.go` 迁到 workspace 粒度(见 §6.1 子节"监控域存量改造清单")。
只有 Plan 0 完成后,`/api/desktop/*``/api/tenant/*` 的 workspace 隔离承诺才能真正落地。
### 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,
tags JSONB, -- 用户自定义标签数组,Modal 分组筛选用
-- 一致性协议(见 §6.8.6
sync_version BIGINT NOT NULL DEFAULT 1, -- 单调递增,每次写入 +1
deleted_at TIMESTAMPTZ, -- tombstone,软删除;非 NULL 时 Modal 和 /accounts 默认过滤掉
delete_requested_at TIMESTAMPTZ, -- Web 端写"请求删除"意图;由 client resync 时坐实 DELETE(见 §6.8.6
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL,
-- 硬约束 8 的 DB 落地;同一物理账号只能被一个 workspace 活跃持有
UNIQUE (platform, platform_uid) WHERE deleted_at IS NULL
);
CREATE INDEX idx_platform_accounts_workspace ON platform_accounts (workspace_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_platform_accounts_client ON platform_accounts (client_id) WHERE deleted_at IS NULL;
-- 任务 + 租约 + 定向路由
CREATE TABLE desktop_tasks (
id UUID PRIMARY KEY,
-- 定向路由(C1:一等列)
job_id UUID NOT NULL, -- 同一 publish-job 展开的 N 条子任务共享;monitor 任务可 = id
target_account_id UUID NOT NULL REFERENCES platform_accounts(id),
target_client_id UUID NOT NULL REFERENCES desktop_clients(id),
platform TEXT NOT NULL, -- 冗余列,便于按平台索引/路由
-- 业务
kind TEXT NOT NULL, -- 'publish' | 'monitor'
payload JSONB NOT NULL,
status TEXT NOT NULL, -- queued | in_progress | succeeded | failed | unknown | aborted
dedup_key TEXT,
-- 租约
active_attempt_id UUID, -- null 表示无租约持有者
lease_token_hash BYTEA,
lease_expires_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
-- 结果
result JSONB,
error JSONB,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_tasks_job ON desktop_tasks (job_id);
CREATE INDEX idx_tasks_target_client_status ON desktop_tasks (target_client_id, status) WHERE status IN ('queued', 'in_progress');
CREATE INDEX idx_tasks_platform_status ON desktop_tasks (platform, status);
-- 发布 Job 聚合
CREATE TABLE desktop_publish_jobs (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
workspace_id UUID NOT NULL,
created_by_user_id UUID NOT NULL,
title TEXT NOT NULL,
content_ref JSONB NOT NULL, -- 多态引用(§6.8.2):{type, id}
scheduled_at TIMESTAMPTZ,
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 执行(publish: filling→submittingmonitor: querying),每 60s POST /extend
POST /result(lease_token, status=succeeded|failed) → server 校验 lease_token 后存结果
需要再次发送的文章 → desktop client “发布管理”调用 `POST /api/desktop/publish-tasks/{id}/retry`
→ server 基于原 publish task 重新创建新的 publish job + queued task
Web UI 可视化
```
**超时处理**`lease_expires_at` 到期后按 §7.5 的任务恢复策略处理;若老 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 下所有发布账号(**workspace scope 由 WorkspaceScope middleware 从 Actor 派生,不接受 query/body 选择**——见 §6.4 中间件要求)。响应按 `(platform, account)` 两层结构组织,每条带:
- `online`(由 server 根据该账号所属 `client_id``last_seen_at < 5 min` 判定)
- `health``live / expired / captcha / risk`
- `lastPublishAt`(上次成功发布时间)
2. Modal UI
- 平台折叠块,标题显示"选中 X / 共 Y"
- 每账号一行:`checkbox · displayName · tags chips · health badge · online badge · 最近发布时间`
- **按 tag 批量勾选**:顶部 tag 多选器(`tags` 来自 `platform_accounts.tags`,用户在"账号管理"里自定义,如"品牌号"/"矩阵号"/"测试号"),勾选 tag 后批量勾选所有带该 tag 的账号
- 搜索框(按 displayName / platform_uid 匹配)
- 全选 / 反选(按平台、按 tag、全局)
- 时间(立刻 / 定时)、素材策略(封面默认 / 指定)
3. 提交:`POST /api/tenant/publish-jobs`body 示例(**`content_ref` 多态**以适配现有 admin-web 的 KOL 模板/品牌素材/图文/视频等多种素材来源)。SaaS 侧的人审在此之前已完成,因此 publish job 只描述执行信息,不再传 `manual/auto mode`
```json
{
"title": "2026 Q2 新品发布",
"content_ref": {
"type": "article_draft",
"id": "draft_1234"
},
"accounts": [
{"account_id": "acc-a1"},
{"account_id": "acc-a2"},
{"account_id": "acc-b5"}
],
"scheduled_at": null
}
```
`content_ref.type` 支持 `article_draft` / `template_gen`KOL 模板生成)/ `video` / `brand_asset` 等,后续按需扩枚举;server 根据 type 在解析阶段拉对应实体并附到每条 desktop_task 的 payload 里。
**Phase 1 workspace 边界规则**(窄 workspace 化策略下的硬约束):上述 tenant-native 实体(`article_draft` / `template_gen` / `video` / `brand_asset`**只允许 actor 的 primary workspace 使用**。server 在 `POST /api/tenant/publish-jobs` 的校验阶段查 content_ref 关联实体的创建者 `primary_workspace_id`**必须等于** `actor.workspace_id`(由 WorkspaceScope 派生),否则返 **422 Unprocessable Entity** + `{code: 'content_ref_not_in_primary_workspace'}`。这样既保证 Phase 1 用户体验(仅单 workspace 用户无感知)、又在 Phase 2 接入多 workspace 时有明确的升级路径(届时规则放宽为"同 tenant 即可"或加独立授权模型)。该规则必须**落到 `POST /publish-jobs` 校验测试**Plan 0 退出 bar 的 cross-workspace 防穿透用例之一)。
server 展开成 3 条 `desktop_tasks(kind=publish)`,共享同一 `job_id`;每条绑定一个 `target_account_id` 与其所属 `target_client_id`(一等列见 §6.6)。
4. Modal 切到"进度面板",订阅面向 Web 的 SSE `GET /api/tenant/events?topic=job:<job_id>`;接收每子任务状态变化(`queued → in_progress → succeeded/failed/unknown/aborted`)。
5. 若目标账号所属桌面端离线,子任务保持 `queued`;对应 desktop client 上线后自动领取。运营也可以在 desktop client 的“发布管理”里查看当前待发布队列与历史发送结果。
6. 全部终态后 Modal 汇总:每账号结果 URL / 错误码。若某条需要补发,统一在 desktop client “发布管理”中对原 publish task 点“再次发送”;服务端会保留原记录,并基于原 task 新建下一次 publish job。
#### 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)` |
统计卡片(按信息密度排序,突出"需要马上关注"的内容):
1. **🔴 待关注账号**(醒目卡):点击筛选出 `health != 'live' OR online = false` 的账号,支持一键进入账号管理页处理。
2. **按平台汇总**:每个平台一个迷你卡(账号数 / 健康数 / 近 7d 发布成功率),点击钻取到该平台的账号列表。
3. workspace 在线桌面端数。
4. 近 7 天发布成功率(按账号,低于阈值的账号红色高亮)。
5. 近 7 天监控完成率。
6. 过去 24h 异常账号数 / 异常平台数。
**监控计划创建页**的 LLM 多选 UI 改 Grid 布局或多选 Tag 模式(不硬编码 3 列),为将来扩到 10+ 家 LLM 留余地。每家 LLM 显示已连接账号数,未连接时灰化提示"需先在桌面端登录 Kimi 账号"。
#### 6.8.4 admin-web 新增 / 扩充 API + 与现有路由的迁移关系
**新增路由**
```
GET /api/tenant/accounts # 带 online + health + lastPublishAt + tags 聚合
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
GET /api/tenant/monitoring-results # 支持 filter / range / 按 llm / 按账号
GET /api/tenant/events # SSEtopic 参数仅作提示,实际 topic 服务端派生
```
**与现有路由的迁移表**(核对 `server/internal/tenant/transport/router.go`):
| 现有路由 | 处置 | 新路由 / 迁移策略 |
|---|---|---|
| `GET /api/tenant/media/platform-accounts` | **删除** | `GET /api/tenant/accounts` 替代。旧响应里缺 `platform_uid`;因是开发版本无生产数据,DB schema 直接 drop 旧表 create 新表(§6.1)。 |
| `DELETE /api/tenant/media/platform-accounts/:id` | **删除** | `DELETE /api/desktop/accounts/{id}?if_sync_version=...` 由桌面端发起;Web 端想删需先弹"先在桌面端解绑"引导,或通过 `POST /api/tenant/accounts/{id}/request-delete` 写 tombstone,下次桌面端 resync 时同步(见 §6.8.6)。 |
| `POST /api/tenant/articles/:id/publish-batches` | **重命名 + 语义扩展** | `POST /api/tenant/publish-jobs`body 里 `content_ref: {type: "article_draft", id: "<article_id>"}`。旧接口一次只绑一篇文章,新接口支持多态 `content_ref`。 |
| `GET /api/tenant/articles/:id/publish-records` | **重命名 + 视角变换** | `GET /api/tenant/publish-jobs/{job_id}` 的子任务列表。若要按文章查,加 filter:`GET /api/tenant/publish-jobs?content_ref_id=<article_id>`。 |
| `POST /api/tenant/media/plugin-installations/register` 等 plugin 路由 | **删除** | 全部被 `/api/desktop/*` 替代(§10.2)。|
**SSE 通道交付契约**`/api/tenant/events` 与 `/api/desktop/events` 对等):
| 能力 | `/api/desktop/events` | `/api/tenant/events` |
|---|---|---|
| 多实例 fanout | ✅ RabbitMQ + 本地 SSE hub + Redis presence | ✅ 同机制,topic 路由不同 |
| 订阅建立时首帧 | — | **发送 `snapshot` 事件**(当前 job/account 快照)再追增量 |
| 事件 `event_id` 单调递增 | ✅ | ✅ |
| `Last-Event-ID` header 重连 replay | ✅ | ✅(至少 5 min 窗口) |
| 客户端去重(按 event_id| ✅ | ✅ |
| 鉴权 | `client_token` + TenantScope | `User JWT` + **新增 `WorkspaceScope`**(见 §6.4 中间件要求)|
| Topic 过滤 | `client_id` 派生 | **服务端按 `actor + workspace_id` 派生**query param `topic` 仅提示、必须经服务端校验归属后才订阅 |
**关键**:两通道**在同一个 RabbitMQ fanout 之上分主题分流**——一条 task 状态变化同时广播给 Web 端(通过 `topic=job:<id>` 派生)和 Desktop 端(通过 `topic=client:<id>` 派生),订阅者拿到的是各自视角的事件流。
#### 6.8.5 离线降级 UX 与上线补领
**离线态展示**
- **某桌面端离线**`last_seen_at > 5 min`):
- Modal 里所属账号标"离线(上次 X 分钟前)· 待执行队列 N",仍可勾选,顶部提示"任务将在该端上线后自动执行"。
- 账号总览 / 监控结果用灰色 badge。
- 若同 workspace 有其他在线客户端但它没持有该账号 → Modal 显式提示"该账号绑定的 `<device_name>` 离线,其他客户端无法代跑(Cookie 本地隔离)"。
- **workspace 所有桌面端都离线**
- Modal 顶部 banner"当前 workspace 无在线桌面端。任务会排队(队列 N 条,预计等待 > M min)。[下载桌面端]"
- 监控计划若连续 N 次未执行,站内通知 + 邮件给 workspace owner。
- **容量/ETA 反馈**`GET /api/tenant/publish-jobs/{id}` 返回 `queue_ahead`(该任务前面还有几条排队)和 `eta_seconds`(基于近期吞吐估算);Modal 进度面板显示为"前面还有 3 条任务 · 预计 2 min 后开始"。
**上线补领机制**(**双向夹逼**,避免任务永远待领):
- **Server 侧**`desktop-clients` 的 Redis presence TTL 从无到有时(offline→online 转换),scheduler 扫描 `desktop_tasks WHERE target_client_id = $1 AND status = 'queued'`,对命中的每条重发 `task_available` 到该 client 的 SSE。
- **Client 侧**`POST /api/desktop/clients/heartbeat` + §6.8.6 resync 完成后,**立即进入 `lease(limit=20)` 循环**直到返回空;之后才回到 SSE-driven 被动模式。
- **event 刷新**admin-web 的 `/api/tenant/events` 通道同时收到 `client_online` 事件,前端自动刷新账号 badge 与 job 状态,无需用户 reload。
#### 6.8.6 Account 数据一致性(CAS + Tombstone 协议)
**关键原则**(取代 v2.1 中模糊的"client = SoT / server = 投影"措辞,因其在并发场景下语义不一致):
1. **Account 的生命周期由 server 的 `sync_version` 单调序列与 `deleted_at` tombstone 共同定义**client 持有本地 partition 与 cookie。server 仍不持有任何 cookie 原文。
2. **所有写操作都是 CASCompare-And-Swap**client 请求必须带 `if_sync_version=<n>`server 仅当 `current_version == n` 时接受写入并自增 version;否则返 409 Conflictclient 据此触发 resync。
3. **删除永远是 tombstonesoft delete**server 不 hard delete,写 `deleted_at = now() + sync_version += 1`hard delete 由周期任务在 tombstone 保留 ≥ 30 天后清理。
**写路径**
| 操作 | 发起方 | 端点 | 语义 |
|---|---|---|---|
| 登录新账号 | client | `POST /api/desktop/accounts` body 带 `if_sync_version=null` | 首次创建,失败(已存在且未 tombstone)返 409 |
| 健康 / 昵称 / tags 变化 | client | `PATCH /api/desktop/accounts/{id}?if_sync_version=<n>` | CAS 更新 |
| 客户端主动解绑 | client | `DELETE /api/desktop/accounts/{id}?if_sync_version=<n>` | 写 tombstone + sync_version 自增 + 级联未完成 task → `aborted` |
| Web 端请求删除 | admin-web | `POST /api/tenant/accounts/{id}/request-delete` | **不直接删**,只在行上标 `delete_requested_at`;下次 client resync 看到后本地清 partition + 走上面的 DELETE 把删除坐实 |
**resync 流程**client 启动时):
```
1. client 本地:SELECT id, sync_version FROM local_accounts
2. client → GET /api/desktop/accounts?client=self
→ 返回 [{id, platform, platform_uid, display_name, health, tags,
client_id, sync_version, deleted_at, delete_requested_at}...]
3. 三方合并:
- 本地无 + server 无 deleted_at → 本地插入
- 本地有 + server 无记录(极少见,异常日志)→ 本地发 POST 同步回去
- 本地有 + server.deleted_at != null → 本地清 partition,删本地记录
- 本地有 + server.delete_requested_at != null → 本地清 partition + POST DELETE 坐实
- 双方 sync_version 不一致 → 以 server 为准覆盖本地元数据(cookie 不动)
- (platform, platform_uid) 冲突(同一 UID 出现在两个 workspace)→ §6.6 唯一索引
直接拒绝新 workspace 的 upsertclient 收 409UI 提示"该账号已在其他 workspace"
4. 合并结束,client 切入业务。
```
**并发案例(误操作撤销)**
| 时刻 | Client A(原持有)| Server | 说明 |
|---|---|---|---|
| T0 | `acc-x` 登录,local.sync_version=5 | sync_version=5 | 稳态 |
| T1 | — | delete_requested_at=now | Web 端用户误点"请求删除" |
| T2 | A PATCH tagsif=5| accepted → version=6 | CAS 通过 |
| T3 | A 收到 `delete_requested` 广播 → UI 询问用户 | — | — |
| T4 | 用户撤销 → `POST /api/tenant/accounts/{id}/request-delete?undo=true` | clear delete_requested_at, version=7 | — |
**语义**:没有一方能单独 hard-delete;任何分歧都可通过 server 的单调 version 序列还原正确状态。
admin-web 侧 "Modal 里账号消失" = client 已走完 DELETE 路径且 tombstone 落地;秒级一致性延迟可接受。
#### 6.8.7 深链接 `georankly://`
| 深链 | 场景 | 参数 | Fallback |
|---|---|---|---|
| `georankly://accounts` | "前往桌面端管理账号" | — | 未装 → 下载页 |
| `georankly://accounts/add?platform=wechat` | "一键添加某平台账号" | platform | 同上 |
| `georankly://tasks/<id>/review?nonce=<opaque_code>` | Manual 模式等审核 | **必须带一次性 nonce**server 签发的**短期 opaque code**(不是 JWT——避免把 claims 放进 URL)。TTL 60 s,后端存入 Redis 带 `jti`;绑定 `(task_id, target_client_id, actor_user_id)`。客户端打开深链时先回调 `POST /api/desktop/links/verify` 换取访问凭证:服务端校验 `target_client_id == 当前 client_token 的 client_id` 且 nonce 未被消费过,通过后**立即 delete jti 失效**。同机恶意进程即使抓到 URL 也无法重放(不同 client_id / 已消费)。 | 同上 |
**点击交互链路**(解决跨浏览器行为差异 + 协议未注册 fallback):
```
user clicks deep link in admin-web
1. admin-web 先 GET /api/tenant/clients 检查该 workspace 是否有在线桌面端
├─ 无 → 直接弹"请先安装并登录桌面客户端"引导页,不尝试唤起协议
└─ 有 → 继续
2. 弹出"正在打开桌面客户端..."中间态 modal(带 loading),同时:
- 触发 window.location = 'georankly://...'
- 设置 2s 超时检测(window.blur / visibilitychange 是否触发)
3. 2s 内检测到窗口失焦 → 深链成功唤起 → 关闭 modal
超时未唤起(协议未注册 / 浏览器阻拦 / 用户取消确认):
- modal 切到"未检测到桌面客户端启动"状态
- 提供两个按钮:[手动启动桌面客户端] [复制任务链接到桌面端地址栏]
- 仍显示下载链接兜底
```
这套做法解决了 Chrome/Edge/Safari/360/QQ 浏览器对自定义协议首次点击时行为不一致的问题(部分会弹"允许打开"、部分直接跳转、部分静默拦截)。
**Windows 协议注册**:安装 package 时通过 NSIS 脚本写入 `HKCU\Software\Classes\georankly` 注册表项;卸载时清理。macOS 通过 `Info.plist` 的 `CFBundleURLTypes` 声明;Linux 通过 `.desktop` 文件的 `MimeType=x-scheme-handler/georankly`。这些具体步骤在 Plan A 的打包任务里明确落实。
---
## 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` 里 `target_client_id = self AND status IN (in_progress)` 的任务:
**Monitor 任务**(幂等只读,可安全重跑):
- 任何阶段崩溃 → 丢弃本地状态 → 放回队列 → 下次被分派重跑。日志里记录 `previous_attempt_aborted_by_crash` 用于对账。
- 不标 UNKNOWN,不骚扰用户。
**Publish 任务**(不可逆副作用,保守处理):
- **未进入 `submitting` 阶段**(即还在 `filling`):重新 ack → 把 state 重置到 `filling` 从头跑,因为还没点过“发布”按钮。
- **已进入 `submitting`****放弃任务,标 `unknown`**。避免重复发布。
**UNKNOWN 任务的 UI 对账工作流**(不只是"提示一下"就完事):
UNKNOWN 任务在 admin-web "事件中心"和"发布历史"页有专门区块,黄色高亮 + 顶部固定。每条带一个 Action Dropdown
| 用户动作 | 后端行为 | 语义 |
|---|---|---|
| **"已确认发布成功"** | `POST /api/tenant/tasks/{id}/reconcile {status: 'succeeded', external_url}` | 把状态坐实为 `succeeded`,计入成功统计 |
| **"已确认发布失败"** | `POST /api/tenant/tasks/{id}/reconcile {status: 'failed', reason}` | 状态改 `failed`,允许触发 retry |
| **"忽略此任务"** | `POST /api/tenant/tasks/{id}/reconcile {status: 'aborted'}` | 状态改 `aborted`,从待办清单撤离 |
| **"重新运行"** | `POST /api/tenant/tasks/{id}/reconcile {status: 'retry'}` | **原 task status → `queued``attempts + 1``active_attempt_id = null`**;等 target_client 通过 §6.3 lease 流程重新领取。不复制、不新建 job,任务 ID 保持原值 → 历史可追溯 |
**关键**:UNKNOWN 不会自动消失——用户必须显式选一项。超过 7 天没处理的 UNKNOWN 任务每周触发一次站内通知给 workspace owner。"重新运行"的 retry 语义与 §15.1 Mermaid 状态图 `unknown → queued` 一致。
---
## 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` / `cancel` / `result`) / 多实例广播 / 幂等 / 熔断 / 断线重连 / queued 任务在 target_client 下线后重新补领 / desktop publish management 的列表与再次发送路径**。
### 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 通道最小闭环(Plan B 块 G1)已上线,应急修复跑通 ≥ 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 工作量重估(10 块)
粗估(单人工程周):
| 块 | 范围 | 估时 |
|---|---|---|
| **0. Workspace 底座一等化**v2.3 新增 · Plan 0| `workspaces` + `workspace_memberships` 表迁移;`Actor/Claims` 加 `workspace_id``WorkspaceScope` middleware;所有 repo query 注入 `workspace_id``cache_support.go` key 改 workspace 粒度;监控域存量改造清单(§6.1.1)完整迁 | **23 周(阻塞 Plan A** |
| **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(从零)****前置 § 5.2 spike 矩阵 0.5 周** | 4.55.5 周 |
| **C. 已有真实发布能力的平台迁移** | 约 6 家(头条 / 百家 / 搜狐 / 知乎 / 企鹅 / 简书)从 chrome.* 迁到 Electron 抽象 | 3 周 |
| **D. Detect-only 平台从零实现 publish** | 7 家(微信公众号 / 掘金 / 懂车帝 / 什么值得买 / 网易 / B 站 / ZOL| 57 周 |
| **E. 服务端协议 + DB + sqlc 重生 + Service 层重构** | `/api/desktop/*`、租约、发布管理(publish-tasks 列表/再次发送)、presence、patch manifest、reconcile | 2.5 周 |
| **F. 打包 5 arch + Mac 公证 + autoUpdater + updater smoke** | — | 1.5 周 |
| **G. Patch 通道** | G1**最小闭环**:服务端签名 manifest + 客户端 Ed25519 验签 + DSL 解释器 + revoke + rollback)前移到 Plan B1.5 周;G2(备用隔离执行通道 + 签名链全套 TUF 要素 + 密钥轮换)留在 Plan C1 周 | 2.5 周(拆 1.5 + 1|
| **H. packages/ui-shared 抽取 + desktop renderer** | 抽 `MarkdownPreview / AccountCard / TagMultiSelect / HealthBadge / SseSubscription composable` 等;**PublishArticleModal 重构为账号级多选(原 accounts[0] 假设要彻底改)**i18n、主题 | **2.53 周(v2.2 review 低估了 11.5 周)** |
| **I. 测试基建** | fake server + fake 平台 + replay + autoUpdater 核心逻辑单测 | 1.5 周 |
合计约 **2833 工程周**v2.3 新增 Plan 0 前置 + Modal 重构工期修正 + G 拆前后期)。这份 spec 因此**拆 4 个 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 包含约 28–33 周的工作量,拆 **4 个独立 plan** 执行。**Plan 0 是 Plan A 的硬前置**workspace 底座不一等化,后面所有协议/鉴权/缓存/监控承诺都无法真正落地)。
### Plan 0 · Workspace 底座一等化(23 周 · 阻塞 Plan A)
#### 范围策略:Phase 1 **窄 workspace 化**v2.4 明确决策)
v2.2 review 发现 workspace 不是后端一等安全边界。但**当前代码完整 tenant-native**articles / brands / kol-templates / tenant_monitoring_quotas / publish_batches / images / queues 全都挂 tenant 主键),若 Plan 0 要求"现有 admin-web 所有页面都在 workspace 粒度工作",实际工作量是 5–8 周而非 2–3 周。因此本 spec 明确采用**两阶段策略**:
- **Phase 1Plan 0,本轮)**:只在桌面客户端新引入的三条线上做 workspace 一等化——`desktop_clients` / `platform_accounts` / `desktop_tasks` / `desktop_publish_jobs` / `tenant_monitoring_quotas` 及其周边代码路径。所有**现有** `articles / brands / kol-templates / publish-batches / images` 等业务线**继续保持 `tenant == workspace`**(即 `workspace_id` 默认 = 用户的 primary workspace,不开放多 workspace 切换 UI)。
- **Phase 2(后续独立 plan,不在本 spec 范围)**:当产品真的需要同一租户多 workspace 场景时,再把现有业务线全量 workspace 化。那时会拉一轮独立的 migration + repo 重构。
这样 Plan 0 的 exit bar 能真的在 2–3 周内完成;也不对现有线做伤筋动骨的改造。**"窄 workspace 化"作为本版硬约束写死在此处**——任何声称"admin-web 全量 workspace 工作"的说法视为范围蠕变(scope creep),需重新评审。
#### Plan 0 内容
1. `workspaces` + `workspace_memberships` 表迁移(server-side, shared across both phases)。
2. `Actor/Claims` 结构扩 `workspace_id`User JWT 也加;登录时 client token issuance 绑定 `primary workspace_id`。
3. `TenantScope` 之上新加 `WorkspaceScope` middleware**只挂在**
- `/api/tenant/events`
- `/api/tenant/accounts*`
- `/api/tenant/publish-jobs*`
- `/api/tenant/clients`
- `/api/tenant/monitoring-*`
- `/api/tenant/tasks/{id}/reconcile`
(其他 `/api/tenant/*` 在 Phase 1 仍挂 TenantScope`workspace_id` 内部 default 为用户 primary。)
4. 以上列表路由的 repo query 显式注入 `workspace_id = :actor_workspace_id`。
5. `cache_support.go` 仅对 **desktop/account/monitoring 相关 cache key** 加 `workspace_id`;其余 cache key 暂保持 tenant 粒度。
6. **监控域存量改造**(§6.1.1 全量):
- `tenant_monitoring_quotas` 加 `workspace_id` 列 + 改 `primary_installation_id` → `primary_client_id`
- `monitoring_service.go` / `monitoring_callback_service.go` / `monitoring_handler.go` 改造
- 现有监控 projection / recovery / dashboard query 一并迁移
- seed data + 定时清理任务同步调整
7. admin-web 调用链:**仅**上述列表涉及的页面(账号总览、发布 Modal、监控结果、客户端总览、事件中心)在 API 层加 workspace context header;其他页面不动。
8. **CI guard**:加一个 lint rule 或自动化检查,拒绝新代码在上述三条线里写没有 `workspace_id` 过滤的 repo query。
#### 退出目标(revised
- **desktop/account/monitoring 三线** workspace 一等化落地并通过 cross-workspace 防穿透单元测试(至少 10 条针对 `GET/POST/DELETE /api/tenant/accounts|publish-jobs|monitoring-results|tasks/.../reconcile` 的越权用例)。
- `/api/tenant/events` 的 topic 服务端派生机制实现并覆盖测试。
- 现有 admin-web 功能**无回归**(现有页面继续按 tenant 粒度工作,不需新增 workspace 切换 UI)。
- §6.1.1 监控存量 8 行改造全部完成并 code review 通过。
### Plan A · 桌面端骨架穿透(5–6 周,Alpha 上限)
- 块 ADesktop runtime 基座 + crash-recovery + publish queue execution
- 块 E(服务端协议 + DB + `/tasks/{id}/reconcile` endpoint + `/api/desktop/publish-tasks` 列表/再次发送)
- 块 H(拆两阶段,见下)
- **H1 · 前期(Plan A 第 12 周)**`packages/ui-shared` 骨架 + **design tokens**CSS variables light/dark + Ant Design Theme Config+ `useAccountSelection` composable(账号级多选逻辑)+ `HealthBadge / AccountCard / TagMultiSelect / MarkdownPreview` 组件迁入。
- **H2 · 后期(Plan A 第 45 周)**`PublishArticleModal` 重构为账号级多选(基于 H1 的 composable),接入新 `/api/tenant/publish-jobs` 展开语义,进度面板 SSE 订阅。
- **§5.2 spike 矩阵**0.5 周,开 Plan A 前先做,见下文)
- **1 个** 监控适配器(Doubao)+ **1 个** 发布适配器(头条号,已有 publish 能力)
- 块 I(测试基建)
- 块 F **仅 Mac 公证**
**Spike 矩阵操作规格**v2.4 补齐):
- **Owner**runtime owner(负责 CDP / Fetch 基建)+ 对应 provider ownerDoubao 为主)联签。
- **产物**`docs/superpowers/specs/electron-chromium-cdp-matrix.md`——表格列 `provider × transport × Electron/Chromium 版本 × CDP domain 结论`,每格附抓包/DevTools 截图或日志片段。
- **三态结论**
- `supported`:该 transport 在目标 Electron 版本下 CDP 完整可用,直接用。
- `degraded`:部分工作(比如 `Network.streamResourceContent` 可用但偶发 chunk 丢失),记录 workaround 并定 known-issue issue。
- `blocked`:完全不工作(页面改用 WebTransport / WebSocket / WebRTC 等 CDP 未覆盖路径)。进入 fallback 决策树:① DOM-only 降级(只从渲染后文本抽取,保留但标"degraded quality")② 移出本版承诺范围(产品决策,工期不赔)③ 等待 Chromium 版本升级。
- **Gate**spike 未出具三态结论前,块 B 的新 provider 接入**不开工**spike 只对 Plan A 承诺的 Doubao 做完整验证即可解锁 Plan A,其余 4 家 LLM 在 Plan B 启动前各自跑一次。
**退出目标**:端到端跑通一条 Doubao 监控任务 + 一条头条发布任务;desktop client 提供“发布管理”视图(待发布队列 + 历史结果 + 再次发送);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务的 reconcile 工作流闭环。
### Plan B · 广度铺开(1214 周,Beta 上限)
- 块 BQwen + DeepSeek + 元宝 + Kimi 从零)
- 块 C(其余 5 家已有真实发布能力的平台迁移)
- 块 D7 家 detect-only 从零实现 publish
- **块 G1 · Patch 通道最小闭环**(服务端签名 manifest + 客户端 Ed25519 验签 + DSL 解释器 + revoke + rollback1.5 周)——前移到此,满足 §10.1 Beta 退出门槛"patch 通道应急修复跑通 ≥ 1 次"
- 块 F 完整:Win/Linux 全架构 + 签名(Win 签名到位)+ autoUpdater 完整
- 块 I 补齐:多适配器回归 + 冒烟 Playbook
**退出目标**13+5 适配器全量可用;autoUpdater smoke 每 release 全过;5 arch × 3 OS 全量打包签名;patch 通道应急修复在 Beta 期至少跑通一次。
### Plan C · 运维闭环与高级能力(4–5 周,GA 上限)
- **块 G2 · Patch 通道高级能力**:备用隔离执行通道(utilityProcess + isolated-vm)、根密钥轮换、TUF 风格完整 manifest1 周)
- Phase 2 Trace 基建(CDP 全事件流 + 2 fps 截屏 rolling buffer + Playwright Trace Viewer 兼容导出)
- Metrics dashboard + 报警规则(crash rate / SSE 保持时长 / adapter 日成功率 / patch 覆盖率)
- 回滚剧本与混沌演练
- 账号共享 / 桌面端主备的演进位置预留(命名软约定,不在本版 DDL 落名;见 §2 硬约束 9)
**退出目标**:patch 热更经过混沌演练;trace 能被外部 Playwright tooling 打开;运营指标全套接入报警;回滚剧本有实战演练记录。
---
## 13. 变更记录
- **v2.52026-04-18**v2.4 final-verification 后的契约收尾——Codex 精准命中 5 个契约口子全部 closeGemini 本轮已给 Ready-to-plan)。
- **Fix 1 · retry 语义**:§6.4 路由表 `reconcile status=retry` 注释改成"原 task status → queued + attempts+1,不复制、不新建 job";§7.5 UNKNOWN 工作流表里"重新创建任务"改为"重新运行",后端改成 `POST /reconcile status=retry`;§15.1 Mermaid 对应边加上 attempts+1 说明。三处一致。
- **Fix 2 · content_ref workspace 边界**:§6.8.2 增硬规则——tenant-native 实体(article_draft/template_gen/video/brand_asset)只允许 actor 的 primary workspace 引用,否则 422;该规则落到 Plan 0 退出 bar 的 cross-workspace 防穿透测试。
- **Fix 3 · WorkspaceScope 输入面**:删 §6.8.2 示例里的 `workspace_id=` query,明确 scope 由 Actor 派生,不接受 query/body 选择。
- **Fix 4 · contract drift**:§6.4 `GET /api/desktop/accounts?client=self` 返回字段补 `delete_requested_at`;§6.8.2 步骤 6 retry 改 JSON body。
- **Fix 5 · parked Web cancel 端点**:§6.4 新增 `POST /api/tenant/tasks/{id}/cancel`(对 parked 或 queued 任务主动取消 → `aborted`),WorkspaceScope + 审计;§15.1 Mermaid 加 `queued → aborted: Web cancel` 边,已有的 `waiting_user → aborted` 标注具体端点。
- **v2.42026-04-18)**:第四轮架构师视角交叉审查后的修订。**Codex verdict: Needs-rework**(剩余底座与一致性问题)、**Gemini verdict: Ready-with-caveats**。合计 1 Critical + 7 Warning。按"全修进 spec"方案逐条落地:
- **Plan 0 范围**Codex C1 Partial):§12 **窄 workspace 化**策略写死——Phase 1 只在 `desktop_clients / platform_accounts / desktop_tasks / desktop_publish_jobs / tenant_monitoring_quotas` 三条线上 workspace 一等化;articles/brands/kol-templates/publish_batches/images 仍保持 `tenant == workspace`WorkspaceScope middleware 仅挂在列表内的路由。列出 8 条 Plan 0 明确动作 + 3 类 exit bar。
- **Spec 一致性清扫**Codex W3):删 §6.4 幂等契约里 `pause/resume` 残留、改 §7.5 `lease_client_id → target_client_id`、§6.6 `platform_accounts` DDL 补 `delete_requested_at` 列、§6.4 路由表新增 `POST /api/tenant/tasks/{id}/reconcile` 与 `POST /api/tenant/accounts/{id}/request-delete`、§14 术语表"Waiting-user"定义改为释放 lease + 严格 CAS 恢复、§13 v2.2 changelog 加注"以 v2.3 及以上为准"。
- **Parked / Reconcile 协议闭环**Codex W2 Partial):§5.3 补"Parked 恢复协议"—— `POST /tasks/{id}/lease?from_parked` 的严格 CAS SQL 示意 + 5 类错误情景 + 重复请求幂等规则;新增"target_client 离线/换机/revoke/72h 超时"5 类恢复策略表。Parked **从不自动 rebind**写死为核心原则。自动 abort 阈值从 24h 调到 72h 避免长假误杀。
- **Reconcile 归属**`/reconcile` 从 `/api/desktop/*` 迁到 `/api/tenant/*`Web 对账归 Web,需 WorkspaceScope)。
- **Mermaid 图前移**Gemini W10):§15.1 Lease 状态机 + §15.2 Account CAS resync **内联**到 spec,在 Plan 0 前画完(不再等 Plan A);§15.3 剩三张图保留 Plan A 期补。
- **Spike 矩阵操作化**Codex W4):§12 补 Spike ownerruntime owner + provider owner 联签)、产物(`electron-chromium-cdp-matrix.md` + 抓包/截图)、**三态结论**supported / degraded / blocked)、blocked 时 fallback 决策树(DOM-only 降级 / 移出承诺范围 / 等 Chromium 升级)。
- **Modal 重构分阶段**Gemini C1):块 H 拆 H1Plan A 第 12 周:ui-shared 骨架 + design tokens + `useAccountSelection` composable+ H2(第 45 周:PublishArticleModal 基于 composable 重构)。
- **Design tokens 显式**Gemini W2):H1 产物明确包含 CSS variables (light/dark) + Ant Design Theme Config。
- **credential_holder DDL 承诺撤回**Codex W3 Partial):§2 硬约束 9 改成"命名软约定"——本版 DDL 继续 `workspace_id / client_id / target_client_id`;§11 相应软化。
- **工期微调**Plan A 5–6 周不变(H 拆两阶段但总量不变);总工期 28–33 周不变。
- **v2.32026-04-18**Claude + Codex + Gemini 第三轮(架构师视角)交叉审查后的修订。**Codex verdict: Needs-rework****Gemini verdict: Ready-with-caveats**——两家合计 2 Critical + 9 Warning + 3 Info。按"全修进 spec + 新增 Plan 0 前置改造"方案落地。
- **C1 · Workspace 升成后端一等安全边界**v2.2 的"新增 WorkspaceScope middleware"是局部补丁,实际需要 JWT claims / repo query / cache / 监控域全链条改造。新增 **Plan 0 · Workspace 底座一等化**(§1223 周,阻塞 Plan A),覆盖 `workspaces` + `workspace_memberships` 表迁移、`Actor/Claims` 加 `workspace_id`、所有 repo query 显式注入 workspace_id、`cache_support.go` key 改粒度、§6.1.1 监控域存量改造清单 8 行。§2 硬约束 8 重写 + 新增硬约束 9(演进缝:`credential_holder` / `account_home` 命名预留)。
- **C2 · §5.2 CDP 路线改 "先 spike 后定案"**v2.2 里 `Fetch.continueResponse` + `Fetch.takeResponseBodyAsStream` 连续调用语义互斥(CDP 文档明文:"Only available in response stage" + "the request can't be continued as is")。v2.3 改为:EventSource 走 `Network.eventSourceMessageReceived`fetch-stream 优先 `Network.streamResourceContent + Network.dataReceived`Chromium ≥ 120 已满足);仅"必须劫持/替换"才用 Fetch 且**不再连续调** continueResponse。Plan A 前置 **provider × transport 实测矩阵 spike**0.5 周),结果入 `electron-chromium-cdp-matrix.md`。
- **W1 · Plan 依赖顺序调整**Beta 退出门槛依赖 patch 通道,但 patch 通道在 Plan C——依赖矛盾。v2.3 把 **块 G 拆为 G1(最小闭环:签名 manifest + Ed25519 验签 + DSL 解释器 + revoke + rollback1.5 周)前移到 Plan B**,G2(备用隔离执行 + 根密钥轮换 + TUF 全要素,1 周)留在 Plan C。§10.1 Beta 门槛相应加备注。
- **W2 · `waiting_user` 从 lease 拆成 parked state**lease 适合短时占有,不适合 2 小时人审。v2.3 在 `readyToReview` 进入时 **释放 lease**`active_attempt_id=null`)、状态置 `waiting_user`、任务仍绑定 `target_client_id` 不被抢;审核通过时 client 走 `POST /tasks/{id}/lease?from_parked=true` 重新领取新 attemptparked 超 24h 自动 abort。§5.3 / §6.4 / §6.7 同步,删 `/pause` + `/resume`,改 `/park` + `/lease?from_parked`。
- **W3 · 演进缝预留** → §2 硬约束 9 明写 `credential_holder` / `account_home` 抽象;Plan C 含演进位置预留。
- **W4 · 监控域存量改造清单** → §6.1.1 新小节,列 8 行改造盘点(迁移 / Repo / Service / Transport / Projection / Dashboard / Seed / 定时清理)。
- **W5 · 深链 nonce 一次性消费 + client 绑定**:改为短期 opaque code(非 JWT),Redis 存 jti,绑 `(task_id, target_client_id, actor_user_id)``/links/verify` 成功后立即 delete 失效;同机重放被 client_id mismatch 拦下(§6.8.7)。
- **W6 · PublishArticleModal 重构**Gemini 实测 `apps/admin-web/src/components/PublishArticleModal.vue` 的 `accountCards` 只取 `accounts[0]`——要改成账号级多选需彻底重写。§10.3 块 H 工期 1.5 → 2.53 周。
- **W7 · `MarkdownPreview.vue` 抽 `packages/ui-shared`**Web 发布前预览与 Desktop manual-review 用同一渲染器,避免不一致(§3.3)。
- **W8 · `packages/http-client` 新增 `SseClient` 类**:标准化指数退避重连 + `Last-Event-ID` + 首帧 snapshot + 事件去重;两端共用(§3.3)。
- **W9 · Trace Viewer sandboxed iframe + hash 路由**`createWebHashHistory` 适配 `file://` 加载;viewer 跑在 CSP sandbox iframe 中,trace 数据通过 `postMessage` 传入,隔离恶意 DOM(§5.6)。
- **I1 · LLM 命名 & 产品入口修正****Hunyuan → Yuanbao 元宝**`yuanbao.tencent.com` 才是对话入口,不是 `hunyuan.tencent.com`);新增 **§14 适配器映射表附录**adapter_id / 公司 / 模型家族 / 产品名 / 官方域名 / 登录方式 / transport 观察)。
- **I2 · UNKNOWN 任务 UI 对账工作流**:§7.5 加 `POST /api/tenant/tasks/{id}/reconcile` 端点 + 四种用户动作(已成功 / 已失败 / 忽略 / 重新创建);超 7 天未处理触发站内通知。
- **I3 · 新增 §15「需要补的图」**:列 5 张 Mermaid 图(全链路时序 / lease 状态机 / Account CAS resync / 多实例部署 / 前端组件层级),Plan A 期补齐放 `diagrams/`。
- **工期重估**:§10.3 从 9 块扩到 10 块(加块 0);总工期 24–28 周 → **2833 周**Plan 0 新增 23 周 + 块 H 工期修正 +1 周 + G 拆前后期)。§12 从 3 plan 拆成 4 plan。
- **v2.22026-04-18**Claude + Codex + Gemini 对 v2.1 的二轮交叉审查后的修订。修复 4 Critical + 9 Warning。
- **C1 多账号路由语义落 schema**:§6.6 `desktop_tasks` 新增一等列 `job_id / target_account_id / target_client_id / platform` + 相关索引;新建 `desktop_publish_jobs` 聚合表;§6.3 SSE 只向 `target_client_id` 推送,lease 仅允许目标 client 原子领取;错 client 会被服务端拒绝。
- **C2 Account 一致性 CAS + Tombstone**:§4.1 Account 增 `syncVersion / deletedAt / tags`;§6.6 `platform_accounts` 增 `sync_version / deleted_at / tags` 列与条件唯一索引;§6.8.6 全文重写为 CAS(`if_sync_version`+ tombstonesoft delete)协议 + resync 三方合并规则 + 并发案例表;新增 `POST /api/tenant/accounts/{id}/request-delete` 由 Web 写删除意图、client 坐实的两段式删除。
- **C3 `/api/tenant/events` 鉴权链**:§6.4 明确要求新增 `WorkspaceScope` middleware(现有 `TenantScope` 不足);topic 必须服务端按 `actor + workspace_id` 派生,不接受 query param 自由选择;两通道对等契约表(多实例 fanout / 首帧 snapshot / `Last-Event-ID` replay / 事件 id 单调递增 / 客户端去重)放在 §6.8.4。
- **C4 与现有 `/api/tenant/media/*` 的迁移表**:§6.8.4 新增完整迁移映射表,列出 `media/platform-accounts` 删除、`articles/:id/publish-batches` → `publish-jobs` 重命名、`publish-records` → `publish-jobs/{id}` 子任务视图等。
- **W1** §6.8.4 SSE 契约对等(见 C3 合并)。
- **W2** §5.2 传输表加 **gRPC-Web / binary-framed fetch-stream** 分支(Kimi 实测路径 `/apiv2/kimi.gateway.chat.v1.ChatService/Chat`);§4.4 两家 LLM 行补登录方式(Kimi:微信扫码 / 手机号;混元:微信/QQ/邮箱 + loopback 检查);保活策略改为"接入时以实际 transport 为准"。
- **W3** §6.3 + §6.8.5 明确 offline→online 双向夹逼补领:server 在 presence 从无到有时主动扫描 queued 重推;client 在 heartbeat+resync 后立即 `lease(limit=20)` 循环到空。
- **W4** §6.4 补 `GET /api/desktop/accounts?client=self` 端点(resync 所需)。
- **W5** §4.1 Account 加 `tags` 字段;§6.8.2 Modal 支持按 tag 多选 + 搜索框 + 三级批量勾选。
- **W6** §6.8.5 增 ETA / 队列深度 / 容量反馈:`/publish-jobs/{id}` 返回 `queue_ahead` + `eta_seconds`Modal 显示"前面还有 N 条 · 预计 M min 后开始"。
- **W7** §6.8.7 深链加中间态 modal + 手动启动 / 复制链接 fallback`georankly://tasks/<id>/review` 带一次性 nonce60s TTL JWT),通过 `POST /api/desktop/links/verify` 换取凭证;补 Windows/mac/Linux 协议注册方式说明。
- **W8** §6.8.2 `content_ref` 改多态:`{type: 'article_draft'|'template_gen'|'video'|'brand_asset', id: ...}`,适配 KOL 模板 / 品牌素材等多种素材来源。
- **W9** §6.8.4 `/publish-jobs/{id}/retry` 改 JSON body `{"task_ids": [...]}`(不用 query string)。
- **Info**:§10.3 标题 `4 块` → `9 块`;§12 开头 2226 → 2428(与 §10.3 一致);§6.8.3 统计卡加"🔴 待关注账号"与"按平台汇总"视图;监控计划 UI 改 Grid/Tag 多选布局为未来扩充留余地。
- **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/cancelmanual 模式引入 `waiting_user` 状态(§5.3/§6.4/§6.6/§6.7)。*(注:v2.2 原设计含 pause/resume 并延长租约,v2.3 修订为 park + lease-from-parked,释放 lease 改 parked state;以 v2.3 及以上为准)*
- **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. 附录:LLM 适配器映射表(I1)
截至 2026-04-18 的对外产品入口与传输观察:
| adapter_id | 公司 | 模型家族 | 消费端产品名 | 官方域名 | 登录方式 | 观察到的 transport |
|---|---|---|---|---|---|---|
| `doubao` | 字节跳动 | Doubao / 云雀 | 豆包 | `www.doubao.com` | 微信扫码 / 手机号 / 抖音 | fetch-streamevent-stream)· 已有实测基础 |
| `qwen` | 阿里 | Qwen / 通义千问 | 通义 | `tongyi.aliyun.com` | 支付宝 / 钉钉 / 手机号 | fetch-stream,带 `bx-ua / x-xsrf-token` 等短期 token |
| `deepseek` | DeepSeek | DeepSeek | DeepSeek | `chat.deepseek.com` | 邮箱 / 手机号 / 微信 / Google | 待 spike(旧插件是 default placeholder|
| `yuanbao` | 腾讯 | Hunyuan 混元 | 元宝 | `yuanbao.tencent.com`**不是** hunyuan.tencent.com| 微信扫码 / QQ / 邮箱(微信涉及 loopback 检查)| 待 spike |
| `kimi` | Moonshot | Kimi | Kimi | `www.kimi.com` | 微信扫码 / 手机号快捷登录 | **gRPC-Web binary-framed**`POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat`),需 framer 解包 |
这些条目在 Plan A spike 矩阵后更新为稳定值并记入 `docs/superpowers/specs/electron-chromium-cdp-matrix.md`。
## 15. 附录:架构图
### 15.1 任务状态机(inline · v2.6 当前口径)
```mermaid
stateDiagram-v2
[*] --> queued: POST /publish-jobs
queued --> in_progress: lease acquired (target_client_id match)
queued --> aborted: POST /api/tenant/tasks/{id}/cancel (Web)
in_progress --> in_progress: extend (每 60s)
in_progress --> succeeded: POST /result status=succeeded
in_progress --> failed: POST /result status=failed
in_progress --> aborted: POST /api/desktop/tasks/{id}/cancel (client)
in_progress --> unknown: crash during submitting (publish only)
unknown --> succeeded: POST /api/tenant/tasks/{id}/reconcile status=succeeded
unknown --> failed: POST /reconcile status=failed
unknown --> aborted: POST /reconcile status=aborted
unknown --> queued: POST /reconcile status=retry (原 task 回 queued + attempts+1)
succeeded --> [*]
failed --> [*]
aborted --> [*]
note left of unknown
仅 publish 任务
仅 submitting 崩溃
monitor 任务直接重跑
end note
note right of succeeded
Desktop Publish Management
可对 succeeded/failed/unknown/aborted
手动再次发送
服务端会新建下一条 queued publish task
end note
```
### 15.2 Account CAS + Tombstone Resyncinline · v2.4 在 Plan 0 前画完)
```mermaid
sequenceDiagram
autonumber
participant A as Client A (original holder)
participant S as Server DB
participant W as admin-web (Web UI)
Note over A,S: T0 稳态:acc-x sync_version=5
A->>S: POST /accounts {..., if_sync_version: null} (首次登录)
S-->>A: 200 {sync_version: 5}
Note over W: T1 用户在 Web 误点"请求删除"
W->>S: POST /api/tenant/accounts/{id}/request-delete
S-->>W: 200 {delete_requested_at: now, sync_version: 6}
S->>A: SSE /api/tenant/events 推 delete_requested 事件
Note over A: T2 并发:A 正在改 tags
A->>S: PATCH /accounts/{id} {tags: [...], if_sync_version: 5}
S-->>A: 409 Conflict (current_version=6)
Note over A: T3 A 本地 resync
A->>S: GET /api/desktop/accounts?client=self
S-->>A: [{id, sync_version: 6, delete_requested_at: now, ...}]
A->>A: UI 询问用户:要不要真的删除?
alt 用户撤销
A->>S: POST /api/tenant/accounts/{id}/request-delete?undo=true
S-->>A: 200 {delete_requested_at: null, sync_version: 7}
Note over A,W: 继续正常使用
else 用户确认删除
A->>A: 清 partition / cookie
A->>S: DELETE /accounts/{id}?if_sync_version=6
S-->>A: 200 {deleted_at: now, sync_version: 7}
S->>S: cascade: task.status='aborted' for pending
S->>W: SSE 推 account.deleted
end
```
### 15.3 Plan A 期补齐的其他图
以下放在 `docs/superpowers/specs/diagrams/` 目录:
1. **全链路时序图**sequenceDiagram)——Web 点击发布 → Server 入库 → SSE 通知 → Desktop 领取 → 适配器执行 → 结果回传 → Web 更新。
2. **多实例部署架构图**——RabbitMQ fanout + 多个 server 实例 + Redis presence registry + 多个 desktop client。
3. **前端组件层级图**admin-web 与 desktop renderer 共享 `packages/ui-shared` 的依赖关系)。
## 16. 术语表
- **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` 验证。
- **发布管理(Publish Management**desktop client 里的发布运维页,展示当前 client 的发布队列与历史发送结果。支持查看 `queued / in_progress / succeeded / failed / unknown / aborted` 任务,并基于任一历史 publish task 重新创建一次新的发送任务。
- **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 通知。