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 周)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Electron 桌面客户端设计(全面替换 WXT 浏览器插件)
|
||||
|
||||
**状态**:Draft v2.5 · 待实施(历经 5 轮 Claude + Codex + Gemini 交叉审查后修订)
|
||||
**状态**:Draft v2.6 · 已按 2026-04-20 当前实现口径修订
|
||||
**作者**:@liangxu + Claude(codex/gemini 交叉审校)
|
||||
**日期**:2026-04-18
|
||||
**范围**:新增 `apps/desktop-client/`、`/api/desktop/*` 服务端路由、相关 DB schema;删除 `apps/browser-extension/` 与 `/api/plugin/*` 路由及相关代码。
|
||||
|
||||
> **v2.5 主要修订**(基于 v2.4 final verification Codex 审查的 5 个契约口子收尾):`reconcile status=retry` 语义写死(原 task 回 queued)+ Phase 1 下 `content_ref` 引用 tenant-native 实体的 workspace 边界规则(只允许 primary workspace) + WorkspaceScope 输入面一致性(删 `workspace_id=` query 示例)+ 补 `delete_requested_at` 返回字段 + 新增 `POST /api/tenant/tasks/{id}/cancel` 端点 + §15.1 Mermaid 与正文一致性修正。完整历史(v1 → v2.5)详见 §13。
|
||||
> **v2.6 当前实现口径**:平台侧二次人工审核设计已废弃。SaaS 完成人工审核后才创建 publish job;desktop client 只负责执行发布与回写结果。`manual / waiting_user / parked / lease?from_parked=true / review-window` 相关设计全部作废。桌面端新增“发布管理”视图,用于查看待发布队列、历史发送结果,并允许对成功/失败/unknown/aborted 的发布任务手动再次发送。文档中若在历史修订记录里仍出现 parked/manual,均以本段为准。
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +91,7 @@ apps/desktop-client/
|
||||
renderer/ # 极简 Vite 项目,仅消费 packages/ui-shared
|
||||
main.ts
|
||||
routes.ts
|
||||
views/ # 桌面端专属视图(账号管理/任务中心/事件中心/诊断)
|
||||
views/ # 桌面端专属视图(控制台/发布管理/账号管理/任务中心/诊断)
|
||||
```
|
||||
|
||||
### 3.3 关键复用(更务实的口径)
|
||||
@@ -298,13 +298,8 @@ dataReceived ? ? ? ? ?
|
||||
每个发布任务或每个账号带 `PublishMode`:
|
||||
|
||||
```ts
|
||||
type PublishMode =
|
||||
| { kind: 'manual'; requireUserReview: true }
|
||||
| { kind: 'auto' }
|
||||
|
||||
type PublishState =
|
||||
| 'filling' // adapter 正在往编辑器填内容
|
||||
| 'readyToReview' // manual 模式下等待用户点发布
|
||||
| 'submitting' // 已经点了发布按钮,等平台回应
|
||||
| 'done' // 平台明确返回成功
|
||||
| 'failed' // 明确失败(可重试)
|
||||
@@ -314,59 +309,12 @@ type PublishState =
|
||||
|
||||
**状态机与租约的绑定**(见 §6 协议):
|
||||
|
||||
- SaaS 中的文章审核在创建 publish job 之前完成;desktop client **不再承接二次人工审核**。
|
||||
- 进入 `filling / submitting` 时,适配器必须每 60 s 调 `lease-manager.extend(lease_token)` 续租(lease TTL 默认 10 min)。
|
||||
- 进入 `readyToReview` 时,**人工审核可能持续几分钟到几小时**,不能用 lease 续期表达。lease-manager **释放当前 lease**,把服务端任务切到 **`waiting_user` parked state**:
|
||||
- 任务归属仍绑定原 `target_account_id + target_client_id`,**不会被其它 client 抢走**。
|
||||
- 任务**不持有活动 lease**(`active_attempt_id = null`),server 不会因 lease 超时回滚状态。
|
||||
- 用户点"发布"后,client 走 `POST /tasks/{id}/lease?from_parked=true` 严格 CAS 重新领取(见下"parked 恢复协议")。
|
||||
- 用户跨重启/切 window 恢复:同样的路径,parked state 持久在 DB。
|
||||
- 服务端设置 "parked 超过 N 小时则自动 abort" 的 fallback(默认 **72 h**,v2.4 从 24 h 调大避免长假/周末误杀;可配置)。
|
||||
- 用户取消或 signal abort → `aborted`。
|
||||
- adapter 抛异常 → `failed`,带错误码。
|
||||
- 进程崩溃 → 启动时按 §7.5 分派为 `unknown`(仅限 publish 任务的 `submitting` 阶段)。
|
||||
|
||||
#### Parked 恢复协议(严格 CAS · v2.4 补齐)
|
||||
|
||||
`POST /api/desktop/tasks/{id}/lease?from_parked=true` 的服务端原子语义(单条 SQL 或 advisory lock):
|
||||
|
||||
```
|
||||
UPDATE desktop_tasks
|
||||
SET active_attempt_id = <new_uuid>,
|
||||
lease_token_hash = <sha256(new_token)>,
|
||||
lease_expires_at = now() + 10 min,
|
||||
status = 'in_progress',
|
||||
attempts = attempts + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'waiting_user'
|
||||
AND target_client_id = $actor_client_id -- 仅 target_client 可调
|
||||
AND active_attempt_id IS NULL
|
||||
RETURNING id, new_attempt_id, new_lease_token, lease_expires_at;
|
||||
```
|
||||
|
||||
- **0 行返回 = 409 Conflict**,客户端按下表处理:
|
||||
|
||||
| 错误情景 | 服务端返回 | 客户端动作 |
|
||||
|---|---|---|
|
||||
| 任务已被其它 attempt 抢先 resume(双击 / 断线重连重发)| 409 + `{reason: "already_in_progress", active_attempt_id}` | 若 attempt_id = self 的待领 → 直接用之前那份;否则忽略(避免双跑)|
|
||||
| 任务已 abort(超时/Web 端 cancel)| 409 + `{reason: "aborted"}` | UI 通知用户 "任务已取消";清理本地 state |
|
||||
| 任务已 succeeded / failed(其它路径抢结果)| 409 + `{reason: "terminal", status: ...}` | 当作结果回调丢失处理,拉一次状态同步 |
|
||||
| 调用方不是 `target_client_id`(换机 / 其它 client 误触发)| 403 + `{reason: "not_target_client"}` | **不**允许抢;UI 提示"请到原桌面端 `<device_name>` 继续" |
|
||||
| 任务不是 `waiting_user` 状态 | 409 + `{reason: "not_parked"}` | 走正常 lease 重新领取 |
|
||||
|
||||
- **重复请求(client 断线重发)**:同一个 client 发两次 `from_parked=true`,第一次成功,第二次命中"已被抢先"分支——但 `active_attempt_id` 的持有者就是自己,返回 200 幂等,带当前 `lease_token`。这要求 server 在检测到 `active_attempt_id != null` 时**把请求 client 和当前持有者对比**:相等且未超时 → 200 幂等返回当前 lease;不等 → 409。
|
||||
|
||||
#### target_client 离线/换机/revoke 时的 parked 恢复策略
|
||||
|
||||
| 情景 | 处理 |
|
||||
|---|---|
|
||||
| target_client **离线 >5 min**(正常关机 / 网络断)| parked 状态保持;Web 端 Modal 显示"等待桌面端 `<device_name>` 上线";**不 rebind**;client 上线后走 §6.3 的 offline→online 补领路径(scheduler 扫描该 client 的 parked 任务并推 `task_available`)触发前端"继续审核"引导。 |
|
||||
| target_client **被 revoke**(`POST /api/desktop/clients/revoke`)| server 级联把该 client 所有 `status IN (waiting_user, in_progress)` 任务置 `unknown`;发通知给 workspace owner 通过 `/api/tenant/tasks/{id}/reconcile` 对账(因为 cookie 已随 client 失效,不能无脑 rebind)。 |
|
||||
| target_client **被用户主动删除** | 同 revoke。 |
|
||||
| target_client **换机**(同 workspace 新 client)| 不自动 rebind(Cookie 是本地隔离的,新 client 上没有该 account 的登录态)。Web 端显示"原桌面端已下线,请在新桌面端重新登录此账号后手动重启任务"。 |
|
||||
| target_client **72h 超时**(parked 自动 abort) | server `status → aborted` + `error = {code: 'parked_timeout', since: ...}`;Web 端 reconcile 工作流处理(可"重新运行"或"忽略")。 |
|
||||
|
||||
**核心原则**:Parked 任务**从不自动迁移**到新 client(避免"我以为我取消了,它在另一台机器上还发了")。所有看起来像 rebind 的动作都必须是用户显式重新创建任务。
|
||||
- 需要再次发送的文章,不走 parked/resume,而是在桌面端“发布管理”里基于原 publish task 重新创建一次新的 publish job。
|
||||
|
||||
文件上传:`protocol.handle('file')` + CDP `Input.dispatchDragEvent` 或直接喂 `<input type=file>`,不经服务端中转。
|
||||
|
||||
@@ -463,7 +411,7 @@ v2.2 review 发现监控子系统存量耦合比插件本身深。Plan A/B 前
|
||||
| Projection | `monitoring_query_results` / `monitoring_callback_logs` | 按 installation_id 索引 | 按 `(workspace_id, client_id)` 重索引;旧数据因开发版本无需保留,直接 drop |
|
||||
| Dashboard | admin-web 监控结果页 | 按 tenant 汇总 | 改 workspace 汇总 |
|
||||
| Seed | `server/cmd/dev-seed/main.go` 等 | 生成 plugin_installation 示例 | 生成 desktop_client 示例 |
|
||||
| 定时清理 | (目前没有 tombstone / parked 清理)| — | 新建:parked > 24h 的任务 auto-abort,tombstone > 30 天的 account 清理 |
|
||||
| 定时清理 | (目前没有 tombstone / unknown / trace 清理)| — | 新建:unknown 任务对账提醒与归档策略、`desktop_task_traces` 超期清理、tombstone > 30 天的 account 清理 |
|
||||
|
||||
**行动**:Plan A 启动前用 0.5 周做一次 impact spike,核对以上每一行都有具体 owner、估时、依赖;**没有确认就不开工**。监控域存量改造是 Plan A 工期的隐藏成本。
|
||||
|
||||
@@ -526,10 +474,10 @@ POST /api/desktop/clients/metrics # 轻量非敏感聚合指标
|
||||
|
||||
# 事件 & 任务租约
|
||||
GET /api/desktop/events # SSE, client_token
|
||||
GET /api/desktop/publish-tasks # 当前 client 的发布队列 + 历史发送结果
|
||||
POST /api/desktop/publish-tasks/{id}/retry # 基于历史 publish task 重新创建一次 publish job
|
||||
POST /api/desktop/tasks/lease # {task_id?} 原子领取;kind=publish|monitor 过滤
|
||||
POST /api/desktop/tasks/{id}/extend # body: {lease_token},续租(短时占用用)
|
||||
POST /api/desktop/tasks/{id}/park # body: {lease_token, reason: 'waiting_user'|'captcha'} 释放 lease、状态 → waiting_user
|
||||
POST /api/desktop/tasks/{id}/lease?from_parked=true # 从 parked 重新领取(严格 CAS,仅 target_client 可调用,见 §5.3)
|
||||
POST /api/desktop/tasks/{id}/cancel # body: {lease_token}(或无 lease 时带 client_token 由 target_client 主动取消)
|
||||
POST /api/desktop/tasks/{id}/result # body: {lease_token, status, payload}
|
||||
POST /api/desktop/tasks/{id}/trace # 用户主动上传诊断包
|
||||
@@ -537,7 +485,7 @@ POST /api/desktop/tasks/{id}/trace # 用户主动上传诊断包
|
||||
# Web 端对账(UNKNOWN 任务 / 账号删除意图,见 §7.5 和 §6.8.6)
|
||||
POST /api/tenant/tasks/{id}/reconcile # body: {status: 'succeeded'|'failed'|'aborted'|'retry', external_url?, reason?} · 仅 Web 用户可调、需 WorkspaceScope
|
||||
# retry 语义:原 task status → queued + attempts + 1(不复制、不新建 job);与 §15.1 状态图一致
|
||||
POST /api/tenant/tasks/{id}/cancel # body: {reason?} · 对 parked(waiting_user)或 queued 任务主动取消 → status=aborted;WorkspaceScope + 审计
|
||||
POST /api/tenant/tasks/{id}/cancel # body: {reason?} · 对 queued 任务主动取消 → status=aborted;WorkspaceScope + 审计
|
||||
POST /api/tenant/accounts/{id}/request-delete # body: {} 或 ?undo=true · 写 delete_requested_at
|
||||
|
||||
# 账号元数据(不传 cookie)
|
||||
@@ -555,7 +503,7 @@ POST /api/tenant/publish-jobs
|
||||
# ...
|
||||
```
|
||||
|
||||
**幂等契约**:所有持有活动 lease 的写操作(`extend` / `cancel` / `result` / `park` / `trace`)**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。`POST /tasks/lease` 和 `POST /tasks/{id}/lease?from_parked=true` 本身是 lease 发放者,不需要 `lease_token`;但后者走**严格 CAS**(见 §5.3 parked 恢复协议)。客户端收到 409 = 租约已失效 / 已被抢占 / parked 任务已 abort,**停止动作 + 触发 resync + 提示人工对账**。
|
||||
**幂等契约**:所有持有活动 lease 的写操作(`extend` / `cancel` / `result` / `trace`)**必须携带 `lease_token`**;服务端校验 `lease_token` 与当前 `active_attempt_id` 绑定,否则返 409 Conflict。`POST /tasks/lease` 本身是 lease 发放者,不需要 `lease_token`。客户端收到 409 = 租约已失效 / 已被抢占 / 任务已取消,**停止动作 + 触发 resync + 提示人工对账**。
|
||||
|
||||
**中间件与 workspace 一等化要求**(**先决条件**,不是实施细节):
|
||||
|
||||
@@ -634,7 +582,7 @@ CREATE TABLE desktop_tasks (
|
||||
-- 业务
|
||||
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 | succeeded | failed | unknown | aborted
|
||||
dedup_key TEXT,
|
||||
-- 租约
|
||||
active_attempt_id UUID, -- null 表示无租约持有者
|
||||
@@ -648,7 +596,7 @@ CREATE TABLE desktop_tasks (
|
||||
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_target_client_status ON desktop_tasks (target_client_id, status) WHERE status IN ('queued', 'in_progress');
|
||||
CREATE INDEX idx_tasks_platform_status ON desktop_tasks (platform, status);
|
||||
|
||||
-- 发布 Job 聚合
|
||||
@@ -696,21 +644,17 @@ SSE push 'task_available' → 任意实例 → 对应 SSE 连接 → client
|
||||
Client POST /tasks/lease → DB atomic claim →
|
||||
返回 {task, attempt_id, lease_token, lease_expires_at=+10min}
|
||||
↓
|
||||
Client 执行(filling→submitting),每 60s POST /extend
|
||||
↓
|
||||
如果 manual 进入审核 → POST /park(lease_token, reason=waiting_user)
|
||||
→ server 释放 lease(active_attempt_id=null),状态置 waiting_user
|
||||
→ 任务仍绑定 target_client_id,不会被抢;不承担 lease 超时压力
|
||||
审核通过 → client POST /tasks/{id}/lease?from_parked=true 重新领取
|
||||
→ 获得新 attempt_id + 新 lease_token,进 submitting
|
||||
parked 超时(默认 24h)→ server 自动 abort,状态变 aborted
|
||||
Client 执行(publish: filling→submitting;monitor: querying),每 60s POST /extend
|
||||
↓
|
||||
POST /result(lease_token, status=succeeded|failed) → server 校验 lease_token 后存结果
|
||||
↓
|
||||
需要再次发送的文章 → desktop client “发布管理”调用 `POST /api/desktop/publish-tasks/{id}/retry`
|
||||
→ server 基于原 publish task 重新创建新的 publish job + queued task
|
||||
↓
|
||||
Web UI 可视化
|
||||
```
|
||||
|
||||
**超时处理**:`lease_expires_at` 到期后 scheduler 把 task 放回 `queued`,`active_attempt_id` 置空;若老 client 恢复后再 POST `/result(lease_token=过期值)`,返 409 → client 本地记为 `unknown` 提示人工。
|
||||
**超时处理**:`lease_expires_at` 到期后按 §7.5 的任务恢复策略处理;若老 client 恢复后再 POST `/result(lease_token=过期值)`,返 409 → client 本地记为 `unknown` 提示人工。发布任务的重新发送统一走桌面端“发布管理”,避免因重复点击造成重复发文。
|
||||
|
||||
### 6.8 admin-web 协同(发布 Modal + 媒体数据面板 + 离线降级)
|
||||
|
||||
@@ -739,8 +683,8 @@ Web UI 可视化
|
||||
- **按 tag 批量勾选**:顶部 tag 多选器(`tags` 来自 `platform_accounts.tags`,用户在"账号管理"里自定义,如"品牌号"/"矩阵号"/"测试号"),勾选 tag 后批量勾选所有带该 tag 的账号
|
||||
- 搜索框(按 displayName / platform_uid 匹配)
|
||||
- 全选 / 反选(按平台、按 tag、全局)
|
||||
- 选模式(manual / auto)、时间(立刻 / 定时)、素材策略(封面默认 / 指定)
|
||||
3. 提交:`POST /api/tenant/publish-jobs`,body 示例(**`content_ref` 多态**以适配现有 admin-web 的 KOL 模板/品牌素材/图文/视频等多种素材来源):
|
||||
- 时间(立刻 / 定时)、素材策略(封面默认 / 指定)
|
||||
3. 提交:`POST /api/tenant/publish-jobs`,body 示例(**`content_ref` 多态**以适配现有 admin-web 的 KOL 模板/品牌素材/图文/视频等多种素材来源)。SaaS 侧的人审在此之前已完成,因此 publish job 只描述执行信息,不再传 `manual/auto mode`:
|
||||
```json
|
||||
{
|
||||
"title": "2026 Q2 新品发布",
|
||||
@@ -749,9 +693,9 @@ Web UI 可视化
|
||||
"id": "draft_1234"
|
||||
},
|
||||
"accounts": [
|
||||
{"account_id": "acc-a1", "mode": "auto"},
|
||||
{"account_id": "acc-a2", "mode": "manual"},
|
||||
{"account_id": "acc-b5", "mode": "auto"}
|
||||
{"account_id": "acc-a1"},
|
||||
{"account_id": "acc-a2"},
|
||||
{"account_id": "acc-b5"}
|
||||
],
|
||||
"scheduled_at": null
|
||||
}
|
||||
@@ -760,9 +704,9 @@ Web UI 可视化
|
||||
|
||||
**Phase 1 workspace 边界规则**(窄 workspace 化策略下的硬约束):上述 tenant-native 实体(`article_draft` / `template_gen` / `video` / `brand_asset`)**只允许 actor 的 primary workspace 使用**。server 在 `POST /api/tenant/publish-jobs` 的校验阶段查 content_ref 关联实体的创建者 `primary_workspace_id`,**必须等于** `actor.workspace_id`(由 WorkspaceScope 派生),否则返 **422 Unprocessable Entity** + `{code: 'content_ref_not_in_primary_workspace'}`。这样既保证 Phase 1 用户体验(仅单 workspace 用户无感知)、又在 Phase 2 接入多 workspace 时有明确的升级路径(届时规则放宽为"同 tenant 即可"或加独立授权模型)。该规则必须**落到 `POST /publish-jobs` 校验测试**(Plan 0 退出 bar 的 cross-workspace 防穿透用例之一)。
|
||||
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` body `{"task_ids": [...]}`。
|
||||
4. Modal 切到"进度面板",订阅面向 Web 的 SSE `GET /api/tenant/events?topic=job:<job_id>`;接收每子任务状态变化(`queued → in_progress → succeeded/failed/unknown/aborted`)。
|
||||
5. 若目标账号所属桌面端离线,子任务保持 `queued`;对应 desktop client 上线后自动领取。运营也可以在 desktop client 的“发布管理”里查看当前待发布队列与历史发送结果。
|
||||
6. 全部终态后 Modal 汇总:每账号结果 URL / 错误码。若某条需要补发,统一在 desktop client “发布管理”中对原 publish task 点“再次发送”;服务端会保留原记录,并基于原 task 新建下一次 publish job。
|
||||
|
||||
#### 6.8.3 媒体数据面板(账号级粒度)
|
||||
|
||||
@@ -796,7 +740,6 @@ 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 # body: {"task_ids": ["..."]}(不用 query string)
|
||||
GET /api/tenant/monitoring-results # 支持 filter / range / 按 llm / 按账号
|
||||
GET /api/tenant/events # SSE,topic 参数仅作提示,实际 topic 服务端派生
|
||||
```
|
||||
@@ -955,7 +898,7 @@ user clicks deep link in admin-web
|
||||
|
||||
### 7.5 崩溃恢复(按 task.kind 分治)
|
||||
|
||||
启动时扫描 `desktop_tasks` 里 `target_client_id = self AND status IN (in_progress, waiting_user)` 的任务:
|
||||
启动时扫描 `desktop_tasks` 里 `target_client_id = self AND status IN (in_progress)` 的任务:
|
||||
|
||||
**Monitor 任务**(幂等只读,可安全重跑):
|
||||
|
||||
@@ -964,7 +907,7 @@ user clicks deep link in admin-web
|
||||
|
||||
**Publish 任务**(不可逆副作用,保守处理):
|
||||
|
||||
- **未进入 `submitting` 阶段**(即还在 `filling` 或 `waiting_user` parked):重新 ack → 把 state 重置到 `filling` 从头跑,因为还没点过"发布"按钮。
|
||||
- **未进入 `submitting` 阶段**(即还在 `filling`):重新 ack → 把 state 重置到 `filling` 从头跑,因为还没点过“发布”按钮。
|
||||
- **已进入 `submitting`**:**放弃任务,标 `unknown`**。避免重复发布。
|
||||
|
||||
**UNKNOWN 任务的 UI 对账工作流**(不只是"提示一下"就完事):
|
||||
@@ -1061,7 +1004,7 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un
|
||||
|
||||
### 9.4 集成 · 协议
|
||||
|
||||
客户端 + 真实 server + fake 平台:验证**任务分发 / 租约 (`lease` / `extend` / `park` / `lease?from_parked` / `cancel`) / 多实例广播 / 幂等 / 熔断 / 断线重连 / parked 任务在 target_client 下线/换机的恢复路径**。
|
||||
客户端 + 真实 server + fake 平台:验证**任务分发 / 租约 (`lease` / `extend` / `cancel` / `result`) / 多实例广播 / 幂等 / 熔断 / 断线重连 / queued 任务在 target_client 下线后重新补领 / desktop publish management 的列表与再次发送路径**。
|
||||
|
||||
### 9.5 人工冒烟 Playbook
|
||||
|
||||
@@ -1122,11 +1065,11 @@ GitHub Actions,runner 矩阵:`macos-14 (arm64)` + `macos-13 (x64)` 合并 un
|
||||
| 块 | 范围 | 估时 |
|
||||
|---|---|---|
|
||||
| **0. Workspace 底座一等化**(v2.3 新增 · Plan 0)| `workspaces` + `workspace_memberships` 表迁移;`Actor/Claims` 加 `workspace_id`;`WorkspaceScope` middleware;所有 repo query 注入 `workspace_id`;`cache_support.go` key 改 workspace 粒度;监控域存量改造清单(§6.1.1)完整迁 | **2–3 周(阻塞 Plan A)** |
|
||||
| **A. Desktop runtime 基座** | Main 进程、session-registry、view-pool、vault、lease-manager(含 parked state)、rate-limiter、circuit-breaker、crash-recovery、tracing(Alpha 版)、transport (SSE + pull + RabbitMQ presence) | 3–4 周 |
|
||||
| **A. Desktop runtime 基座** | Main 进程、session-registry、view-pool、vault、lease-manager、rate-limiter、circuit-breaker、crash-recovery、tracing(Alpha 版)、transport (SSE + pull + RabbitMQ presence) | 3–4 周 |
|
||||
| **B. LLM 监控适配器** | Doubao + Qwen + **DeepSeek(从零)** + **元宝(从零)** + **Kimi(从零)**;**前置 § 5.2 spike 矩阵 0.5 周** | 4.5–5.5 周 |
|
||||
| **C. 已有真实发布能力的平台迁移** | 约 6 家(头条 / 百家 / 搜狐 / 知乎 / 企鹅 / 简书)从 chrome.* 迁到 Electron 抽象 | 3 周 |
|
||||
| **D. Detect-only 平台从零实现 publish** | 7 家(微信公众号 / 掘金 / 懂车帝 / 什么值得买 / 网易 / B 站 / ZOL)| 5–7 周 |
|
||||
| **E. 服务端协议 + DB + sqlc 重生 + Service 层重构** | `/api/desktop/*`、租约(含 park/lease-from-parked)、presence、patch manifest、reconcile | 2.5 周 |
|
||||
| **E. 服务端协议 + DB + sqlc 重生 + Service 层重构** | `/api/desktop/*`、租约、发布管理(publish-tasks 列表/再次发送)、presence、patch manifest、reconcile | 2.5 周 |
|
||||
| **F. 打包 5 arch + Mac 公证 + autoUpdater + updater smoke** | — | 1.5 周 |
|
||||
| **G. Patch 通道** | G1(**最小闭环**:服务端签名 manifest + 客户端 Ed25519 验签 + DSL 解释器 + revoke + rollback)前移到 Plan B,1.5 周;G2(备用隔离执行通道 + 签名链全套 TUF 要素 + 密钥轮换)留在 Plan C,1 周 | 2.5 周(拆 1.5 + 1)|
|
||||
| **H. packages/ui-shared 抽取 + desktop renderer** | 抽 `MarkdownPreview / AccountCard / TagMultiSelect / HealthBadge / SseSubscription composable` 等;**PublishArticleModal 重构为账号级多选(原 accounts[0] 假设要彻底改)**;i18n、主题 | **2.5–3 周(v2.2 review 低估了 1–1.5 周)** |
|
||||
@@ -1214,8 +1157,8 @@ v2.2 review 发现 workspace 不是后端一等安全边界。但**当前代码
|
||||
|
||||
### Plan A · 桌面端骨架穿透(5–6 周,Alpha 上限)
|
||||
|
||||
- 块 A(Desktop runtime 基座,含 parked state 实现 + parked 恢复协议)
|
||||
- 块 E(服务端协议 + DB + `/tasks/{id}/reconcile` endpoint + `/tasks/{id}/lease?from_parked` 严格 CAS)
|
||||
- 块 A(Desktop runtime 基座 + crash-recovery + publish queue execution)
|
||||
- 块 E(服务端协议 + DB + `/tasks/{id}/reconcile` endpoint + `/api/desktop/publish-tasks` 列表/再次发送)
|
||||
- 块 H(拆两阶段,见下)
|
||||
- **H1 · 前期(Plan A 第 1–2 周)**:`packages/ui-shared` 骨架 + **design tokens**(CSS variables light/dark + Ant Design Theme Config)+ `useAccountSelection` composable(账号级多选逻辑)+ `HealthBadge / AccountCard / TagMultiSelect / MarkdownPreview` 组件迁入。
|
||||
- **H2 · 后期(Plan A 第 4–5 周)**:`PublishArticleModal` 重构为账号级多选(基于 H1 的 composable),接入新 `/api/tenant/publish-jobs` 展开语义,进度面板 SSE 订阅。
|
||||
@@ -1234,7 +1177,7 @@ v2.2 review 发现 workspace 不是后端一等安全边界。但**当前代码
|
||||
- `blocked`:完全不工作(页面改用 WebTransport / WebSocket / WebRTC 等 CDP 未覆盖路径)。进入 fallback 决策树:① DOM-only 降级(只从渲染后文本抽取,保留但标"degraded quality")② 移出本版承诺范围(产品决策,工期不赔)③ 等待 Chromium 版本升级。
|
||||
- **Gate**:spike 未出具三态结论前,块 B 的新 provider 接入**不开工**;spike 只对 Plan A 承诺的 Doubao 做完整验证即可解锁 Plan A,其余 4 家 LLM 在 Plan B 启动前各自跑一次。
|
||||
|
||||
**退出目标**:端到端跑通一条 Doubao 监控任务 + 一条头条发布任务(含 manual 模式 parked → resume);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务的 reconcile 工作流闭环。
|
||||
**退出目标**:端到端跑通一条 Doubao 监控任务 + 一条头条发布任务;desktop client 提供“发布管理”视图(待发布队列 + 历史结果 + 再次发送);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务的 reconcile 工作流闭环。
|
||||
|
||||
### Plan B · 广度铺开(12–14 周,Beta 上限)
|
||||
|
||||
@@ -1352,7 +1295,7 @@ v2.2 review 发现 workspace 不是后端一等安全边界。但**当前代码
|
||||
|
||||
## 15. 附录:架构图
|
||||
|
||||
### 15.1 Lease 状态机(inline · v2.4 在 Plan 0 前画完)
|
||||
### 15.1 任务状态机(inline · v2.6 当前口径)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
@@ -1360,12 +1303,9 @@ stateDiagram-v2
|
||||
queued --> in_progress: lease acquired (target_client_id match)
|
||||
queued --> aborted: POST /api/tenant/tasks/{id}/cancel (Web)
|
||||
in_progress --> in_progress: extend (每 60s)
|
||||
in_progress --> waiting_user: POST /park (manual readyToReview)
|
||||
waiting_user --> in_progress: POST /lease?from_parked=true (严格 CAS)
|
||||
in_progress --> succeeded: POST /result status=succeeded
|
||||
in_progress --> failed: POST /result status=failed
|
||||
in_progress --> aborted: POST /api/desktop/tasks/{id}/cancel (client)
|
||||
waiting_user --> aborted: 72h timeout / POST /api/tenant/tasks/{id}/cancel (Web)
|
||||
in_progress --> unknown: crash during submitting (publish only)
|
||||
unknown --> succeeded: POST /api/tenant/tasks/{id}/reconcile status=succeeded
|
||||
unknown --> failed: POST /reconcile status=failed
|
||||
@@ -1375,18 +1315,18 @@ stateDiagram-v2
|
||||
failed --> [*]
|
||||
aborted --> [*]
|
||||
|
||||
note right of waiting_user
|
||||
parked state:
|
||||
active_attempt_id = null
|
||||
仍绑 target_client_id
|
||||
不被抢走
|
||||
end note
|
||||
|
||||
note left of unknown
|
||||
仅 publish 任务
|
||||
仅 submitting 崩溃
|
||||
monitor 任务直接重跑
|
||||
end note
|
||||
|
||||
note right of succeeded
|
||||
Desktop Publish Management
|
||||
可对 succeeded/failed/unknown/aborted
|
||||
手动再次发送
|
||||
服务端会新建下一条 queued publish task
|
||||
end note
|
||||
```
|
||||
|
||||
### 15.2 Account CAS + Tombstone Resync(inline · v2.4 在 Plan 0 前画完)
|
||||
@@ -1446,7 +1386,7 @@ sequenceDiagram
|
||||
- **Client Token**:一台 Electron 安装的后台身份,与 User JWT 并列;解耦 client_version,支持 rotate/revoke。
|
||||
- **Partition**:Electron `session.fromPartition('persist:<id>')`,每个账号一个隔离的 cookie/storage 容器。
|
||||
- **Lease**:任务租约。领取任务时服务端返回 `attempt_id + lease_token + lease_expires_at`;后续所有写操作必须带 `lease_token` 验证。
|
||||
- **Waiting-user 状态(parked)**:manual 发布模式下,adapter 暂停等用户审核时,服务端**释放当前 lease**(`active_attempt_id = null`)、状态置为 `waiting_user`;任务仍绑 `target_client_id` 不被抢走;用户回来时通过 `POST /tasks/{id}/lease?from_parked=true` 严格 CAS 重新领取(见 §5.3)。72h 超时自动 abort。
|
||||
- **发布管理(Publish Management)**:desktop client 里的发布运维页,展示当前 client 的发布队列与历史发送结果。支持查看 `queued / in_progress / succeeded / failed / unknown / aborted` 任务,并基于任一历史 publish task 重新创建一次新的发送任务。
|
||||
- **Platform UID**:平台侧真实账号 ID(如微信 fakeid、B 站 mid、知乎 hash_id);`(platform, platform_uid)` 在 DB 层唯一,落实硬约束 8。
|
||||
- **DSL(Patch 默认通道)**:一套受限的声明式解析语言,只能做字符串/JSON 解析与模式匹配,无 JS 执行能力。
|
||||
- **Presence registry**:多实例服务端共享的 `client_id → (instance_id, conn_id)` 注册表,用 Redis TTL 30 s 维护;用于跨实例路由 SSE 通知。
|
||||
|
||||
Reference in New Issue
Block a user