# Electron 桌面客户端设计(全面替换 WXT 浏览器插件) **状态**:Draft v2.5 · 待实施(历经 5 轮 Claude + Codex + Gemini 交叉审查后修订) **作者**:@liangxu + Claude(codex/gemini 交叉审校) **日期**:2026-04-18 **范围**:新增 `apps/desktop-client/`、`/api/desktop/*` 服务端路由、相关 DB schema;删除 `apps/browser-extension/` 与 `/api/plugin/*` 路由及相关代码。 > **v2.5 主要修订**(基于 v2.4 final verification Codex 审查的 5 个契约口子收尾):`reconcile status=retry` 语义写死(原 task 回 queued)+ Phase 1 下 `content_ref` 引用 tenant-native 实体的 workspace 边界规则(只允许 primary workspace) + WorkspaceScope 输入面一致性(删 `workspace_id=` query 示例)+ 补 `delete_requested_at` 返回字段 + 新增 `POST /api/tenant/tasks/{id}/cancel` 端点 + §15.1 Mermaid 与正文一致性修正。完整历史(v1 → v2.5)详见 §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` 字段 + 唯一索引)。**前置条件**: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:` session,挂在离屏 `BaseWindow` 上。 - **生命周期**:**两档**。Hot-tier(最近 N 分钟有任务或保活的账号)常驻 WebContentsView;Cold-tier 账号只保留 session partition 在磁盘,WebContentsView lazy-create。阈值默认 30 min,可配置。避免 10 账号场景下 RAM 被固定占用。 - 仅在实测 CDP 抓流阻塞 UI 线程时,才把 debugger 交互迁到 `utilityProcess`,其他模块保持不动。 ### 3.2 模块分层 新增 `apps/desktop-client/`,作为 pnpm workspace 的新包: ``` apps/desktop-client/ main/ bootstrap.ts # app ready, single-instance-lock, tray, autoUpdater session-registry.ts # account-id → (WebContentsView|null) + session view-pool.ts # Hot/Cold 两档 WebContentsView 生命周期 vault.ts # safeStorage 包 cookie/token + SQLite(本机恢复缓存) vault-export.ts # 用户交互式导出(口令二次加密)用于手工迁机器 scheduler.ts # 本地 cron + 服务端 SSE/pull 合流 lease-manager.ts # 任务租约:ack→attempt_id+lease_token→extend/cancel/result rate-limiter.ts # 每平台/账号 token bucket,共享 business/keep-alive/probe 预算 circuit-breaker.ts # 平台级熔断 keep-alive.ts # 探活 / 保活 / resume warm-up 错峰 crash-recovery.ts # 启动时按 task.kind 分派恢复策略 patch-loader/ registry.ts # 已加载 patch 版本表 signer.ts # manifest + Ed25519 + hash + 回滚序号校验 parser-dsl.ts # 受限 DSL 解释器(默认通道) vm-host.ts # utilityProcess 沙箱启动 + 冻结 global(备用通道) tracing/ recorder.ts # 结构化日志 + 关键截屏(Alpha 范围) trace-writer.ts # 轻量 zip(Alpha 仅 JSON 日志 + 截屏) redactor.ts # Cookie / Authorization / password 字段自动脱敏 # Playwright 兼容导出作为 Phase 2 能力,见 §5.6 transport/ api-client.ts # 复用 packages/http-client,注入 client_token sse-client.ts # /api/desktop/events 长连接 + 60s pull 兜底 preload/ bridge.ts # contextBridge 窄 API renderer/ # 极简 Vite 项目,仅消费 packages/ui-shared main.ts routes.ts views/ # 桌面端专属视图(账号管理/任务中心/事件中心/诊断) ``` ### 3.3 关键复用(更务实的口径) **可复用**: - `packages/shared-types`:平台枚举、任务类型、DTO。需要**扩展**:加入租约、platform_uid、patch manifest 等新类型。 - `packages/http-client`:HTTP 基座直接用;**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-',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//`;同时主进程用 `safeStorage` 加密写本地 SQLite 作为**本机恢复缓存**(跨账号解绑、重装应用时 restore 用;**非迁机器备份**)。 7. 启动时强制 `safeStorage.isEncryptionAvailable()`;若在 Linux 上 `safeStorage.getSelectedStorageBackend()` 返回 `basic_text`,UI 显著告警、默认关闭加密快照(避免把平台 cookie 明文写盘)。 8. 账号进入"常驻池",hot-tier 分配 WebContentsView,cold-tier 仅保留 partition;按需激活 CDP。 ### 4.3 多账号并存 同一平台多账号完全隔离——partition 不同、cookies 不同、UA 不同、可通过 session 级 proxy 走不同 IP。代理来自 `Account.proxy` 字段,由 `_shared/proxy-helper.ts` 统一调用 `ses.setProxy`(§5.4)。调度器按账号维度限流(默认同一账号并发 1、同一平台并发 3,可配置)。 ### 4.4 Cookie/Session 保活策略 三个档位: | 档位 | 动作 | 频率 | 代价 | |---|---|---|---| | **探活** | 加载主页,检查 DOM 登录标志 | 30 min | 低 | | **保活** | 访问平台私有 API 触发 Set-Cookie/token 刷新 | 8 min(豆包/千问)| 中 | | **按需激活** | 任务到来前 pre-check | 任务驱动 | 低 | | 平台类型 | 账号示例 | 保活方式 | 节奏 | |---|---|---|---| | 媒体发布 | 微信/头条/B 站/知乎 等 13 家 | 只探活 | 30 min | | 豆包 | Doubao | 主动保活 | 8 min + 30 min 探活 | | 千问 | Qwen | 主动保活 | 8 min + 30 min 探活 | | DeepSeek | DeepSeek | 只探活 | 30 min | | **元宝**(腾讯)| 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 keepAlive(ctx: AdapterContext): Promise // 默认 no-op publish(ctx: AdapterContext, task: PublishTask): Promise } interface MonitorAdapter { provider: LLMProvider probe(ctx: AdapterContext): Promise keepAlive(ctx: AdapterContext): Promise query(ctx: AdapterContext, task: MonitorQueryTask): Promise } ``` ### 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.0(Chromium 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 CDP(finally) ``` **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 / submitting` 时,适配器必须每 60 s 调 `lease-manager.extend(lease_token)` 续租(lease TTL 默认 10 min)。 - 进入 `readyToReview` 时,**人工审核可能持续几分钟到几小时**,不能用 lease 续期表达。lease-manager **释放当前 lease**,把服务端任务切到 **`waiting_user` parked state**: - 任务归属仍绑定原 `target_account_id + target_client_id`,**不会被其它 client 抢走**。 - 任务**不持有活动 lease**(`active_attempt_id = null`),server 不会因 lease 超时回滚状态。 - 用户点"发布"后,client 走 `POST /tasks/{id}/lease?from_parked=true` 严格 CAS 重新领取(见下"parked 恢复协议")。 - 用户跨重启/切 window 恢复:同样的路径,parked state 持久在 DB。 - 服务端设置 "parked 超过 N 小时则自动 abort" 的 fallback(默认 **72 h**,v2.4 从 24 h 调大避免长假/周末误杀;可配置)。 - 用户取消或 signal abort → `aborted`。 - adapter 抛异常 → `failed`,带错误码。 - 进程崩溃 → 启动时按 §7.5 分派为 `unknown`(仅限 publish 任务的 `submitting` 阶段)。 #### Parked 恢复协议(严格 CAS · v2.4 补齐) `POST /api/desktop/tasks/{id}/lease?from_parked=true` 的服务端原子语义(单条 SQL 或 advisory lock): ``` UPDATE desktop_tasks SET active_attempt_id = , lease_token_hash = , lease_expires_at = now() + 10 min, status = 'in_progress', attempts = attempts + 1, updated_at = now() WHERE id = $1 AND status = 'waiting_user' AND target_client_id = $actor_client_id -- 仅 target_client 可调 AND active_attempt_id IS NULL RETURNING id, new_attempt_id, new_lease_token, lease_expires_at; ``` - **0 行返回 = 409 Conflict**,客户端按下表处理: | 错误情景 | 服务端返回 | 客户端动作 | |---|---|---| | 任务已被其它 attempt 抢先 resume(双击 / 断线重连重发)| 409 + `{reason: "already_in_progress", active_attempt_id}` | 若 attempt_id = self 的待领 → 直接用之前那份;否则忽略(避免双跑)| | 任务已 abort(超时/Web 端 cancel)| 409 + `{reason: "aborted"}` | UI 通知用户 "任务已取消";清理本地 state | | 任务已 succeeded / failed(其它路径抢结果)| 409 + `{reason: "terminal", status: ...}` | 当作结果回调丢失处理,拉一次状态同步 | | 调用方不是 `target_client_id`(换机 / 其它 client 误触发)| 403 + `{reason: "not_target_client"}` | **不**允许抢;UI 提示"请到原桌面端 `` 继续" | | 任务不是 `waiting_user` 状态 | 409 + `{reason: "not_parked"}` | 走正常 lease 重新领取 | - **重复请求(client 断线重发)**:同一个 client 发两次 `from_parked=true`,第一次成功,第二次命中"已被抢先"分支——但 `active_attempt_id` 的持有者就是自己,返回 200 幂等,带当前 `lease_token`。这要求 server 在检测到 `active_attempt_id != null` 时**把请求 client 和当前持有者对比**:相等且未超时 → 200 幂等返回当前 lease;不等 → 409。 #### target_client 离线/换机/revoke 时的 parked 恢复策略 | 情景 | 处理 | |---|---| | target_client **离线 >5 min**(正常关机 / 网络断)| parked 状态保持;Web 端 Modal 显示"等待桌面端 `` 上线";**不 rebind**;client 上线后走 §6.3 的 offline→online 补领路径(scheduler 扫描该 client 的 parked 任务并推 `task_available`)触发前端"继续审核"引导。 | | target_client **被 revoke**(`POST /api/desktop/clients/revoke`)| server 级联把该 client 所有 `status IN (waiting_user, in_progress)` 任务置 `unknown`;发通知给 workspace owner 通过 `/api/tenant/tasks/{id}/reconcile` 对账(因为 cookie 已随 client 失效,不能无脑 rebind)。 | | target_client **被用户主动删除** | 同 revoke。 | | target_client **换机**(同 workspace 新 client)| 不自动 rebind(Cookie 是本地隔离的,新 client 上没有该 account 的登录态)。Web 端显示"原桌面端已下线,请在新桌面端重新登录此账号后手动重启任务"。 | | target_client **72h 超时**(parked 自动 abort) | server `status → aborted` + `error = {code: 'parked_timeout', since: ...}`;Web 端 reconcile 工作流处理(可"重新运行"或"忽略")。 | **核心原则**:Parked 任务**从不自动迁移**到新 client(避免"我以为我取消了,它在另一台机器上还发了")。所有看起来像 rebind 的动作都必须是用户显式重新创建任务。 文件上传:`protocol.handle('file')` + CDP `Input.dispatchDragEvent` 或直接喂 ``,不经服务端中转。 ### 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:", "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": "" } ``` 客户端约束: - 内置 root key(可通过 Release 通道更新),支持 `key_id` 轮换。 - 下载时校验签名;**每次加载前再次校验 hash + 过期时间 + 未被 `revokes` 列表覆盖**。 - `patch_seq` 必须严格 > 已加载值,否则拒绝(防降级重放)。 - 服务端下发一个 `revoke ` 指令 → 客户端停用对应 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 `