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:
@@ -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)` 写到 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=<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 # 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: "<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 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=<n>`,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=<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 的 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/<id>/review` | Manual 模式等审核 | 同上 |
|
||||
| 深链 | 场景 | 参数 | Fallback |
|
||||
|---|---|---|---|
|
||||
| `georankly://accounts` | "前往桌面端管理账号" | — | 未装 → 下载页 |
|
||||
| `georankly://accounts/add?platform=wechat` | "一键添加某平台账号" | platform | 同上 |
|
||||
| `georankly://tasks/<id>/review?nonce=<jwt>` | 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/<id>/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 通道。
|
||||
|
||||
Reference in New Issue
Block a user