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:
@@ -1,8 +1,10 @@
|
||||
# Plan A · 桌面客户端骨架穿透 Implementation Plan
|
||||
|
||||
> **2026-04-20 当前实现口径**:desktop 侧二次人工审核与 `manual / waiting_user / parked / from_parked` 链路已废弃。SaaS 完成人工审核后才创建 publish job;desktop 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 基座 | 块 A:Main 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter / parked 恢复协议 + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
|
||||
| A.2 · Desktop runtime 基座 | 块 A:Main 进程 / 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 + F:fake 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 4:sqlc 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.3:Handler 层实现所有 `/api/desktop/*` 和 `/api/tenant/*` 白名单路由
|
||||
@@ -694,50 +683,28 @@ func (h *PublishJobHandler) Create(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:LeaseFromParked 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 崩溃 → UNKNOWN;monitor 崩溃 → 重跑无副作用
|
||||
@@ -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 3:Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选**
|
||||
- [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义**
|
||||
- [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:<id>` SSE**
|
||||
- [ ] **Step 6:manual 任务展示"前往桌面端" deep link `georankly://tasks/<id>/review?nonce=<jwt>`**
|
||||
- [ ] **Step 6:进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link**
|
||||
- [ ] **Step 7:E2E 测试(Playwright admin-web)**
|
||||
- [ ] **Step 8:commit**
|
||||
|
||||
### Task A.6.2:desktop-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 周)
|
||||
|
||||
Reference in New Issue
Block a user