docs(desktop-client): spec v2.2 applies 4C+9W from second cross-review

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/<id>/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) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 21:48:02 +08:00
parent c41222706b
commit a83faff211
@@ -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 <platform> <patch_seq>` 指令 → 客户端停用对应 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)` 写到 RedisTTL 30 sSSE 心跳续期)。Scheduler 根据 presence 决定是否广播。
- **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 路由表
@@ -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 / 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 指令
@@ -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:<job_id>`;接收每子任务状态变化(`queued → in_progress → waiting_user → succeeded/failed/unknown/aborted`)。
5. manual 模式任务进入 `waiting_user` 后,面板上显示"账号 X 等待桌面端审核",带"前往桌面端"按钮 → `georankly://tasks/<id>/review`(未装客户端时跳下载页)。
6. 全部终态后 Modal 汇总:每账号结果 URL / 错误码;对失败子任务点"重试" → `POST /api/tenant/publish-jobs/{job_id}/retry?task_ids=...`。
@@ -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:<id>|account:<id>|workspace # 面向 Web 的 SSE
GET /api/tenant/events # SSEtopic 参数仅作提示,实际 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: "<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 分钟前)",仍可勾选,顶部提示"任务将在该端上线后自动执行"。
- Modal 里所属账号标"离线(上次 X 分钟前)· 待执行队列 N",仍可勾选,顶部提示"任务将在该端上线后自动执行"。
- 账号总览 / 监控结果用灰色 badge。
- 若同 workspace 有其他在线客户端但它没持有该账号 → Modal 显式提示"该账号绑定的 `<device_name>` 离线,其他客户端无法代跑(Cookie 本地隔离)"。
- **workspace 所有桌面端都离线**
- Modal 顶部 banner"当前 workspace 无在线桌面端。任务会排队等待。[下载桌面端]"
- Modal 顶部 banner"当前 workspace 无在线桌面端。任务会排队(队列 N 条,预计等待 > M min。[下载桌面端]"
- 监控计划若连续 N 次未执行,站内通知 + 邮件给 workspace owner。
- **桌面端重新上线**
- desktop-client 启动 `POST /api/desktop/clients/heartbeat` + §6.8.6 resyncqueued 任务通过 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 的 SoTserver 存投影**
- **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. **所有写操作都是 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` | "一键添加某平台账号" | 同上 |
| `georankly://tasks/<id>/review` | Manual 模式等审核 | 同上 |
| 深链 | 场景 | 参数 | Fallback |
|---|---|---|---|
| `georankly://accounts` | "前往桌面端管理账号" | — | 未装 → 下载页 |
| `georankly://accounts/add?platform=wechat` | "一键添加某平台账号" | platform | 同上 |
| `georankly://tasks/<id>/review?nonce=<jwt>` | Manual 模式等审核 | **必须带 nonce**server 签发的短期 JWTTTL 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 Actionsrunner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un
如果未来发现有内部测试账号遗留在老表里:手动导出账号元数据后 drop 老表,不写自动迁移。
### 10.3 工作量重估(4 块)
### 10.3 工作量重估(9 块)
粗估(单人工程周):
@@ -895,7 +1039,7 @@ GitHub Actionsrunner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un
## 12. Plan 拆分建议
本 spec 包含约 2226 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship:
本 spec 包含约 2428 周的工作量,应按以下 3 个独立 plan 执行,每个 plan 都能独立 ship:
### Plan A · 骨架穿透(56 周,Alpha 上限)
@@ -931,6 +1075,21 @@ GitHub Actionsrunner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un
## 13. 变更记录
- **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 通道。