diff --git a/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md b/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md index be9aa93..1a71870 100644 --- a/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md +++ b/docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md @@ -1,11 +1,11 @@ # Electron 桌面客户端设计(全面替换 WXT 浏览器插件) -**状态**:Draft v2 · 待实施(v1 经 Claude + Codex + Gemini 三方交叉审查后修订) +**状态**:Draft v2.4 · 待实施(历经 4 轮 Claude + Codex + Gemini 交叉审查后修订) **作者**:@liangxu + Claude(codex/gemini 交叉审校) **日期**:2026-04-18 **范围**:新增 `apps/desktop-client/`、`/api/desktop/*` 服务端路由、相关 DB schema;删除 `apps/browser-extension/` 与 `/api/plugin/*` 路由及相关代码。 -> **v2 主要修订**:修正 CDP 流式抓取方法(C1)、Patch 通道沙箱机制(C2)、SSE 多实例协调(C3)、workspace 独占 DB 落地(C4)、任务租约协议(C5)、老适配器工作量重估(C6);以及 12 条 Warning 逐项落入文档。详见 §13 变更记录。 +> **v2.4 主要修订**:Plan 0 **窄 workspace 化** 范围写死 + spec 一致性全量清扫 + Parked 恢复协议闭环(严格 CAS + 5 类恢复策略) + §15 Lease 状态机 & Account CAS resync Mermaid 图内联 + Spike 三态操作化 + 块 H 拆 H1/H2 + Design tokens 显式 + credential_holder DDL 承诺软化。完整历史(v1 → v2.4)详见 §13。 --- @@ -37,7 +37,7 @@ 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` 是产品边界,不是代码硬写;DB 列名采用 `credential_holder_workspace_id` / `account_home_client_id` 风格而非 `owner_*`,为将来"共享运营账号池 / 桌面端主备"留演进位置(见 §6.6 DDL 注释)。 +9. **演进缝预留**:短期 `一账号 = 一 workspace = 一台 client` 是产品边界,不是代码硬写。本版 DDL 继续使用 `workspace_id` / `client_id` / `target_client_id` 命名(不提前引入 `credential_holder_*` / `account_home_*` 命名以避免 v2.4 前 spec 与 DDL 不一致);未来真正做"共享运营账号池 / 桌面端主备"时,通过 rename migration + 兼容视图平滑演进。 10. 要求最小 electron 体积包 --- @@ -316,15 +316,58 @@ type PublishState = - 进入 `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 抢走**(因为 target_client_id 路由规则)。 + - 任务归属仍绑定原 `target_account_id + target_client_id`,**不会被其它 client 抢走**。 - 任务**不持有活动 lease**(`active_attempt_id = null`),server 不会因 lease 超时回滚状态。 - - 用户点"发布"后,客户端**重新获取 lease**(`POST /tasks/{id}/lease` 带 `from_parked=true`),进入 `submitting`。 - - 用户跨重启恢复:同样的路径,因为 parked state 持久在 DB。 - - 服务端可选设置 "parked 超过 N 小时则自动 abort" 的 fallback(默认 24h,可配置)。 + - 用户点"发布"后,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 公共能力 @@ -486,11 +529,15 @@ 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}/park # body: {lease_token, reason: 'waiting_user'|'captcha'} 释放 lease、状态 → waiting_user -POST /api/desktop/tasks/{id}/lease?from_parked=true # 从 parked 重新领取(不走 queue,指定回到 target_client) +POST /api/desktop/tasks/{id}/lease?from_parked=true # 从 parked 重新领取(严格 CAS,仅 target_client 可调用,见 §5.3) 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 +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, tags} POST /api/desktop/accounts # upsert:以 (workspace_id, platform, platform_uid) 为键;body 带 if_sync_version 做 CAS @@ -506,7 +553,7 @@ POST /api/tenant/publish-jobs # ... ``` -**幂等契约**:所有写操作(lease/extend/pause/resume/cancel/result)**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。客户端收到 409 = 租约已失效,**停止动作 + 提示人工对账**。 +**幂等契约**:所有持有活动 lease 的写操作(`extend` / `cancel` / `result` / `park` / `trace`)**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。`POST /tasks/lease` 和 `POST /tasks/{id}/lease?from_parked=true` 本身是 lease 发放者,不需要 `lease_token`;但后者走**严格 CAS**(见 §5.3 parked 恢复协议)。客户端收到 409 = 租约已失效 / 已被抢占 / parked 任务已 abort,**停止动作 + 触发 resync + 提示人工对账**。 **中间件与 workspace 一等化要求**(**先决条件**,不是实施细节): @@ -564,6 +611,7 @@ CREATE TABLE platform_accounts ( -- 一致性协议(见 §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, @@ -903,7 +951,7 @@ user clicks deep link in admin-web ### 7.5 崩溃恢复(按 task.kind 分治) -启动时扫描 `desktop_tasks` 里 `lease_client_id = self AND status IN (in_progress, waiting_user)` 的任务: +启动时扫描 `desktop_tasks` 里 `target_client_id = self AND status IN (in_progress, waiting_user)` 的任务: **Monitor 任务**(幂等只读,可安全重跑): @@ -921,9 +969,9 @@ UNKNOWN 任务在 admin-web "事件中心"和"发布历史"页有专门区块, | 用户动作 | 后端行为 | 语义 | |---|---|---| -| **"已确认发布成功"** | `POST /tasks/{id}/reconcile {status: 'succeeded', external_url}` | 把状态坐实为 `succeeded`,计入成功统计 | -| **"已确认发布失败"** | `POST /tasks/{id}/reconcile {status: 'failed', reason}` | 状态改 `failed`,允许触发 retry | -| **"忽略此任务"** | `POST /tasks/{id}/reconcile {status: 'aborted'}` | 状态改 `aborted`,从待办清单撤离 | +| **"已确认发布成功"** | `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/publish-jobs` 复制原 payload | 原任务保留 UNKNOWN 作为审计记录,新建一条跑 | **关键**:UNKNOWN 不会自动消失——用户必须显式选一项。超过 7 天没处理的 UNKNOWN 任务每周触发一次站内通知给 workspace owner。 @@ -1009,7 +1057,7 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un ### 9.4 集成 · 协议 -客户端 + 真实 server + fake 平台:验证**任务分发 / 租约 (lease/extend/pause/resume/cancel) / 多实例广播 / 幂等 / 熔断 / 断线重连**。 +客户端 + 真实 server + fake 平台:验证**任务分发 / 租约 (`lease` / `extend` / `park` / `lease?from_parked` / `cancel`) / 多实例广播 / 幂等 / 熔断 / 断线重连 / parked 任务在 target_client 下线/换机的恢复路径**。 ### 9.5 人工冒烟 Playbook @@ -1122,28 +1170,66 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un ### Plan 0 · Workspace 底座一等化(2–3 周 · 阻塞 Plan A) -v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Actor/Claims` 只有 tenant_id、`cache_support.go` key 按 tenant、没有 workspaces 表迁移、监控表按 tenant)。这些是横切式改造,不能作为局部补丁塞进 Plan A。 +#### 范围策略:Phase 1 **窄 workspace 化**(v2.4 明确决策) -- `workspaces` + `workspace_memberships` 表迁移 -- `Actor/Claims` 结构扩 `workspace_id`;User JWT 也加 -- `TenantScope` 之上新加 `WorkspaceScope` middleware -- 所有 repo layer query 显式注入 `workspace_id = :actor_workspace_id` -- `cache_support.go` workspace-sensitive key 加 `workspace_id` -- **监控域存量改造清单**(§6.1.1)全量迁:`tenant_monitoring_quotas` → 加 workspace_id + 改 `primary_client_id`;`monitoring_service.go` / `monitoring_callback_service.go` / `monitoring_handler.go` 全量改造 -- admin-web 现有页面的 API 调用补 workspace context +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 明确采用**两阶段策略**: -**退出目标**:现有 admin-web 页面全部在 workspace 粒度正常工作;tenant_monitoring 按 workspace 隔离;`/api/tenant/*` 所有路由挂上 `WorkspaceScope`;repo 单元测试覆盖 cross-workspace 防穿透用例。 +- **Phase 1(Plan 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 上限) -- 块 A(Desktop runtime 基座,含 parked state 实现) -- 块 E(服务端协议 + DB + reconcile endpoint) -- 块 H(ui-shared 抽取 + renderer + **PublishArticleModal 重构为账号级多选**) -- **§5.2 spike 矩阵(0.5 周)先做再定适配器实现** +- 块 A(Desktop runtime 基座,含 parked state 实现 + parked 恢复协议) +- 块 E(服务端协议 + DB + `/tasks/{id}/reconcile` endpoint + `/tasks/{id}/lease?from_parked` 严格 CAS) +- 块 H(拆两阶段,见下) + - **H1 · 前期(Plan A 第 1–2 周)**:`packages/ui-shared` 骨架 + **design tokens**(CSS variables light/dark + Ant Design Theme Config)+ `useAccountSelection` composable(账号级多选逻辑)+ `HealthBadge / AccountCard / TagMultiSelect / MarkdownPreview` 组件迁入。 + - **H2 · 后期(Plan A 第 4–5 周)**:`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 owner(Doubao 为主)联签。 +- **产物**:`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 监控任务 + 一条头条发布任务(含 manual 模式 parked → resume);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务的 reconcile 工作流闭环。 ### Plan B · 广度铺开(12–14 周,Beta 上限) @@ -1163,7 +1249,7 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac - Phase 2 Trace 基建(CDP 全事件流 + 2 fps 截屏 rolling buffer + Playwright Trace Viewer 兼容导出) - Metrics dashboard + 报警规则(crash rate / SSE 保持时长 / adapter 日成功率 / patch 覆盖率) - 回滚剧本与混沌演练 -- 账号共享 / 桌面端主备的演进位置预留(`credential_holder` 抽象,§2 硬约束 9) +- 账号共享 / 桌面端主备的演进位置预留(命名软约定,不在本版 DDL 落名;见 §2 硬约束 9) **退出目标**:patch 热更经过混沌演练;trace 能被外部 Playwright tooling 打开;运营指标全套接入报警;回滚剧本有实战演练记录。 @@ -1171,6 +1257,17 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac ## 13. 变更记录 +- **v2.4(2026-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 owner(runtime owner + provider owner 联签)、产物(`electron-chromium-cdp-matrix.md` + 抓包/截图)、**三态结论**(supported / degraded / blocked)、blocked 时 fallback 决策树(DOM-only 降级 / 移出承诺范围 / 等 Chromium 升级)。 + - **Modal 重构分阶段**(Gemini C1):块 H 拆 H1(Plan A 第 1–2 周:ui-shared 骨架 + design tokens + `useAccountSelection` composable)+ H2(第 4–5 周: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.3(2026-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 底座一等化**(§12,2–3 周,阻塞 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`。 @@ -1184,7 +1281,7 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac - **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 /tasks/{id}/reconcile` 端点 + 四种用户动作(已成功 / 已失败 / 忽略 / 重新创建);超 7 天未处理触发站内通知。 + - **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 周 → **28–33 周**(Plan 0 新增 2–3 周 + 块 H 工期修正 +1 周 + G 拆前后期)。§12 从 3 plan 拆成 4 plan。 - **v2.2(2026-04-18)**:Claude + Codex + Gemini 对 v2.1 的二轮交叉审查后的修订。修复 4 Critical + 9 Warning。 @@ -1211,7 +1308,7 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac - **C2** Patch 通道默认改为**纯数据 DSL**(无 JS 执行),可执行代码作为备用通道并限定在 `utilityProcess + isolated-vm` 沙箱;引入 TUF 风格 manifest + patch_seq + 根密钥轮换 + 撤销机制(§5.5)。 - **C3** 补齐 SSE 多实例协调方案:RabbitMQ 广播 + Redis presence registry,SSE 仅推通知、任务归属由 lease endpoint 原子决定(§6.3)。 - **C4** `platform_accounts` 新增 `platform_uid` + 唯一索引 `(platform, platform_uid)`,落实硬约束 8(§6.6)。 - - **C5** 任务协议引入 `attempt_id + lease_token` + extend/pause/resume/cancel;manual 模式进入 `waiting_user` 状态并延长租约(§5.3/§6.4/§6.6/§6.7)。 + - **C5** 任务协议引入 `attempt_id + lease_token` + extend/cancel;manual 模式引入 `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)。 @@ -1243,17 +1340,91 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac 这些条目在 Plan A spike 矩阵后更新为稳定值并记入 `docs/superpowers/specs/electron-chromium-cdp-matrix.md`。 -## 15. 附录:需要补的图(Plan A 期补齐) +## 15. 附录:架构图 -这是 v2.2 / v2.3 review 共同指出的文档短板,前端 + 新人理解链路需要**视觉化**: +### 15.1 Lease 状态机(inline · v2.4 在 Plan 0 前画完) -1. **全链路时序图**(Mermaid sequenceDiagram)——Web 点击发布 → Server 入库 → SSE 通知 → Desktop 领取 → 适配器执行 → 结果回传 → Web 更新,含正常流、离线补领流、租约冲突流、parked / resume 流。 -2. **Lease 状态机图**(Mermaid stateDiagram-v2)——`queued → in_progress → waiting_user(parked) → in_progress → succeeded / failed / unknown / aborted`。 -3. **Account CAS + Tombstone resync 时序图**(Mermaid sequenceDiagram)——client/server/Web 三方,含"Web 写 delete_requested + client 坐实"的两段式删除。 -4. **多实例部署架构图**——RabbitMQ fanout + 多个 server 实例 + Redis presence registry + 多个 desktop client。 -5. **前端组件层级图**(admin-web 与 desktop renderer 共享 `packages/ui-shared` 的依赖关系)。 +```mermaid +stateDiagram-v2 + [*] --> queued: POST /publish-jobs + queued --> in_progress: lease acquired (target_client_id match) + in_progress --> in_progress: extend (每 60s) + in_progress --> waiting_user: POST /park (manual readyToReview) + waiting_user --> in_progress: POST /lease?from_parked (严格 CAS) + in_progress --> succeeded: POST /result status=succeeded + in_progress --> failed: POST /result status=failed + in_progress --> aborted: POST /cancel + waiting_user --> aborted: 72h timeout / Web cancel + 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 (新 attempt) + succeeded --> [*] + failed --> [*] + aborted --> [*] -这些图由 Plan A 期负责 spec 维护的同学用 Mermaid 补,放在 `docs/superpowers/specs/diagrams/` 目录,并在本 spec 相关章节里用 `![](diagrams/...)` 引用。 + note right of waiting_user + parked state: + active_attempt_id = null + 仍绑 target_client_id + 不被抢走 + end note + + note left of unknown + 仅 publish 任务 + 仅 submitting 崩溃 + monitor 任务直接重跑 + end note +``` + +### 15.2 Account CAS + Tombstone Resync(inline · 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. 术语表 @@ -1264,7 +1435,7 @@ v2.2 review 发现现有代码里 workspace 不是后端一等安全边界(`Ac - **Client Token**:一台 Electron 安装的后台身份,与 User JWT 并列;解耦 client_version,支持 rotate/revoke。 - **Partition**:Electron `session.fromPartition('persist:')`,每个账号一个隔离的 cookie/storage 容器。 - **Lease**:任务租约。领取任务时服务端返回 `attempt_id + lease_token + lease_expires_at`;后续所有写操作必须带 `lease_token` 验证。 -- **Waiting-user 状态**:manual 发布模式下,adapter 暂停等用户审核时,服务端将任务置为此状态并延长租约;在此状态下不会被重分配。 +- **Waiting-user 状态(parked)**:manual 发布模式下,adapter 暂停等用户审核时,服务端**释放当前 lease**(`active_attempt_id = null`)、状态置为 `waiting_user`;任务仍绑 `target_client_id` 不被抢走;用户回来时通过 `POST /tasks/{id}/lease?from_parked=true` 严格 CAS 重新领取(见 §5.3)。72h 超时自动 abort。 - **Platform UID**:平台侧真实账号 ID(如微信 fakeid、B 站 mid、知乎 hash_id);`(platform, platform_uid)` 在 DB 层唯一,落实硬约束 8。 - **DSL(Patch 默认通道)**:一套受限的声明式解析语言,只能做字符串/JSON 解析与模式匹配,无 JS 执行能力。 - **Presence registry**:多实例服务端共享的 `client_id → (instance_id, conn_id)` 注册表,用 Redis TTL 30 s 维护;用于跨实例路由 SSE 通知。