From a83faff211f3c9cbe0681f4f9a8f8bd7f4186814 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Apr 2026 21:48:02 +0800 Subject: [PATCH] docs(desktop-client): spec v2.2 applies 4C+9W from second cross-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round Claude+Codex+Gemini cross-review surfaced implementation- level gaps in the v2.1 admin-web coordination layer. All 4 Critical and 9 Warning findings addressed: Critical: - C1 (multi-account routing): desktop_tasks now has first-class columns job_id, target_account_id, target_client_id, platform with indexes; new desktop_publish_jobs aggregation table; §6.3 SSE only pushes to target_client_id; lease rejects non-target requesters. - C2 (account consistency): Replace vague "client=SoT, server=projection" with CAS + Tombstone protocol. platform_accounts gains sync_version and deleted_at; all client writes require if_sync_version CAS; web-side delete writes delete_requested_at intent, client resync completes the delete via normal DELETE path. Added 3-way merge rules and concurrent case table in §6.8.6. - C3 (/api/tenant/events auth): WorkspaceScope middleware required (TenantScope insufficient); topics must be server-derived from actor+workspace, never accept free query params; symmetric contract table between /api/desktop/events and /api/tenant/events (multi- instance fanout, Last-Event-ID replay, snapshot first frame, monotonic event_id, client dedup). - C4 (route migration): Full migration map added in §6.8.4 listing media/platform-accounts -> accounts, articles/:id/publish-batches -> publish-jobs, publish-records -> publish-jobs/{id} subtasks. Warning: - Kimi binary-framed gRPC-Web path added to §5.2 transport table; login methods (Kimi: wechat/phone; Hunyuan: wechat/qq/email + loopback) noted in §4.4. - Offline->online dual-sided reclaim: server scans queued tasks and re-pushes task_available; client heartbeats+resyncs then enters lease(limit=20) loop until empty. - GET /api/desktop/accounts?client=self added to §6.4 for resync. - Account.tags added; Modal gains tag multiselect + search + three- level batch select. - §6.8.5 exposes queue_ahead + eta_seconds for capacity feedback. - Deep link gains intermediate-state modal + manual-launch/copy fallbacks + one-time nonce JWT on tasks//review; Windows/mac/ Linux protocol registration specified. - content_ref is now polymorphic {type, id} supporting article_draft / template_gen / video / brand_asset. - retry endpoint accepts JSON body, not query string. Info: §10.3 title corrected to 9 blocks; §12 opening synced to 24-28 weeks; §6.8.3 adds "attention-needed accounts" card + per-platform summary; monitoring LLM UI switched to Grid/Tag responsive layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-04-18-electron-desktop-client-design.md | 259 ++++++++++++++---- 1 file changed, 209 insertions(+), 50 deletions(-) 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 7d9626d..4ffa6fc 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 @@ -141,6 +141,9 @@ type Account = { usedThisHour: number resetAt: Date } + tags?: string[] // 用户可自定义分组标签(如"品牌号"/"矩阵号"/"测试号"),Modal 按 tag 批量勾选 + syncVersion: number // 单调递增,每次写入 +1,用于 client-server 一致性 CAS + deletedAt?: Date // tombstone,软删除,resync 时用于判断 "server 已删" } type ProxyConfig = { @@ -182,8 +185,8 @@ type ProxyConfig = { | 豆包 | Doubao | 主动保活 | 8 min + 30 min 探活 | | 千问 | Qwen | 主动保活 | 8 min + 30 min 探活 | | DeepSeek | DeepSeek | 只探活 | 30 min | -| 混元 | Hunyuan(腾讯)| 主动保活(SSE / 短期 token,待接入时复测)| 8 min + 30 min 探活 | -| Kimi | Moonshot | 主动保活(SSE / 短期 token,待接入时复测)| 8 min + 30 min 探活 | +| 混元 | Hunyuan(腾讯)· 登录方式:微信扫码 / QQ / 邮箱(微信涉及 `open.weixin.qq.com` iframe + 本地 loopback 检查)| 接入时按实际 transport 复测;默认主动保活 | 8 min + 30 min 探活 | +| Kimi | Moonshot · 登录方式:微信扫码 / 手机号快捷登录。实测请求是 `POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat`(gRPC-Web binary-framed,**非 EventSource**,parser 需先 framing 解包,见 §5.2) | 接入时按实际 transport 复测;默认主动保活 | 8 min + 30 min 探活 | 实施细节: @@ -249,6 +252,7 @@ interface MonitorAdapter { | `new EventSource(url)` | `Network.enable` → `Network.eventSourceMessageReceived` 监听每条 message | | `fetch(url)` + `text/event-stream` + streaming body | `Fetch.enable({patterns})` → `Fetch.requestPaused` → `Fetch.continueResponse` → `Fetch.takeResponseBodyAsStream` + `IO.read` 流式拼接 | | `fetch(url)` + chunked transfer + 自定义协议 | 同上(或 Chromium ≥ 120 的 `Network.streamResourceContent + Network.dataReceived`,**需先确认 Electron 打包的 Chromium 版本支持**)| +| **gRPC-Web / binary-framed fetch-stream**(如 Kimi `/apiv2/kimi.gateway.chat.v1.ChatService/Chat`)| 同 `Fetch.enable + takeResponseBodyAsStream + IO.read`,但 **provider parser 必须先做 gRPC-Web framing 解包**(5-byte 前缀:1B compressed flag + 4B message length)再按 protobuf 解 payload。正规化成 MonitorResult 前建议引入 `framer.ts` 公共工具 | **Chromium 版本兼容矩阵**由 `docs/superpowers/specs/electron-chromium-cdp-matrix.md`(本 spec 落地后新建)维护;每次升 Electron 大版本检查一次上面这些 domain 是否仍可用。 @@ -341,7 +345,7 @@ type PublishState = - 服务端下发一个 `revoke ` 指令 → 客户端停用对应 patch、降级为前一版本,直到收到下一批 manifest。 - UI 每个适配器显示:`client 1.2.0 · doubao patch #47 (via DSL)`。 -### 5.6 Trace 基础设施(Alpha 范围 + Phase 2 兼容 Playwright) +### 5.6 Trace 基础设施(Alpha 范围 + Phase 2 兼容 Playwright)(不着急) **Alpha 范围(MVP)**: @@ -402,19 +406,21 @@ Web UI / Scheduler creates task each instance looks up its local SSE hub: "do I hold any client that can handle this task's platform+workspace?" ↓ - if yes: push {type: 'task_available', task_id} via SSE + 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 claims task + 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' RETURNING` 或 Postgres advisory lock)。多个 client 同时收到通知也只会有一个 lease 成功。 +- **任务归属由 `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)` 写到 Redis(TTL 30 s,SSE 心跳续期)。Scheduler 根据 presence 决定是否要广播。 +- **presence registry**:每个 server 实例维护本地 `client_id → SSE connection`,并把 `(client_id, instance_id)` 写到 Redis(TTL 30 s,SSE 心跳续期)。Scheduler 按 `target_client_id` 查 presence 决定是否广播或挂队。 +- **offline→online 主动补领**:server 检测到某 `client_id` 从 offline 转 online(Redis TTL 从无到有),扫描 `desktop_tasks WHERE target_client_id = $1 AND status = 'queued'`,对命中的每条重推 `task_available`;client 侧也会在 heartbeat 后进入 `lease(kind?, limit=20)` 循环到空,两头夹逼避免任务"永远待领"。 ### 6.4 路由表 @@ -438,9 +444,10 @@ POST /api/desktop/tasks/{id}/result # body: {lease_token, status, payloa POST /api/desktop/tasks/{id}/trace # 用户主动上传诊断包 # 账号元数据(不传 cookie) -POST /api/desktop/accounts # upsert:以 (workspace_id, platform, platform_uid) 为键 -PATCH /api/desktop/accounts/{id} # 改 displayName / health / quota -DELETE /api/desktop/accounts/{id} +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 +PATCH /api/desktop/accounts/{id} # 改 displayName / health / quota / tags;body 带 if_sync_version 做 CAS +DELETE /api/desktop/accounts/{id}?if_sync_version= # 软删除写 tombstone,非 hard delete # Patch 通道 GET /api/desktop/patches/manifest # 返回当前适用的 manifest 列表 + revoke 指令 @@ -453,6 +460,8 @@ POST /api/tenant/publish-jobs **幂等契约**:所有写操作(lease/extend/pause/resume/cancel/result)**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。客户端收到 409 = 租约已失效,**停止动作 + 提示人工对账**。 +**中间件要求**:`/api/desktop/*` 沿用现有 `TenantScope` 即可(因为每个 client 只属于一个 workspace);`/api/tenant/events`、`/api/tenant/accounts`、`/api/tenant/publish-jobs` 必须**新增 `WorkspaceScope` middleware**——当前 `server/internal/shared/middleware/tenant_scope.go` 只注入 `tenant_id`,不足以隔离同租户下不同 workspace 的事件订阅/数据访问。`WorkspaceScope` 从 User JWT 的 `workspace_id` claim 或 URL path 派生,**永不接受 query 参数自由选择**;所有涉及 workspace 归属的查询在 middleware 里强制注入 `workspace_id = :actor_workspace_id` 过滤条件。 + ### 6.5 服务端边界 服务端只存**任务意图**("账号 A 去豆包问 Q")与**结果**("答案文本 + 品牌是否出现")。**永不**存 Cookie、**永不**直连平台帮客户端跑。客户端失联 = 任务排队;长时间拿不到 = 超时失败。硬边界,防止 Cookie 从客户端外泄。 @@ -478,7 +487,7 @@ CREATE TABLE desktop_clients ( revoked_at TIMESTAMPTZ ); --- 平台账号(workspace 独占约束落地) +-- 平台账号(workspace 独占约束 + 一致性协议) CREATE TABLE platform_accounts ( id UUID PRIMARY KEY, tenant_id UUID NOT NULL, @@ -490,22 +499,35 @@ CREATE TABLE platform_accounts ( 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 默认过滤掉 created_at TIMESTAMPTZ NOT NULL, last_seen_at TIMESTAMPTZ, - UNIQUE (platform, platform_uid) -- 硬约束 8 的 DB 落地 + 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, - kind TEXT NOT NULL, -- 'publish' | 'monitor' + -- 定向路由(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 | waiting_user | succeeded | failed | unknown | aborted + status TEXT NOT NULL, -- queued | in_progress | waiting_user | succeeded | failed | unknown | aborted dedup_key TEXT, -- 租约 - active_attempt_id UUID, -- null 表示无租约持有者 + active_attempt_id UUID, -- null 表示无租约持有者 lease_token_hash BYTEA, - lease_client_id UUID REFERENCES desktop_clients(id), lease_expires_at TIMESTAMPTZ, attempts INT NOT NULL DEFAULT 0, -- 结果 @@ -514,6 +536,22 @@ CREATE TABLE desktop_tasks ( 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', 'waiting_user'); +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 ( @@ -582,23 +620,29 @@ Web UI 可视化 - `lastPublishAt`(上次成功发布时间) 2. Modal UI: - 平台折叠块,标题显示"选中 X / 共 Y" - - 每账号一行:`checkbox · displayName · health badge · online badge · 最近发布时间` - - 全选 / 反选(按平台或全局) + - 每账号一行:`checkbox · displayName · tags chips · health badge · online badge · 最近发布时间` + - **按 tag 批量勾选**:顶部 tag 多选器(`tags` 来自 `platform_accounts.tags`,用户在"账号管理"里自定义,如"品牌号"/"矩阵号"/"测试号"),勾选 tag 后批量勾选所有带该 tag 的账号 + - 搜索框(按 displayName / platform_uid 匹配) + - 全选 / 反选(按平台、按 tag、全局) - 选模式(manual / auto)、时间(立刻 / 定时)、素材策略(封面默认 / 指定) -3. 提交:`POST /api/tenant/publish-jobs`,body 示例: +3. 提交:`POST /api/tenant/publish-jobs`,body 示例(**`content_ref` 多态**以适配现有 admin-web 的 KOL 模板/品牌素材/图文/视频等多种素材来源): ```json { "title": "2026 Q2 新品发布", - "content_ref": "draft:1234", + "content_ref": { + "type": "article_draft", + "id": "draft_1234" + }, "accounts": [ {"account_id": "acc-a1", "mode": "auto"}, {"account_id": "acc-a2", "mode": "manual"}, {"account_id": "acc-b5", "mode": "auto"} ], - "schedule_at": null + "scheduled_at": null } ``` - server 展开成 3 条 `desktop_tasks(kind=publish)`,共享同一 `job_id`;每条绑定一个 `account_id` 与其所属 `client_id`。 + `content_ref.type` 支持 `article_draft` / `template_gen`(KOL 模板生成)/ `video` / `brand_asset` 等,后续按需扩枚举;server 根据 type 在解析阶段拉对应实体并附到每条 desktop_task 的 payload 里。 + 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:`;接收每子任务状态变化(`queued → in_progress → waiting_user → succeeded/failed/unknown/aborted`)。 5. manual 模式任务进入 `waiting_user` 后,面板上显示"账号 X 等待桌面端审核",带"前往桌面端"按钮 → `georankly://tasks//review`(未装客户端时跳下载页)。 6. 全部终态后 Modal 汇总:每账号结果 URL / 错误码;对失败子任务点"重试" → `POST /api/tenant/publish-jobs/{job_id}/retry?task_ids=...`。 @@ -613,56 +657,156 @@ admin-web 的"账号总览 / 发布历史 / 监控结果"三个页面全部按 ` | **发布历史** | `GET /api/tenant/publish-jobs?...` | job 分组,展开后每行 `(job, account, status)` | | **监控结果** | `GET /api/tenant/monitoring-results?plan_id=&range=` | 每行 `(plan, query, llm_account, answer, hit)` | -统计卡片:workspace 在线桌面端数、各平台账号健康率、近 7 天发布成功率(**按账号**)、近 7 天监控完成率、过去 24h 异常账号数 / 异常平台数。 +统计卡片(按信息密度排序,突出"需要马上关注"的内容): -#### 6.8.4 admin-web 新增 / 扩充 API +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 聚合 +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) -POST /api/tenant/publish-jobs/{id}/retry # 重跑失败子任务 +POST /api/tenant/publish-jobs/{id}/retry # body: {"task_ids": ["..."]}(不用 query string) GET /api/tenant/monitoring-results # 支持 filter / range / 按 llm / 按账号 -GET /api/tenant/events?topic=job:|account:|workspace # 面向 Web 的 SSE +GET /api/tenant/events # SSE,topic 参数仅作提示,实际 topic 服务端派生 ``` -**关键**:`/api/tenant/events` 与 `/api/desktop/events`(§6.4)是**两条独立通道**,订阅 topic 不同——前者推 task 状态给 Web UI,后者推 task 分派给 desktop client。 +**与现有路由的迁移表**(核对 `server/internal/tenant/transport/router.go`): -#### 6.8.5 离线降级 UX +| 现有路由 | 处置 | 新路由 / 迁移策略 | +|---|---|---| +| `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: ""}`。旧接口一次只绑一篇文章,新接口支持多态 `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=`。 | +| `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:` 派生)和 Desktop 端(通过 `topic=client:` 派生),订阅者拿到的是各自视角的事件流。 + +#### 6.8.5 离线降级 UX 与上线补领 + +**离线态展示**: - **某桌面端离线**(`last_seen_at > 5 min`): - - Modal 里所属账号标"离线(上次 X 分钟前)",仍可勾选,顶部提示"任务将在该端上线后自动执行"。 + - Modal 里所属账号标"离线(上次 X 分钟前)· 待执行队列 N",仍可勾选,顶部提示"任务将在该端上线后自动执行"。 - 账号总览 / 监控结果用灰色 badge。 + - 若同 workspace 有其他在线客户端但它没持有该账号 → Modal 显式提示"该账号绑定的 `` 离线,其他客户端无法代跑(Cookie 本地隔离)"。 - **workspace 所有桌面端都离线**: - - Modal 顶部 banner:"当前 workspace 无在线桌面端。任务会排队等待。[下载桌面端]" + - Modal 顶部 banner:"当前 workspace 无在线桌面端。任务会排队(队列 N 条,预计等待 > M min)。[下载桌面端]" - 监控计划若连续 N 次未执行,站内通知 + 邮件给 workspace owner。 -- **桌面端重新上线**: - - desktop-client 启动 `POST /api/desktop/clients/heartbeat` + §6.8.6 resync;queued 任务通过 SSE 被自然分派。 - - admin-web 的 SSE 收到 `client_online` 事件自动刷新 badge,不需 reload。 +- **容量/ETA 反馈**:`GET /api/tenant/publish-jobs/{id}` 返回 `queue_ahead`(该任务前面还有几条排队)和 `eta_seconds`(基于近期吞吐估算);Modal 进度面板显示为"前面还有 3 条任务 · 预计 2 min 后开始"。 -#### 6.8.6 Account 数据一致性(client ↔ server) +**上线补领机制**(**双向夹逼**,避免任务永远待领): -**client 是 account existence 的 SoT,server 存投影**: +- **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。 -- 登录完成:client `POST /api/desktop/accounts`(upsert,以 `(workspace_id, platform, platform_uid)` 为键,唯一索引见 §6.6)。 -- 健康 / 昵称变化:client `PATCH /api/desktop/accounts/{id}`。 -- 删除:client `DELETE /api/desktop/accounts/{id}` → server 级联把该账号未完成的 task 置为 `aborted`。 -- **启动时 resync**:client `GET /api/desktop/accounts?client=self` 比对本地清单;server 有而本地没的(用户在别处删过)→ 本地删;反之 upsert;`platform_uid` 冲突走 §6.6 唯一索引处理。 +#### 6.8.6 Account 数据一致性(CAS + Tombstone 协议) -admin-web 看到"账号不存在"(Modal 里某账号消失)= client 已删;秒级一致性延迟可接受。 +**关键原则**(取代 v2.1 中模糊的"client = SoT / server = 投影"措辞,因其在并发场景下语义不一致): + +1. **Account 的生命周期由 server 的 `sync_version` 单调序列与 `deleted_at` tombstone 共同定义**;client 持有本地 partition 与 cookie。server 仍不持有任何 cookie 原文。 +2. **所有写操作都是 CAS(Compare-And-Swap)**:client 请求必须带 `if_sync_version=`,server 仅当 `current_version == n` 时接受写入并自增 version;否则返 409 Conflict,client 据此触发 resync。 +3. **删除永远是 tombstone(soft 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=` | CAS 更新 | +| 客户端主动解绑 | client | `DELETE /api/desktop/accounts/{id}?if_sync_version=` | 写 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 的 upsert,client 收 409,UI 提示"该账号已在其他 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 tags(if=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` | "一键添加某平台账号" | 同上 | -| `georankly://tasks//review` | Manual 模式等审核 | 同上 | +| 深链 | 场景 | 参数 | Fallback | +|---|---|---|---| +| `georankly://accounts` | "前往桌面端管理账号" | — | 未装 → 下载页 | +| `georankly://accounts/add?platform=wechat` | "一键添加某平台账号" | platform | 同上 | +| `georankly://tasks//review?nonce=` | Manual 模式等审核 | **必须带 nonce**:server 签发的短期 JWT(TTL 60 s,含 `task_id + actor_id + workspace_id`),客户端打开深链时先回调 `POST /api/desktop/links/verify` 换取可访问凭证;防止任意网页构造深链唤起本地敏感页 | 同上 | -admin-web 点深链前先查 `/api/tenant/clients` 判断是否有该 workspace 的在线桌面端;没有直接弹下载引导,不让用户点空链接。 +**点击交互链路**(解决跨浏览器行为差异 + 协议未注册 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 的打包任务里明确落实。 --- @@ -841,7 +985,7 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un 如果未来发现有内部测试账号遗留在老表里:手动导出账号元数据后 drop 老表,不写自动迁移。 -### 10.3 工作量重估(4 块) +### 10.3 工作量重估(9 块) 粗估(单人工程周): @@ -895,7 +1039,7 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un ## 12. Plan 拆分建议 -本 spec 包含约 22–26 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship: +本 spec 包含约 24–28 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship: ### Plan A · 骨架穿透(5–6 周,Alpha 上限) @@ -931,6 +1075,21 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un ## 13. 变更记录 +- **v2.2(2026-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`)+ tombstone(soft 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//review` 带一次性 nonce(60s 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 开头 22–26 → 24–28(与 §10.3 一致);§6.8.3 统计卡加"🔴 待关注账号"与"按平台汇总"视图;监控计划 UI 改 Grid/Tag 多选布局为未来扩充留余地。 - **v2.1(2026-04-18)**:用户增量反馈后的小修订。 - **LLM 监控 3 家 → 5 家**:新增混元(Tencent Hunyuan)、Kimi(Moonshot),§4.4 保活表增行(两家默认走 8 min 主动保活,实际节奏接入时复测);§10.3 块 B 估时 2–3 周 → 4–5 周;总工期 22–26 周 → 24–28 周;§12 Plan B 范围更新。 - **新增 §6.8「admin-web 协同」**:覆盖发布 Modal 账号级多选语义、媒体数据面板(账号级粒度)、`/api/tenant/*` 聚合与 SSE 通道、离线降级 UX、Account 数据一致性(client = existence SoT / server = 投影)、`georankly://` 深链接。明确 `/api/tenant/events` 与 `/api/desktop/events` 是两条独立 SSE 通道。