feat(desktop): drop parked review flow, add publish management

SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除
manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询,
desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次
发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based
desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与
媒体库;plan A / spec 文档同步口径。
This commit is contained in:
2026-04-20 09:52:48 +08:00
parent b16e9f0bd1
commit a617d39a4a
93 changed files with 5519 additions and 3594 deletions
@@ -1,8 +1,10 @@
# Plan A · 桌面客户端骨架穿透 Implementation Plan
> **2026-04-20 当前实现口径**desktop 侧二次人工审核与 `manual / waiting_user / parked / from_parked` 链路已废弃。SaaS 完成人工审核后才创建 publish jobdesktop client 只负责执行发布,并提供“发布管理”查看待发布队列、历史结果与手动再次发送。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务(含 manual parked 恢复);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务 reconcile 工作流闭环。
**Goal:** 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务desktop client 提供“发布管理”(待发布队列 + 历史结果 + 再次发送);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务 reconcile 工作流闭环。
**Architecture:** Electron 单进程树 + 多 Session + WebContentsView Hot/Cold 分档 + CDP 按 transport 分流抓取 + `/api/desktop/*` 定向路由协议 + SSE + 60s pull 兜底 + RabbitMQ fanout + Redis presence registry。桌面端复用 `packages/http-client + shared-types`renderer 基于新建 `packages/ui-shared`(含 design tokens + composables);admin-web 复用同一套共享组件。
@@ -20,11 +22,11 @@
|---|---|---|---|---|
| A.0 · Spike 前置 | §5.2 CDP × Doubao 三态矩阵 | 0.5 周 | Plan 0 | ❌ 必须先做 |
| A.1 · 服务端协议 + DB | 块 E:`/api/desktop/*` + `/api/tenant/publish-jobs,accounts,tasks,events` + desktop 四张表新建 + reconcile/cancel endpoints | 1.5 周 | A.0 | ✅ 与 A.2 并行 |
| A.2 · Desktop runtime 基座 | 块 AMain 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter / parked 恢复协议 + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
| A.2 · Desktop runtime 基座 | 块 AMain 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
| A.3 · ui-shared H1 | `packages/ui-shared` 骨架 + design tokens + `useAccountSelection` composable + 5 个组件迁入 | 1.5 周 | Plan 0 | ✅ 与 A.1/A.2 并行 |
| A.4 · 1 监控适配器 Doubao | 基于 A.2 + A.0 spike 结论实现 | 1 周 | A.0, A.2 | — |
| A.5 · 1 发布适配器 头条号 | 基于 A.2 + 状态机 parked 流程 | 1 周 | A.2 | ✅ 与 A.4 并行 |
| A.6 · PublishArticleModal H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal | 1 周 | A.1, A.3 | — |
| A.5 · 1 发布适配器 头条号 | 基于 A.2 的直发状态机 + 队列节流 + 失败可再次发送 | 1 周 | A.2 | ✅ 与 A.4 并行 |
| A.6 · PublishArticleModal + 发布管理 H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal,并补 desktop publish management 页 | 1 周 | A.1, A.2, A.3 | — |
| A.7 · 测试基建 + Mac 公证 | 块 I + Ffake server / fake 平台 / Playwright _electron + Mac 公证 CI | 1 周 | A.2, A.4, A.5 | ✅ 与 A.6 并行 |
| A.8 · Exit bar | E2E + 混沌 + 真实账号冒烟 + reconcile 闭环演练 | 0.5 周 | ALL | — |
@@ -249,7 +251,7 @@ CREATE TABLE desktop_tasks (
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');
WHERE status IN ('queued', 'in_progress');
CREATE INDEX idx_tasks_workspace ON desktop_tasks (workspace_id);
-- desktop_publish_jobs
@@ -458,21 +460,6 @@ WHERE id = sqlc.arg(task_id)
AND status = 'queued'
RETURNING *;
-- name: LeaseFromParked :one
-- 严格 CAS:从 waiting_user 回到 in_progress,仅 target_client 可调
UPDATE desktop_tasks
SET active_attempt_id = sqlc.arg(attempt_id),
lease_token_hash = sqlc.arg(lease_token_hash),
lease_expires_at = now() + interval '10 minutes',
status = 'in_progress',
attempts = attempts + 1,
updated_at = now()
WHERE id = sqlc.arg(task_id)
AND target_client_id = sqlc.arg(client_id)
AND status = 'waiting_user'
AND active_attempt_id IS NULL
RETURNING *;
-- name: ExtendLease :one
UPDATE desktop_tasks
SET lease_expires_at = now() + interval '10 minutes', updated_at = now()
@@ -481,13 +468,6 @@ WHERE id = sqlc.arg(task_id)
AND status = 'in_progress'
RETURNING *;
-- name: ParkTask :one
UPDATE desktop_tasks
SET status = 'waiting_user', active_attempt_id = NULL, lease_token_hash = NULL,
lease_expires_at = NULL, updated_at = now()
WHERE id = sqlc.arg(task_id) AND lease_token_hash = sqlc.arg(lease_token_hash)
RETURNING *;
-- name: CompleteTask :one
UPDATE desktop_tasks
SET status = sqlc.arg(status), result = sqlc.arg(result), error = sqlc.narg(error),
@@ -514,21 +494,30 @@ WHERE id = sqlc.arg(task_id)
RETURNING *;
-- name: WebCancelTask :one
-- Web 取消 parked / queued task
-- Web 取消 queued task
UPDATE desktop_tasks
SET status = 'aborted', updated_at = now()
WHERE id = sqlc.arg(task_id)
AND workspace_id = sqlc.arg(workspace_id)
AND status IN ('queued', 'waiting_user')
AND status = 'queued'
RETURNING *;
-- name: GetTaskForAudit :one
SELECT * FROM desktop_tasks WHERE id = sqlc.arg(task_id) AND workspace_id = sqlc.arg(workspace_id);
-- name: ListParkedTasksByClient :many
-- For offline→online reclaim
-- name: ListPublishTasksByClient :many
-- 发布管理:排队任务优先,随后返回历史发送记录
SELECT * FROM desktop_tasks
WHERE target_client_id = sqlc.arg(client_id) AND status IN ('queued', 'waiting_user');
WHERE target_client_id = sqlc.arg(client_id)
AND kind = 'publish'
ORDER BY
CASE WHEN status IN ('queued', 'in_progress') THEN 0 ELSE 1 END,
updated_at DESC
LIMIT sqlc.arg(limit_count);
-- name: ListQueuedTasksByClient :many
SELECT * FROM desktop_tasks
WHERE target_client_id = sqlc.arg(client_id) AND status = 'queued';
```
- [ ] **Step 4sqlc generate + 编译**
@@ -541,7 +530,7 @@ cd server && sqlc generate && go build ./...
```bash
git add server/internal/tenant/repository/
git commit -m "feat(sqlc): desktop tables queries incl. lease / park / reconcile / cancel CAS"
git commit -m "feat(sqlc): desktop task lease queries and publish management listing"
```
### Task A.1.3Handler 层实现所有 `/api/desktop/*` 和 `/api/tenant/*` 白名单路由
@@ -694,50 +683,28 @@ func (h *PublishJobHandler) Create(c *gin.Context) {
}
```
- [ ] **Step 3LeaseFromParked handler(严格 CAS**
- [ ] **Step 3发布管理 handlers(列表 + 再次发送**
```go
func (h *DesktopTaskHandler) LeaseFromParked(c *gin.Context) {
client := middleware.MustClientFromCtx(c.Request.Context()) // client_token 派生
taskID := uuid.MustParse(c.Param("id"))
fromParked := c.Query("from_parked") == "true"
if !fromParked { /* 走普通 /lease */ }
func (h *DesktopTaskHandler) ListPublish(c *gin.Context) {
client := middleware.MustClientFromCtx(c.Request.Context())
limit := clampInt(queryInt(c, "limit", 100), 1, 200)
attemptID := uuid.New()
token := newLeaseToken()
tokenHash := sha256Hash(token)
tasks, err := h.app.DesktopTaskService.ListPublishTasks(c.Request.Context(), client, limit)
if err != nil { /* ... */ return }
task, err := h.app.DesktopRepo.LeaseFromParked(c.Request.Context(),
generated.LeaseFromParkedParams{
TaskID: taskID,
ClientID: client.ID,
AttemptID: attemptID,
LeaseTokenHash: tokenHash,
})
if err != nil {
// 0 rows → detect reason and return 409
currentTask, _ := h.app.DesktopRepo.GetTaskForAudit(c.Request.Context(), taskID, client.WorkspaceID)
reason := classifyLeaseFailure(currentTask, client)
response.Error(c, response.Conflict(40901, "lease_from_parked_conflict", reason))
return
}
response.OK(c, gin.H{
"task": task,
"attempt_id": attemptID,
"lease_token": token,
"lease_expires_at": task.LeaseExpiresAt.Unix(),
})
response.OK(c, gin.H{"items": tasks})
}
// classifyLeaseFailure 返回 spec §5.3 的 5 类错误码之一:
// "already_in_progress" | "aborted" | "terminal" | "not_target_client" | "not_parked"
func classifyLeaseFailure(t generated.DesktopTask, c middleware.Client) string {
if t.TargetClientID != c.ID { return "not_target_client" }
if t.Status == "aborted" { return "aborted" }
if t.Status == "succeeded" || t.Status == "failed" { return "terminal" }
if t.Status == "in_progress" && t.ActiveAttemptID != nil { return "already_in_progress" }
return "not_parked"
func (h *DesktopTaskHandler) RetryPublish(c *gin.Context) {
client := middleware.MustClientFromCtx(c.Request.Context())
taskID := uuid.MustParse(c.Param("id"))
result, err := h.app.PublishJobService.RetryByDesktopTask(c.Request.Context(), client, taskID)
if err != nil { /* ... */ return }
h.app.EventBus.PublishTaskAvailable(result.TargetClientID, result.TaskID)
response.OK(c, result)
}
```
@@ -852,8 +819,8 @@ type Presence interface {
在每个 server 实例启动 worker,订阅 Redis keyspace notification `expired` / `set`,检测 `presence:<client>` 的 set 事件。收到时:
```go
parkedTasks, _ := repo.ListParkedTasksByClient(ctx, clientID)
for _, t := range parkedTasks {
queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID)
for _, t := range queuedTasks {
bus.PublishTaskAvailable(clientID, t.ID)
}
```
@@ -865,7 +832,7 @@ git add server/internal/shared/eventbus/
git commit -m "feat(eventbus): RabbitMQ fanout + Redis presence + offline→online task reclaim"
```
### Task A.1.5:服务端 Task 集成测试(关键:lease CAS / parked / cross-client
### Task A.1.5:服务端 Task 集成测试(关键:lease CAS / publish retry / cross-client
**Files:**
- Create: `server/internal/tenant/transport/desktop_task_integration_test.go`
@@ -873,15 +840,13 @@ git commit -m "feat(eventbus): RabbitMQ fanout + Redis presence + offline→onli
- [ ] **Step 1:核心测试用例**
```go
func TestLeaseFromParked_RejectsNonTargetClient(t *testing.T) { /* ... */ }
func TestLeaseFromParked_IdempotentOnDoubleCall(t *testing.T) { /* ... */ }
func TestLeaseFromParked_OnAbortedTaskReturns409Aborted(t *testing.T) { /* ... */ }
func TestLeaseFromParked_OnTerminalReturns409Terminal(t *testing.T) { /* ... */ }
func TestParkedTaskNotClaimedByOtherClient(t *testing.T) { /* ... */ }
func TestCrashedTargetClient_ParkedTaskStaysOrphan(t *testing.T) { /* ... */ }
func TestListPublishTasks_ReturnsQueueBeforeHistory(t *testing.T) { /* ... */ }
func TestRetryPublish_RejectsNonTargetClient(t *testing.T) { /* ... */ }
func TestRetryPublish_CreatesNewQueuedTask(t *testing.T) { /* ... */ }
func TestQueuedTaskNotClaimedByOtherClient(t *testing.T) { /* ... */ }
func TestCrashedTargetClient_QueuedTaskIsRepublishedOnReconnect(t *testing.T) { /* ... */ }
func TestReconcileRetry_MovesTaskBackToQueued(t *testing.T) { /* ... */ }
func TestReconcileSucceeded_MarksTaskSucceeded(t *testing.T) { /* ... */ }
func TestWebCancelParkedTask(t *testing.T) { /* ... */ }
func TestWebCancelQueuedTask(t *testing.T) { /* ... */ }
func TestPublishJobRejectsContentRefFromAnotherWorkspace(t *testing.T) { /* 422 */ }
```
@@ -1039,11 +1004,10 @@ git commit -m "chore(desktop-client): Electron + electron-vite scaffold"
### Task A.2.4: `lease-manager.ts`(关键)
1. `LeaseManager.acquire(taskId)``POST /tasks/lease`
2. `LeaseManager.acquireFromParked(taskId)``POST /tasks/{id}/lease?from_parked=true`;处理 409 的 5 类错误
3. `LeaseManager.extend(lease_token)` → 每 60s 自动调
4. `LeaseManager.park(reason)` `POST /tasks/{id}/park`,释放 local token
5. `LeaseManager.complete(result)``POST /tasks/{id}/result`
6. 单元测试:402 抢占幂等、断线重连带同一 attempt 的 409 处理、parked 期间不续租
2. `LeaseManager.extend(lease_token)` → 每 60s 自动调
3. `LeaseManager.complete(result)``POST /tasks/{id}/result`
4. `LeaseManager.cancel(taskId)` / `clearLocalState()` → 终态或放弃时清本地 lease
5. 单元测试:409 抢占幂等、断线重连带同一 attempt 的 409 处理、终态后不再续租
### Task A.2.5: `rate-limiter.ts`
@@ -1063,7 +1027,7 @@ git commit -m "chore(desktop-client): Electron + electron-vite scaffold"
### Task A.2.7: `crash-recovery.ts`
1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status IN ('in_progress', 'waiting_user')`
1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress'`
2. 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN
3. 上报服务端
4. 单元测试:publish submitting 崩溃 → UNKNOWNmonitor 崩溃 → 重跑无副作用
@@ -1389,10 +1353,10 @@ export function useSseSubscription<T>(url: string) {
**模块清单:**
1. `probe` + `keepAlive`
2. `publish(ctx, task)` 状态机:filling → readyToReview (manual) → submitting → done
3. manual 模式:`LeaseManager.park('waiting_user')` 释放 lease;用户点"发布"后 `acquireFromParked()`
4. 处理 5 类 409 错误
5. 集成测试:manual parked → restart client → acquireFromParked → complete
2. `publish(ctx, task)` 状态机:filling → submitting → done/failed/unknown
3. 接入 rate limiter,离线积压任务按账号节流逐篇外发,避免过快触发平台风控
4. 成功/失败/unknown/aborted 任务进入 desktop publish management,可手动再次发送
5. 集成测试:连续 queued 任务按节流顺序出队;失败任务可以通过 `POST /api/desktop/publish-tasks/{id}/retry` 重新生成 queued task
---
@@ -1408,10 +1372,23 @@ export function useSseSubscription<T>(url: string) {
- [ ] **Step 3Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选**
- [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义**
- [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:<id>` SSE**
- [ ] **Step 6manual 任务展示"前往桌面端" deep link `georankly://tasks/<id>/review?nonce=<jwt>`**
- [ ] **Step 6进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link**
- [ ] **Step 7E2E 测试(Playwright admin-web**
- [ ] **Step 8commit**
### Task A.6.2desktop-client 发布管理页
**Files:**
- Modify: `apps/desktop-client/src/renderer/components/DesktopShell.vue`
- Create: `apps/desktop-client/src/renderer/views/PublishManagementView.vue`
- Modify: `apps/desktop-client/src/main/transport/api-client.ts`
- [ ] **Step 1:侧栏新增“发布管理”入口**
- [ ] **Step 2:调用 `GET /api/desktop/publish-tasks` 展示待发布队列 + 历史发送结果**
- [ ] **Step 3:历史列表覆盖 succeeded / failed / unknown / aborted**
- [ ] **Step 4:对任一历史 publish task 提供“再次发送”按钮,调用 `POST /api/desktop/publish-tasks/{id}/retry`**
- [ ] **Step 5:E2E 测试离线积压多篇任务逐条发送与手动补发路径**
---
## Phase A.7 · 测试基建 + Mac 公证(1 周)