2026-04-18 23:47:13 +08:00
# Plan A · 桌面客户端骨架穿透 Implementation Plan
2026-04-20 09:52:48 +08:00
> **2026-04-20 当前实现口径**: desktop 侧二次人工审核与 `manual / waiting_user / parked / from_parked` 链路已废弃。SaaS 完成人工审核后才创建 publish job; desktop client 只负责执行发布,并提供“发布管理”查看待发布队列、历史结果与手动再次发送。
2026-04-18 23:47:13 +08:00
> **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.
2026-04-20 09:52:48 +08:00
**Goal: ** 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务;desktop client 提供“发布管理”(待发布队列 + 历史结果 + 再次发送);Mac 公证版可安装;lease 协议在 fake 多实例 + 混沌测试下无脏写;UNKNOWN 任务 reconcile 工作流闭环。
2026-04-18 23:47:13 +08:00
**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 复用同一套共享组件。
**Tech Stack: ** Electron 41.2.0 (Chromium 146) + WebContentsView + webContents.debugger · Vue 3 + Vite + Ant Design Vue 4.x · pino · electron-builder · pnpm workspace · Go (gin/sqlc) · PostgreSQL · Redis · RabbitMQ · Playwright (_electron.launch)
**Prerequisite: ** Plan 0 必须完成并打 `plan-0-complete` tag 后才能开工。
**Spec reference: ** `docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md` §12 Plan A
---
## Plan A 分 Phase 总览
| Phase | 范围 | 建议工期 | 依赖 | 可并行 |
|---|---|---|---|---|
| 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 并行 |
2026-04-20 09:52:48 +08:00
| A.2 · Desktop runtime 基座 | 块 A: Main 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
2026-04-18 23:47:13 +08:00
| 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 | — |
2026-04-20 09:52:48 +08:00
| 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 | — |
2026-04-18 23:47:13 +08:00
| 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 | — |
**合计 5– 6 工程周 ** (如果团队 ≥ 3 人并行 A.1+A.2+A.3)。
---
## Phase A.0 · §5.2 CDP Spike( 0.5 周 · 必须先做)
**Owner: ** runtime owner(负责 Electron/CDP 基建) + Doubao adapter owner 联签。
**产物: ** `docs/superpowers/specs/electron-chromium-cdp-matrix.md` + 抓包/日志附件。
**Exit: ** 三态结论(supported / degraded / blocked)对 Doubao 的实测完结。
### Task A.0.1:建 Spike 工作区
**Files: **
- Create: `apps/desktop-client/` pnpm workspace 初始骨架
- Create: `tools/spike-cdp/` 独立 Electron 最小脚本
- [ ] **Step 1:初始化 spike 工作区 **
``` bash
mkdir -p tools/spike-cdp && cd tools/spike-cdp
pnpm init
pnpm add -D electron@41.2.0 typescript @types/node
```
- [ ] **Step 2:写最小 Electron entry **
`tools/spike-cdp/main.ts` :
``` ts
import { app , BrowserWindow , WebContentsView , BaseWindow } from 'electron'
app . whenReady ( ) . then ( async ( ) = > {
const base = new BaseWindow ( { width : 1200 , height : 900 } )
const view = new WebContentsView ( {
webPreferences : { contextIsolation : true , sandbox : true }
} )
base . contentView . addChildView ( view )
view . setBounds ( { x : 0 , y : 0 , width : 1200 , height : 900 } )
const dbg = view . webContents . debugger
dbg . attach ( '1.3' )
// 实测矩阵 A: EventSource → Network.eventSourceMessageReceived
await dbg . sendCommand ( 'Network.enable' )
dbg . on ( 'message' , ( _e , method , params ) = > {
if ( method === 'Network.eventSourceMessageReceived' ) {
console . log ( '[ESM]' , JSON . stringify ( params ) . slice ( 0 , 200 ) )
}
} )
// 实测矩阵 B: fetch-stream → Fetch + takeResponseBodyAsStream
await dbg . sendCommand ( 'Fetch.enable' , { patterns : [ { urlPattern : '*doubao*' } ] } )
dbg . on ( 'message' , async ( _e , method , params : any ) = > {
if ( method === 'Fetch.requestPaused' ) {
console . log ( '[FRP]' , params . request . url )
await dbg . sendCommand ( 'Fetch.continueResponse' , { requestId : params.requestId } )
}
} )
await view . webContents . loadURL ( 'https://www.doubao.com/chat/' )
} )
```
- [ ] **Step 3: commit spike scaffold **
``` bash
git add tools/spike-cdp/
git commit -m "chore(spike): minimal Electron CDP spike harness"
```
### Task A.0.2:对 Doubao 做三态 spike
- [ ] **Step 1:登录 Doubao 账号(手工) **
``` bash
cd tools/spike-cdp && pnpm exec electron main.ts
```
手动扫码登录 Doubao。
- [ ] **Step 2:问一个测试问题 **
输入「你好」按回车,观察 console 日志哪类事件触发:
- 若出现 `[ESM]` 行:属 EventSource 路径 → transport = `EventSource`
- 若出现 `[FRP]` 行 → 抓到 fetch/chunked 请求,继续用 `Fetch.takeResponseBodyAsStream + IO.read` 读 body
- [ ] **Step 3:抓取响应 body **
依 Step 2 观察结果:
Path A( EventSource):记录 `params.data` 字段。
Path B( Fetch):
``` ts
if ( method === 'Fetch.requestPaused' ) {
const { stream } = await dbg . sendCommand ( 'Fetch.takeResponseBodyAsStream' , { requestId : params.requestId } )
let out = ''
while ( true ) {
const r = await dbg . sendCommand ( 'IO.read' , { handle : stream , size : 8192 } ) as any
out += r . data
if ( r . eof ) break
}
console . log ( '[BODY]' , out . slice ( 0 , 500 ) )
}
```
- [ ] **Step 4:记录三态结论 **
到 `docs/superpowers/specs/electron-chromium-cdp-matrix.md` 写首个条目:
``` markdown
# Electron × Chromium × CDP 兼容矩阵
| Provider | Transport | Electron 41.2.0 / Chromium 146 | 结论 | Workaround / Notes |
|---|---|---|---|---|
| Doubao | <实测 transport> | <supported/degraded/blocked> | <描述> | <抓包附件 link> |
```
- [ ] **Step 5:附上 3 张 DevTools 截图 + 1 段原始日志 **
放到 `docs/superpowers/specs/diagrams/spike-doubao-2026-04.png` 等。
- [ ] **Step 6: Go / No-Go 决策 **
- 若 `supported` → A.4 可开工,parser 按实测 schema 写。
- 若 `degraded` → 记 workaround 条目,A.4 按 workaround 实现。
- 若 `blocked` (所有 CDP 路径都拿不到 body)→ 升级到 **fallback 决策 ** ( DOM-only 降级 或 移出 Plan A 范围)。此时**停**继续,回产品线做范围决策。
- [ ] **Step 7: commit spike 结果 **
``` bash
git add docs/superpowers/specs/electron-chromium-cdp-matrix.md docs/superpowers/specs/diagrams/spike-*
git commit -m "spike(cdp): Doubao transport matrix (2026-04-18 Electron 41.2.0)"
```
---
## Phase A.1 · 服务端协议 + DB(块 E · 1.5 周)
本 Phase 补齐 spec §6 的所有新 schema + 路由 + handler。**与 A.2 并行**。
### Task A.1.1: DB schema 迁移
**Files: **
- Create: `server/migrations/20260421100000_create_desktop_tables.up.sql`
- Create: `server/migrations/20260421100000_create_desktop_tables.down.sql`
- [ ] **Step 1: up migration(按 spec §6.6 完整 schema) **
``` sql
-- desktop_clients
CREATE TABLE desktop_clients (
id UUID PRIMARY KEY ,
tenant_id BIGINT NOT NULL REFERENCES tenants ( id ) ,
workspace_id BIGINT NOT NULL REFERENCES workspaces ( id ) ,
user_id BIGINT NOT NULL REFERENCES users ( id ) ,
token_hash BYTEA NOT NULL ,
device_name TEXT ,
os TEXT ,
cpu_arch TEXT ,
client_version TEXT ,
channel TEXT ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now ( ) ,
last_seen_at TIMESTAMPTZ ,
last_rotated_at TIMESTAMPTZ ,
revoked_at TIMESTAMPTZ
) ;
CREATE INDEX idx_desktop_clients_workspace ON desktop_clients ( workspace_id ) ;
-- 重建 platform_accounts 为新 schema( drop 旧 + create 新,因为是开发版本无生产数据)
DROP TABLE IF EXISTS platform_accounts CASCADE ;
CREATE TABLE platform_accounts (
id UUID PRIMARY KEY ,
tenant_id BIGINT NOT NULL REFERENCES tenants ( id ) ,
workspace_id BIGINT NOT NULL REFERENCES workspaces ( id ) ,
client_id UUID NOT NULL REFERENCES desktop_clients ( id ) ,
platform TEXT NOT NULL ,
platform_uid TEXT NOT NULL ,
account_fingerprint TEXT ,
display_name TEXT NOT NULL ,
health TEXT NOT NULL ,
verified_at TIMESTAMPTZ ,
tags JSONB ,
sync_version BIGINT NOT NULL DEFAULT 1 ,
deleted_at TIMESTAMPTZ ,
delete_requested_at TIMESTAMPTZ ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now ( ) ,
last_seen_at TIMESTAMPTZ ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now ( )
) ;
CREATE UNIQUE INDEX uniq_platform_accounts_platform_uid
ON platform_accounts ( 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 ;
-- desktop_tasks
CREATE TABLE desktop_tasks (
id UUID PRIMARY KEY ,
job_id UUID NOT NULL ,
tenant_id BIGINT NOT NULL REFERENCES tenants ( id ) ,
workspace_id BIGINT NOT NULL REFERENCES workspaces ( 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 ,
payload JSONB NOT NULL ,
status TEXT NOT NULL ,
dedup_key TEXT ,
active_attempt_id UUID ,
lease_token_hash BYTEA ,
lease_expires_at TIMESTAMPTZ ,
attempts INT NOT NULL DEFAULT 0 ,
result JSONB ,
error JSONB ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now ( ) ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now ( )
) ;
CREATE INDEX idx_tasks_job ON desktop_tasks ( job_id ) ;
CREATE INDEX idx_tasks_target_client_status
ON desktop_tasks ( target_client_id , status )
2026-04-20 09:52:48 +08:00
WHERE status IN ( ' queued ' , ' in_progress ' ) ;
2026-04-18 23:47:13 +08:00
CREATE INDEX idx_tasks_workspace ON desktop_tasks ( workspace_id ) ;
-- desktop_publish_jobs
CREATE TABLE desktop_publish_jobs (
id UUID PRIMARY KEY ,
tenant_id BIGINT NOT NULL REFERENCES tenants ( id ) ,
workspace_id BIGINT NOT NULL REFERENCES workspaces ( id ) ,
created_by_user_id BIGINT NOT NULL REFERENCES users ( id ) ,
title TEXT NOT NULL ,
content_ref JSONB NOT NULL ,
scheduled_at TIMESTAMPTZ ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now ( ) ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now ( )
) ;
-- desktop_task_attempts (审计)
CREATE TABLE desktop_task_attempts (
id UUID PRIMARY KEY ,
task_id UUID NOT NULL REFERENCES desktop_tasks ( id ) ,
client_id UUID NOT NULL REFERENCES desktop_clients ( id ) ,
started_at TIMESTAMPTZ NOT NULL DEFAULT now ( ) ,
ended_at TIMESTAMPTZ ,
final_status TEXT ,
error JSONB
) ;
-- desktop_task_traces
CREATE TABLE desktop_task_traces (
task_id UUID NOT NULL REFERENCES desktop_tasks ( id ) ,
attempt_id UUID ,
size BIGINT ,
uploaded_at TIMESTAMPTZ ,
object_key TEXT
) ;
-- 删除旧 plugin_installations(硬切换,spec §10.2)
DROP TABLE IF EXISTS plugin_installations ;
```
- [ ] **Step 2: down migration **
``` sql
-- 还原:drop 新表、重建旧 plugin_installations(用于开发端万一回滚)
DROP TABLE IF EXISTS desktop_task_traces ;
DROP TABLE IF EXISTS desktop_task_attempts ;
DROP TABLE IF EXISTS desktop_publish_jobs ;
DROP TABLE IF EXISTS desktop_tasks ;
DROP TABLE IF EXISTS platform_accounts ;
DROP TABLE IF EXISTS desktop_clients ;
-- plugin_installations 重建略(开发端无生产数据可不回)
```
- [ ] **Step 3:跑迁移 **
``` bash
cd server && make migrate-up
```
- [ ] **Step 4: commit **
``` bash
git add server/migrations/20260421100000_create_desktop_tables.*
git commit -m "feat(db): create desktop_clients/platform_accounts/desktop_tasks/publish_jobs/attempts/traces"
```
### Task A.1.2: sqlc queries + generated code
**Files: **
- Create: `server/internal/tenant/repository/queries/desktop_client.sql`
- Create: `server/internal/tenant/repository/queries/platform_account.sql`
- Create: `server/internal/tenant/repository/queries/desktop_task.sql`
- Create: `server/internal/tenant/repository/queries/desktop_publish_job.sql`
- [ ] **Step 1:写 desktop_client.sql **
以 spec §6.4 路由反推必须的查询:
``` sql
-- name: RegisterDesktopClient :one
INSERT INTO desktop_clients ( id , tenant_id , workspace_id , user_id , token_hash ,
device_name , os , cpu_arch , client_version , channel )
VALUES ( sqlc . arg ( id ) , sqlc . arg ( tenant_id ) , sqlc . arg ( workspace_id ) , sqlc . arg ( user_id ) ,
sqlc . arg ( token_hash ) , sqlc . arg ( device_name ) , sqlc . arg ( os ) , sqlc . arg ( cpu_arch ) ,
sqlc . arg ( client_version ) , sqlc . arg ( channel ) )
RETURNING * ;
-- name: GetDesktopClientByTokenHash :one
SELECT * FROM desktop_clients
WHERE token_hash = sqlc . arg ( token_hash ) AND revoked_at IS NULL ;
-- name: UpdateClientLastSeen :exec
UPDATE desktop_clients SET last_seen_at = now ( )
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id ) ;
-- name: RotateClientToken :one
UPDATE desktop_clients
SET token_hash = sqlc . arg ( token_hash ) , last_rotated_at = now ( )
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id )
RETURNING * ;
-- name: RevokeClient :exec
UPDATE desktop_clients SET revoked_at = now ( )
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id ) ;
-- name: ListClientsByWorkspace :many
SELECT * FROM desktop_clients
WHERE workspace_id = sqlc . arg ( workspace_id ) AND revoked_at IS NULL
ORDER BY last_seen_at DESC NULLS LAST ;
```
- [ ] **Step 2:写 platform_account.sql **
``` sql
-- name: UpsertPlatformAccount :one
INSERT INTO platform_accounts ( id , tenant_id , workspace_id , client_id , platform ,
platform_uid , account_fingerprint , display_name , health , tags )
VALUES ( sqlc . arg ( id ) , sqlc . arg ( tenant_id ) , sqlc . arg ( workspace_id ) , sqlc . arg ( client_id ) ,
sqlc . arg ( platform ) , sqlc . arg ( platform_uid ) , sqlc . arg ( account_fingerprint ) ,
sqlc . arg ( display_name ) , ' live ' , sqlc . arg ( tags ) )
ON CONFLICT ( platform , platform_uid ) WHERE deleted_at IS NULL
DO UPDATE SET
display_name = EXCLUDED . display_name ,
tags = EXCLUDED . tags ,
sync_version = platform_accounts . sync_version + 1 ,
updated_at = now ( )
WHERE platform_accounts . workspace_id = EXCLUDED . workspace_id
RETURNING * ;
-- name: GetPlatformAccount :one
SELECT * FROM platform_accounts
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id ) AND deleted_at IS NULL ;
-- name: ListAccountsByWorkspace :many
SELECT * FROM platform_accounts
WHERE workspace_id = sqlc . arg ( workspace_id ) AND deleted_at IS NULL
AND ( sqlc . narg ( kind ) : : text IS NULL OR sqlc . narg ( kind ) : : text = ' any '
OR ( sqlc . narg ( kind ) = ' publish ' AND platform NOT IN ( ' doubao ' , ' qwen ' , ' deepseek ' , ' hunyuan ' , ' kimi ' ) )
OR ( sqlc . narg ( kind ) = ' monitor ' AND platform IN ( ' doubao ' , ' qwen ' , ' deepseek ' , ' hunyuan ' , ' kimi ' ) ) )
ORDER BY platform , display_name ;
-- name: PatchPlatformAccount :one
UPDATE platform_accounts
SET display_name = COALESCE ( sqlc . narg ( display_name ) , display_name ) ,
health = COALESCE ( sqlc . narg ( health ) , health ) ,
tags = COALESCE ( sqlc . narg ( tags ) , tags ) ,
sync_version = sync_version + 1 ,
updated_at = now ( )
WHERE id = sqlc . arg ( id )
AND workspace_id = sqlc . arg ( workspace_id )
AND sync_version = sqlc . arg ( if_sync_version )
AND deleted_at IS NULL
RETURNING * ;
-- name: TombstonePlatformAccount :one
UPDATE platform_accounts
SET deleted_at = now ( ) , sync_version = sync_version + 1 , updated_at = now ( )
WHERE id = sqlc . arg ( id )
AND workspace_id = sqlc . arg ( workspace_id )
AND sync_version = sqlc . arg ( if_sync_version )
AND deleted_at IS NULL
RETURNING * ;
-- name: MarkDeleteRequested :one
UPDATE platform_accounts
SET delete_requested_at = now ( ) , sync_version = sync_version + 1 , updated_at = now ( )
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id ) AND deleted_at IS NULL
RETURNING * ;
-- name: ClearDeleteRequested :exec
UPDATE platform_accounts
SET delete_requested_at = NULL , sync_version = sync_version + 1 , updated_at = now ( )
WHERE id = sqlc . arg ( id ) AND workspace_id = sqlc . arg ( workspace_id ) ;
```
- [ ] **Step 3:写 desktop_task.sql(租约协议核心) **
``` sql
-- name: CreatePublishJobTasks :many
WITH inserted AS (
INSERT INTO desktop_publish_jobs ( id , tenant_id , workspace_id , created_by_user_id , title , content_ref , scheduled_at )
VALUES ( sqlc . arg ( job_id ) , sqlc . arg ( tenant_id ) , sqlc . arg ( workspace_id ) , sqlc . arg ( user_id ) ,
sqlc . arg ( title ) , sqlc . arg ( content_ref ) , sqlc . narg ( scheduled_at ) )
RETURNING id
)
INSERT INTO desktop_tasks ( id , job_id , tenant_id , workspace_id , target_account_id ,
target_client_id , platform , kind , payload , status )
SELECT gen_random_uuid ( ) , ( SELECT id FROM inserted ) , sqlc . arg ( tenant_id ) , sqlc . arg ( workspace_id ) ,
a . id , a . client_id , a . platform , ' publish ' , sqlc . arg ( task_payload ) , ' queued '
FROM platform_accounts a
WHERE a . id = ANY ( sqlc . arg ( account_ids ) : : uuid [ ] )
AND a . workspace_id = sqlc . arg ( workspace_id )
RETURNING * ;
-- name: LeaseTask :one
-- 正常从 queued 领取;只有 target_client_id 匹配才生效
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 = ' queued '
RETURNING * ;
-- name: ExtendLease :one
UPDATE desktop_tasks
SET lease_expires_at = now ( ) + interval ' 10 minutes ' , updated_at = now ( )
WHERE id = sqlc . arg ( task_id )
AND lease_token_hash = sqlc . arg ( lease_token_hash )
AND status = ' in_progress '
RETURNING * ;
-- name: CompleteTask :one
UPDATE desktop_tasks
SET status = sqlc . arg ( status ) , result = sqlc . arg ( result ) , error = sqlc . narg ( error ) ,
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: ReconcileTask :one
-- Web reconcile: status 可为 succeeded/failed/aborted/retry
-- retry = 原 task 回 queued + attempts+1
UPDATE desktop_tasks
SET status = CASE WHEN sqlc . arg ( status ) : : text = ' retry ' THEN ' queued ' ELSE sqlc . arg ( status ) : : text END ,
attempts = attempts + CASE WHEN sqlc . arg ( status ) : : text = ' retry ' THEN 1 ELSE 0 END ,
result = sqlc . narg ( result ) ,
error = sqlc . narg ( error ) ,
active_attempt_id = NULL ,
lease_token_hash = NULL ,
lease_expires_at = NULL ,
updated_at = now ( )
WHERE id = sqlc . arg ( task_id )
AND workspace_id = sqlc . arg ( workspace_id )
AND status = ' unknown '
RETURNING * ;
-- name: WebCancelTask :one
2026-04-20 09:52:48 +08:00
-- Web 取消 queued task
2026-04-18 23:47:13 +08:00
UPDATE desktop_tasks
SET status = ' aborted ' , updated_at = now ( )
WHERE id = sqlc . arg ( task_id )
AND workspace_id = sqlc . arg ( workspace_id )
2026-04-20 09:52:48 +08:00
AND status = ' queued '
2026-04-18 23:47:13 +08:00
RETURNING * ;
-- name: GetTaskForAudit :one
SELECT * FROM desktop_tasks WHERE id = sqlc . arg ( task_id ) AND workspace_id = sqlc . arg ( workspace_id ) ;
2026-04-20 09:52:48 +08:00
-- name: ListPublishTasksByClient :many
-- 发布管理:排队任务优先,随后返回历史发送记录
2026-04-18 23:47:13 +08:00
SELECT * FROM desktop_tasks
2026-04-20 09:52:48 +08:00
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 ' ;
2026-04-18 23:47:13 +08:00
```
- [ ] **Step 4: sqlc generate + 编译 **
``` bash
cd server && sqlc generate && go build ./...
```
- [ ] **Step 5: commit **
``` bash
git add server/internal/tenant/repository/
2026-04-20 09:52:48 +08:00
git commit -m "feat(sqlc): desktop task lease queries and publish management listing"
2026-04-18 23:47:13 +08:00
```
### Task A.1.3: Handler 层实现所有 `/api/desktop/*` 和 `/api/tenant/*` 白名单路由
**Files: **
- Create: `server/internal/tenant/transport/desktop_client_handler.go`
- Create: `server/internal/tenant/transport/desktop_account_handler.go`
- Create: `server/internal/tenant/transport/desktop_task_handler.go`
- Create: `server/internal/tenant/transport/publish_job_handler.go`
- Create: `server/internal/tenant/transport/tenant_events_handler.go`
- Modify: `server/internal/tenant/transport/router.go`
**说明:**每个 handler 按 spec §6.4 路由逐个实现。下面给 3 个关键端点的代码骨架,其余类似。
- [ ] **Step 1: Client register handler **
`desktop_client_handler.go` :
``` go
package transport
import (
"crypto/rand"
"encoding/hex"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type DesktopClientHandler struct { app * bootstrap . App }
func NewDesktopClientHandler ( app * bootstrap . App ) * DesktopClientHandler {
return & DesktopClientHandler { app : app }
}
type registerReq struct {
DeviceName string ` json:"device_name" binding:"required" `
OS string ` json:"os" binding:"required" `
CPUArch string ` json:"cpu_arch" `
ClientVersion string ` json:"client_version" binding:"required" `
Channel string ` json:"channel" `
}
func ( h * DesktopClientHandler ) Register ( c * gin . Context ) {
actor := auth . MustActor ( c . Request . Context ( ) )
var req registerReq
if err := c . ShouldBindJSON ( & req ) ; err != nil {
response . Error ( c , response . ErrBadRequest ( 40001 , "bad_json" , err . Error ( ) ) )
return
}
// 生成 client_token
raw := make ( [ ] byte , 32 )
rand . Read ( raw )
token := hex . EncodeToString ( raw )
tokenHash := sha256Hash ( token ) // helper
client , err := h . app . DesktopRepo . RegisterDesktopClient ( c . Request . Context ( ) ,
generated . RegisterDesktopClientParams {
ID : uuid . New ( ) ,
TenantID : actor . TenantID ,
WorkspaceID : actor . PrimaryWorkspaceID ,
UserID : actor . UserID ,
TokenHash : tokenHash ,
DeviceName : sql . NullString { String : req . DeviceName , Valid : true } ,
OS : sql . NullString { String : req . OS , Valid : true } ,
CPUArch : sql . NullString { String : req . CPUArch , Valid : true } ,
ClientVersion : sql . NullString { String : req . ClientVersion , Valid : true } ,
Channel : sql . NullString { String : req . Channel , Valid : req . Channel != "" } ,
} )
if err != nil {
response . Error ( c , response . ErrInternal ( 50000 , "client_register_failed" , err . Error ( ) ) )
return
}
response . OK ( c , gin . H {
"client_id" : client . ID ,
"client_token" : token , // 仅此次返回,client 自己存
"expires_at" : client . CreatedAt . Add ( 30 * 24 * time . Hour ) . Unix ( ) ,
} )
}
```
- [ ] **Step 2: Publish job handler(账号级多选展开 + content_ref 边界校验) **
`publish_job_handler.go` :
``` go
type createJobReq struct {
Title string ` json:"title" binding:"required" `
ContentRef ContentRef ` json:"content_ref" binding:"required" `
Accounts [ ] struct {
AccountID uuid . UUID ` json:"account_id" binding:"required" `
Mode string ` json:"mode" binding:"required,oneof=auto manual" `
} ` json:"accounts" binding:"required,min=1" `
ScheduledAt * time . Time ` json:"scheduled_at" `
}
type ContentRef struct {
Type string ` json:"type" binding:"required,oneof=article_draft template_gen video brand_asset" `
ID string ` json:"id" binding:"required" `
}
func ( h * PublishJobHandler ) Create ( c * gin . Context ) {
actor := auth . MustActor ( c . Request . Context ( ) )
var req createJobReq
if err := c . ShouldBindJSON ( & req ) ; err != nil { /* ... */ return }
// content_ref workspace 边界校验(spec §6.8.2 Phase 1 规则)
if err := h . validateContentRefInWorkspace ( c , req . ContentRef , actor ) ; err != nil {
response . Error ( c , response . ErrUnprocessable ( 42201 , "content_ref_not_in_primary_workspace" ,
"content_ref 引用的实体不属于当前 workspace" ) )
return
}
accountIDs := make ( [ ] uuid . UUID , len ( req . Accounts ) )
for i , a := range req . Accounts { accountIDs [ i ] = a . AccountID }
taskPayload , _ := json . Marshal ( map [ string ] any {
"content_ref" : req . ContentRef ,
"modes" : lo . Associate ( req . Accounts , func ( a any ) ( uuid . UUID , string ) { /* ... */ } ) ,
} )
tasks , err := h . app . DesktopRepo . CreatePublishJobTasks ( c . Request . Context ( ) ,
generated . CreatePublishJobTasksParams {
JobID : uuid . New ( ) ,
TenantID : actor . TenantID ,
WorkspaceID : actor . PrimaryWorkspaceID ,
UserID : actor . UserID ,
Title : req . Title ,
ContentRef : req . ContentRef , // jsonb
AccountIDs : accountIDs ,
TaskPayload : taskPayload ,
} )
if err != nil { /* ... */ return }
// 发 task_available 到 RabbitMQ
for _ , t := range tasks {
h . app . EventBus . PublishTaskAvailable ( t . TargetClientID , t . ID )
}
response . OK ( c , gin . H { "job_id" : tasks [ 0 ] . JobID , "task_ids" : lo . Map ( tasks , func ( t any ) uuid . UUID { /* ... */ } ) } )
}
```
2026-04-20 09:52:48 +08:00
- [ ] **Step 3:发布管理 handlers(列表 + 再次发送) **
2026-04-18 23:47:13 +08:00
``` go
2026-04-20 09:52:48 +08:00
func ( h * DesktopTaskHandler ) ListPublish ( c * gin . Context ) {
client := middleware . MustClientFromCtx ( c . Request . Context ( ) )
limit := clampInt ( queryInt ( c , "limit" , 100 ) , 1 , 200 )
2026-04-18 23:47:13 +08:00
2026-04-20 09:52:48 +08:00
tasks , err := h . app . DesktopTaskService . ListPublishTasks ( c . Request . Context ( ) , client , limit )
if err != nil { /* ... */ return }
response . OK ( c , gin . H { "items" : tasks } )
2026-04-18 23:47:13 +08:00
}
2026-04-20 09:52:48 +08:00
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 )
2026-04-18 23:47:13 +08:00
}
```
- [ ] **Step 4: Tenant events SSE handler(参考 spec §6.8.4) **
``` go
func ( h * TenantEventsHandler ) Subscribe ( c * gin . Context ) {
actor := auth . MustActor ( c . Request . Context ( ) )
lastEventID := c . GetHeader ( "Last-Event-ID" )
topics := events . DeriveAllowedTopics ( actor )
if len ( topics ) == 0 {
response . Error ( c , response . ErrUnauthorized ( 40106 , "no_topics" , "no subscribable topics" ) )
return
}
c . Writer . Header ( ) . Set ( "Content-Type" , "text/event-stream" )
c . Writer . Header ( ) . Set ( "Cache-Control" , "no-cache" )
c . Writer . Header ( ) . Set ( "Connection" , "keep-alive" )
c . Writer . Flush ( )
// Step A: 发 snapshot(订阅建立首帧)
snapshot := h . app . EventBus . SnapshotForTopics ( topics , lastEventID )
for _ , evt := range snapshot {
fmt . Fprintf ( c . Writer , "id: %d\nevent: %s\ndata: %s\n\n" , evt . SeqID , evt . Type , evt . JSON )
c . Writer . Flush ( )
}
// Step B: 订阅 RabbitMQ,转发
sub := h . app . EventBus . Subscribe ( topics )
defer sub . Close ( )
for {
select {
case <- c . Request . Context ( ) . Done ( ) :
return
case evt := <- sub . Chan :
if ! events . ValidateTopicForActor ( evt . Topic , actor ) { continue }
fmt . Fprintf ( c . Writer , "id: %d\nevent: %s\ndata: %s\n\n" , evt . SeqID , evt . Type , evt . JSON )
c . Writer . Flush ( )
}
}
}
```
- [ ] **Step 5: router.go 把所有新 handler 挂到路由白名单(替换 Plan 0 的 notImplementedHandler) **
把 Task 0.3.2 的占位 handler 一一替换成上面的真实 handler。
- [ ] **Step 6:编译 + 集成测试 **
``` bash
cd server && go build ./... && go test ./internal/tenant/transport/
```
- [ ] **Step 7: commit **
``` bash
git add server/internal/tenant/transport/
git commit -m "feat(api): desktop_clients/accounts/tasks/publish_jobs/tenant_events handlers"
```
### Task A.1.4: RabbitMQ fanout + Redis presence
**Files: **
- Create: `server/internal/shared/eventbus/rabbitmq_bus.go`
- Create: `server/internal/shared/eventbus/presence.go`
- [ ] **Step 1:定义 EventBus interface **
``` go
package eventbus
type Event struct {
SeqID int64
Topic string
Type string
JSON json . RawMessage
Time time . Time
}
type EventBus interface {
Publish ( topic string , eventType string , payload any ) error
Subscribe ( topics [ ] string ) Subscription
PublishTaskAvailable ( targetClientID uuid . UUID , taskID uuid . UUID ) error
SnapshotForTopics ( topics [ ] string , lastEventID string ) [ ] Event
}
type Subscription interface {
Chan ( ) <- chan Event
Close ( ) error
}
```
- [ ] **Step 2: RabbitMQ 实现(fanout exchange) **
略(标准 amqp091-go 使用方式;每个实例订阅 fanout,本地 filter 感兴趣 topic)。
- [ ] **Step 3: Redis presence **
``` go
type Presence interface {
MarkOnline ( ctx , clientID , instanceID ) error // TTL 30s,每 10s 续期
IsOnline ( ctx , clientID ) ( instanceID string , ok bool )
GetOnlineClientsInWorkspace ( ctx , workspaceID ) [ ] uuid . UUID
}
```
用 `SET presence:<client_id> <instance_id> EX 30` 。
- [ ] **Step 4: offline→online 监听器 **
在每个 server 实例启动 worker,订阅 Redis keyspace notification `expired` / `set` ,检测 `presence:<client>` 的 set 事件。收到时:
``` go
2026-04-20 09:52:48 +08:00
queuedTasks , _ := repo . ListQueuedTasksByClient ( ctx , clientID )
for _ , t := range queuedTasks {
2026-04-18 23:47:13 +08:00
bus . PublishTaskAvailable ( clientID , t . ID )
}
```
- [ ] **Step 5: commit **
``` bash
git add server/internal/shared/eventbus/
git commit -m "feat(eventbus): RabbitMQ fanout + Redis presence + offline→online task reclaim"
```
2026-04-20 09:52:48 +08:00
### Task A.1.5:服务端 Task 集成测试(关键:lease CAS / publish retry / cross-client)
2026-04-18 23:47:13 +08:00
**Files: **
- Create: `server/internal/tenant/transport/desktop_task_integration_test.go`
- [ ] **Step 1:核心测试用例 **
``` go
2026-04-20 09:52:48 +08:00
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 ) { /* ... */ }
2026-04-18 23:47:13 +08:00
func TestReconcileRetry_MovesTaskBackToQueued ( t * testing . T ) { /* ... */ }
func TestReconcileSucceeded_MarksTaskSucceeded ( t * testing . T ) { /* ... */ }
func TestWebCancelQueuedTask ( t * testing . T ) { /* ... */ }
func TestPublishJobRejectsContentRefFromAnotherWorkspace ( t * testing . T ) { /* 422 */ }
```
- [ ] **Step 2:跑测试 **
``` bash
cd server && go test ./internal/tenant/transport/ -run TestDesktop -v
```
- [ ] **Step 3: commit **
``` bash
git add server/internal/tenant/transport/desktop_task_integration_test.go
git commit -m "test(desktop): task protocol + lease CAS + reconcile + Web cancel integration suite"
```
---
## Phase A.2 · Desktop runtime 基座(块 A · 2 周)
### Task A.2.1: pnpm workspace + Electron scaffold
**Files: **
- Create: `apps/desktop-client/package.json`
- Create: `apps/desktop-client/tsconfig.json`
- Create: `apps/desktop-client/electron-builder.yml`
- Create: `apps/desktop-client/src/main/bootstrap.ts`
- [ ] **Step 1: package.json **
``` json
{
"name" : "@geo/desktop-client" ,
"version" : "0.1.0" ,
"main" : "dist/main/bootstrap.js" ,
"scripts" : {
"dev" : "electron-vite dev" ,
"build" : "electron-vite build" ,
"package:mac" : "electron-builder --mac --arm64 --x64 --universal" ,
"test" : "vitest run" ,
"typecheck" : "tsc --noEmit"
} ,
"dependencies" : {
"@geo/http-client" : "workspace:*" ,
"@geo/shared-types" : "workspace:*" ,
"@geo/ui-shared" : "workspace:*" ,
"electron-updater" : "^6.x" ,
"pino" : "^9.x" ,
"better-sqlite3" : "^11.x"
} ,
"devDependencies" : {
"electron" : "41.2.0" ,
"electron-vite" : "^2.x" ,
"electron-builder" : "^25.x" ,
"vitest" : "^2.x" ,
"@playwright/test" : "^1.x"
}
}
```
- [ ] **Step 2: electron-vite 结构 + tsconfig + entry **
```
apps/desktop-client/
electron.vite.config.ts
src/
main/
bootstrap.ts # app ready, single-instance-lock, tray, autoUpdater
session-registry.ts
view-pool.ts
vault.ts
scheduler.ts
lease-manager.ts
rate-limiter.ts
circuit-breaker.ts
keep-alive.ts
crash-recovery.ts
transport/
api-client.ts
sse-client.ts
tracing/
recorder.ts
trace-writer.ts
redactor.ts
adapters/
base.ts
_shared/
doubao.ts # Phase A.4 填充
toutiao.ts # Phase A.5 填充
index.ts
preload/
bridge.ts
renderer/
main.ts
routes.ts
views/
```
- [ ] **Step 3:最小 bootstrap 能启动 **
``` ts
import { app , Tray , BaseWindow } from 'electron'
import { initSingleInstance } from './single-instance'
import { initTray } from './tray'
import { initSessionRegistry } from './session-registry'
import { initTransport } from './transport/api-client'
app . whenReady ( ) . then ( async ( ) = > {
initSingleInstance ( )
await initSessionRegistry ( )
initTransport ( )
initTray ( )
} )
```
- [ ] **Step 4: commit scaffold **
``` bash
git add apps/desktop-client/
git commit -m "chore(desktop-client): Electron + electron-vite scaffold"
```
### Task A.2.2-A.2.N:逐模块实现
> **Note: ** 每个模块(session-registry / view-pool / vault / lease-manager / rate-limiter / keep-alive / circuit-breaker / crash-recovery / api-client / sse-client / tracing)都按如下模板做:
>
> 1. **写接口 + 失败测试**( Vitest)
> 2. **写最小实现**
> 3. **跑测试通过**
> 4. **commit**
下面给每个模块的**最小功能清单**;具体 bite-sized 步骤由 subagent 在 executing-plans 时展开。
---
### Task A.2.2: `session-registry.ts` + `view-pool.ts`
**功能清单(每条对应一个子 Task,TDD 实现): **
1. `SessionRegistry.create(platform)` → 分配新 partition (`persist:acc-<uuid>` ),返回 session
2. `SessionRegistry.get(accountId)` → 已存在则返回,否则 lazy create
3. `ViewPool.acquireHot(accountId)` → 创建或复用 WebContentsView
4. `ViewPool.demoteToCool(accountId)` → 30 min 空闲后 close view but keep session partition on disk
5. 单元测试:Hot/Cold transitions + partition 持久化 + RAM baseline 断言
### Task A.2.3: `vault.ts` + `vault-export.ts`
1. `Vault.writeEncrypted(key, value)` → 用 `safeStorage.encryptString` ; Linux 检测 backend
2. `Vault.readEncrypted(key)` → 解密;backend mismatch 返 error 不 panic
3. `VaultExport.exportToFile(path, passphrase)` → PBKDF2 + AES-GCM 用户口令二次加密
4. `VaultExport.importFromFile(path, passphrase)` → 解密后用新环境 safeStorage 重加密
5. 单元测试:Linux basic_text fallback 告警 + 跨环境导出/导入
### Task A.2.4: `lease-manager.ts`(关键)
1. `LeaseManager.acquire(taskId)` → `POST /tasks/lease`
2026-04-20 09:52:48 +08:00
2. `LeaseManager.extend(lease_token)` → 每 60s 自动调
3. `LeaseManager.complete(result)` → `POST /tasks/{id}/result`
4. `LeaseManager.cancel(taskId)` / `clearLocalState()` → 终态或放弃时清本地 lease
5. 单元测试:409 抢占幂等、断线重连带同一 attempt 的 409 处理、终态后不再续租
2026-04-18 23:47:13 +08:00
### Task A.2.5: `rate-limiter.ts`
1. Token bucket per (platform, account),桶容量 = `hourlyBudget`
2. 业务 / 保活 / 探活 同桶扣减
3. 429 / captcha 响应识别 → 指数退避
4. `powerMonitor suspend/resume` → 暂停 + 错峰 warm-up
5. 单元测试:桶耗尽时拒绝;resume 错峰分批
### Task A.2.6: `keep-alive.ts`
1. 按 spec §4.4 分层:probe 30 min / heartbeat 8 min( Doubao/Qwen)
2. 错峰:`hash(accountId) mod period ± 30s`
3. 静音时段(用户本地时区 0:00–7:00)
4. 失败升级 → `health=expired` → Tray 红点 + OS 通知
5. 单元测试:多账号错峰不同步;健康状态迁移
### Task A.2.7: `crash-recovery.ts`
2026-04-20 09:52:48 +08:00
1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress'`
2026-04-18 23:47:13 +08:00
2. 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN
3. 上报服务端
4. 单元测试:publish submitting 崩溃 → UNKNOWN; monitor 崩溃 → 重跑无副作用
### Task A.2.8: `transport/api-client.ts` + `transport/sse-client.ts`
1. 基于 `@geo/http-client` ,所有请求自动注入 `Authorization: Bearer <client_token>`
2. `SseClient` : EventTarget 类(不依赖 Vue),包含指数退避重连、`Last-Event-ID` 、event_id 去重、snapshot 首帧处理
3. 60s pull 兜底(SSE disconnect 超 X 秒转 pull)
4. 单元测试:重连、重放、去重、fallback switching
### Task A.2.9: `tracing/` 三件套(Alpha 范围)
1. `Recorder` :按 Plan 开关,默认关闭;失败时自动 flush rolling buffer
2. `TraceWriter` :流式 zip,最多 50 份 / 500 MB
3. `Redactor` : Cookie / Authorization / password 等自动 `<redacted>`
4. 单元测试:脱敏不漏字段;buffer overflow 不 OOM
**每个 Task 都按"写接口 → 测试失败 → 最小实现 → 测试通过 → commit"的节奏走,具体步骤在 subagent execute 时展开。 **
---
## Phase A.3 · ui-shared H1( 1.5 周 · 与 A.1/A.2 并行)
### Task A.3.1: packages/ui-shared 骨架
**Files: **
- Create: `packages/ui-shared/package.json`
- Create: `packages/ui-shared/src/index.ts`
- [ ] **Step 1: package 初始化 **
``` bash
mkdir -p packages/ui-shared/src
cd packages/ui-shared && pnpm init
```
`package.json` :
``` json
{
"name" : "@geo/ui-shared" ,
"version" : "0.1.0" ,
"type" : "module" ,
"main" : "src/index.ts" ,
"exports" : {
"." : "./src/index.ts" ,
"./tokens" : "./src/tokens/index.ts" ,
"./composables" : "./src/composables/index.ts" ,
"./components" : "./src/components/index.ts"
} ,
"peerDependencies" : {
"vue" : "^3.x" ,
"ant-design-vue" : "^4.x"
}
}
```
- [ ] **Step 2: commit **
``` bash
git add packages/ui-shared/package.json pnpm-workspace.yaml
pnpm install
git commit -m "chore(ui-shared): init packages/ui-shared"
```
### Task A.3.2: Design tokens
**Files: **
- Create: `packages/ui-shared/src/tokens/colors.ts`
- Create: `packages/ui-shared/src/tokens/theme.ts`
- Create: `packages/ui-shared/src/tokens/index.css`
- [ ] **Step 1: CSS variables light/dark **
``` css
: root {
--color-bg-primary : #ffffff ;
--color-bg-secondary : #f5f7fa ;
--color-text-primary : #0f172a ;
--color-text-secondary : #475569 ;
--color-border : #e2e8f0 ;
--color-brand : #2563eb ;
--color-success : #16a34a ;
--color-warn : #eab308 ;
--color-danger : #ef4444 ;
--spacing-xs : 4 px ;
--spacing-sm : 8 px ;
--spacing-md : 16 px ;
--spacing-lg : 24 px ;
--radius-sm : 4 px ;
--radius-md : 8 px ;
}
html [ data-theme = 'dark' ] {
--color-bg-primary : #0f172a ;
--color-bg-secondary : #1e293b ;
--color-text-primary : #f1f5f9 ;
--color-text-secondary : #94a3b8 ;
--color-border : #334155 ;
}
```
- [ ] **Step 2: AntD theme config **
``` ts
// theme.ts
import type { ThemeConfig } from 'ant-design-vue/es/config-provider'
export const lightTheme : ThemeConfig = {
token : {
colorPrimary : '#2563eb' ,
colorSuccess : '#16a34a' ,
colorWarning : '#eab308' ,
colorError : '#ef4444' ,
borderRadius : 8 ,
}
}
export const darkTheme : ThemeConfig = {
algorithm : 'darkAlgorithm' ,
token : { colorPrimary : '#60a5fa' }
}
```
- [ ] **Step 3: commit **
``` bash
git add packages/ui-shared/src/tokens/
git commit -m "feat(ui-shared): design tokens (CSS variables + AntD theme light/dark)"
```
### Task A.3.3: `useAccountSelection` composable(多选核心)
**Files: **
- Create: `packages/ui-shared/src/composables/useAccountSelection.ts`
- Create: `packages/ui-shared/src/composables/useAccountSelection.test.ts`
- [ ] **Step 1:写失败测试 **
``` ts
import { describe , it , expect } from 'vitest'
import { useAccountSelection , type AccountCard } from './useAccountSelection'
const sample : AccountCard [ ] = [
{ id : 'a1' , platform : 'wechat' , displayName : 'GZH A' , tags : [ 'brand' ] , online : true , health : 'live' } ,
{ id : 'a2' , platform : 'wechat' , displayName : 'GZH B' , tags : [ 'test' ] , online : false , health : 'expired' } ,
{ id : 'b1' , platform : 'toutiao' , displayName : 'Toutiao 1' , tags : [ 'brand' ] , online : true , health : 'live' } ,
]
describe ( 'useAccountSelection' , ( ) = > {
it ( 'selects by tag' , ( ) = > {
const sel = useAccountSelection ( sample )
sel . selectByTag ( 'brand' )
expect ( [ . . . sel . selected . value ] ) . toEqual ( [ 'a1' , 'b1' ] )
} )
it ( 'selects by platform' , ( ) = > {
const sel = useAccountSelection ( sample )
sel . selectByPlatform ( 'wechat' )
expect ( [ . . . sel . selected . value ] ) . toEqual ( [ 'a1' , 'a2' ] )
} )
it ( 'search filters visible' , ( ) = > {
const sel = useAccountSelection ( sample )
sel . search . value = 'GZH'
expect ( sel . visible . value . map ( a = > a . id ) ) . toEqual ( [ 'a1' , 'a2' ] )
} )
it ( 'toggle inverts selection per visible' , ( ) = > {
const sel = useAccountSelection ( sample )
sel . selectAll ( )
sel . invert ( )
expect ( sel . selected . value . size ) . toBe ( 0 )
} )
} )
```
- [ ] **Step 2:跑失败 **
``` bash
cd packages/ui-shared && pnpm test -- useAccountSelection
```
Expected: FAIL。
- [ ] **Step 3:写实现 **
``` ts
import { computed , ref } from 'vue'
export type AccountCard = {
id : string
platform : string
displayName : string
tags : string [ ]
online : boolean
health : 'live' | 'expired' | 'captcha' | 'risk'
}
export function useAccountSelection ( initial : AccountCard [ ] ) {
const all = ref ( initial )
const selected = ref ( new Set < string > ( ) )
const search = ref ( '' )
const tagFilter = ref < string [ ] > ( [ ] )
const platformFilter = ref < string [ ] > ( [ ] )
const visible = computed ( ( ) = >
all . value . filter ( a = > {
if ( search . value && ! a . displayName . includes ( search . value ) && ! a . id . includes ( search . value ) ) return false
if ( tagFilter . value . length && ! a . tags . some ( t = > tagFilter . value . includes ( t ) ) ) return false
if ( platformFilter . value . length && ! platformFilter . value . includes ( a . platform ) ) return false
return true
} )
)
function toggle ( id : string ) {
if ( selected . value . has ( id ) ) selected . value . delete ( id )
else selected . value . add ( id )
}
function selectAll() {
for ( const a of visible . value ) selected . value . add ( a . id )
}
function invert() {
const s = new Set < string > ( )
for ( const a of visible . value ) if ( ! selected . value . has ( a . id ) ) s . add ( a . id )
selected . value = s
}
function selectByTag ( tag : string ) {
for ( const a of all . value ) if ( a . tags . includes ( tag ) ) selected . value . add ( a . id )
}
function selectByPlatform ( p : string ) {
for ( const a of all . value ) if ( a . platform === p ) selected . value . add ( a . id )
}
return { all , visible , selected , search , tagFilter , platformFilter ,
toggle , selectAll , invert , selectByTag , selectByPlatform }
}
```
- [ ] **Step 4:跑通过 **
``` bash
cd packages/ui-shared && pnpm test -- useAccountSelection
```
Expected: PASS。
- [ ] **Step 5: commit **
``` bash
git add packages/ui-shared/src/composables/
git commit -m "feat(ui-shared): useAccountSelection composable"
```
### Task A.3.4:组件迁入 + SSE composable
为简洁起见,下列 5 个子任务每个都按同样 TDD 模式做(失败测试 → 实现 → 通过 → commit):
- [ ] **A.3.4.1: ** `MarkdownPreview.vue` (从 `apps/admin-web/src/components/MarkdownPreview.vue` 提取)
- [ ] **A.3.4.2: ** `AccountCard.vue` (账号卡片带 health / online badge)
- [ ] **A.3.4.3: ** `TagMultiSelect.vue` ( tag 多选控件)
- [ ] **A.3.4.4: ** `HealthBadge.vue`
- [ ] **A.3.4.5: ** `useSseSubscription` composable(基于 `@geo/http-client` 的 `SseClient` ,提供 Vue 响应式绑定)
* * `useSseSubscription` 关键点:**
``` ts
import { onMounted , onUnmounted , ref } from 'vue'
import { SseClient } from '@geo/http-client'
export function useSseSubscription < T > ( url : string ) {
const events = ref < T [ ] > ( [ ] )
const connected = ref ( false )
const client = new SseClient ( url , {
onEvent : ( e ) = > events . value . push ( e as T ) ,
onConnect : ( ) = > ( connected . value = true ) ,
onDisconnect : ( ) = > ( connected . value = false ) ,
} )
onMounted ( ( ) = > client . connect ( ) )
onUnmounted ( ( ) = > client . close ( ) )
return { events , connected , client }
}
```
### Task A.3.5: admin-web 接入 ui-shared(回退测试)
把 `apps/admin-web` 里原来内嵌的 MarkdownPreview 等替换为 `@geo/ui-shared` 引用。跑现有 admin-web 测试不回归。
---
## Phase A.4 · Doubao 监控适配器(1 周)
**Prereq: ** A.0 spike 结论为 supported / degraded。
### Task A.4.1: DoubaoAdapter 基于 spike 结果实现
**Files: **
- Create: `apps/desktop-client/src/main/adapters/doubao.ts`
- Create: `apps/desktop-client/src/main/adapters/_shared/sse-framer.ts`
- Create: `apps/desktop-client/src/main/adapters/doubao.test.ts`
**模块清单(TDD 实施): **
1. `probe(ctx)` → 打开 `doubao.com` ,检查登录态 DOM 标志
2. `keepAlive(ctx)` → 访问私有 API 触发 ms_token 刷新
3. `query(ctx, task)` → 按 A.0 matrix 选 transport 分支抓 SSE / fetch-stream
4. Provider parser( handle 豆包特定 payload schema)
5. 所有 CDP 调用 wrap 进 `_shared/debugger-helper.ts`
6. fixture 测试:抓一段真实 SSE(脱敏)→ parser 断言输出
### Task A.4.2:端到端冒烟
- [ ] 启 fake server + fake Doubao page → 派监控任务 → 验证 result 回调
---
## Phase A.5 · 头条号发布适配器(1 周)
**Files: **
- Create: `apps/desktop-client/src/main/adapters/toutiao.ts`
**模块清单: **
1. `probe` + `keepAlive`
2026-04-20 09:52:48 +08:00
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
2026-04-18 23:47:13 +08:00
---
## Phase A.6 · PublishArticleModal H2 重构(1 周)
### Task A.6.1:基于 `useAccountSelection` 重写 Modal
**Files: **
- Modify: `apps/admin-web/src/components/PublishArticleModal.vue`
- [ ] **Step 1:删除 `accounts[0]` 假设 **
- [ ] **Step 2:引入 `useAccountSelection` **
- [ ] **Step 3: Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选 **
- [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义 **
- [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:<id>` SSE **
2026-04-20 09:52:48 +08:00
- [ ] **Step 6:进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link **
2026-04-18 23:47:13 +08:00
- [ ] **Step 7: E2E 测试(Playwright admin-web) **
- [ ] **Step 8: commit **
2026-04-20 09:52:48 +08:00
### 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 测试离线积压多篇任务逐条发送与手动补发路径 **
2026-04-18 23:47:13 +08:00
---
## Phase A.7 · 测试基建 + Mac 公证(1 周)
### Task A.7.1: Fake server + fake platforms
**Files: **
- Create: `tests/fake-server/` ( replay /api/desktop/* 的最小 mock)
- Create: `tests/fake-platforms/doubao/`
- Create: `tests/fake-platforms/toutiao/`
具体实现略——要求能在 CI 中 `node tests/fake-server start` 起服务。
### Task A.7.2: Playwright `_electron.launch` 跑端到端
- 启动 fake server + fake 平台
- 注入伪账号
- 派监控任务 + 发布任务
- 断言 result 回调格式
### Task A.7.3: Mac 公证 CI
**Files: **
- Modify: `.github/workflows/release.yml`
``` yaml
- name : Build Mac universal
run : cd apps/desktop-client && pnpm package:mac
env :
APPLE_ID : ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD : ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID : ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK : ${{ secrets.MAC_CSC_P12_BASE64 }}
CSC_KEY_PASSWORD : ${{ secrets.MAC_CSC_PASSWORD }}
```
- [ ] **Step 1:配 secrets ** (用户线下)
- [ ] **Step 2: CI 跑通一次 `.dmg` 产出 + notarize 成功 **
### Task A.7.4: autoUpdater smoke unit tests
- 版本比较
- 签名验签
- `allowDowngrade` 决策
- `minClientVersion` 校验
---
## Phase A.8 · Exit bar( 0.5 周)
### Task A.8.1:端到端真实账号冒烟 Playbook
**Files: ** `docs/superpowers/runbooks/plan-a-smoke-playbook.md`
- [ ] 用测试账号登录头条 → 创建 publish job → manual 模式 → 前往桌面端审核 → 发布成功
- [ ] 用测试账号登录 Doubao → 创建监控计划 → 验证结果
- [ ] UNKNOWN 路径:kill desktop 进程 during submitting → 重启 → reconcile 按 4 种 action 各跑一次
### Task A.8.2:混沌测试
- [ ] 启动两个 server 实例(fake RabbitMQ / Redis)→ client A 连 instance 1 → task 创建于 instance 2 → 验证 SSE 正确推到 A
- [ ] kill instance 1 → A 降级 60s pull → 重连 instance 2 → 继续工作
- [ ] Parked 恢复:park task → kill client → 重启 → acquireFromParked 成功
### Task A.8.3: Sign-off + 打 tag
- [ ] 所有 Phase 的 exit bar 都 close
- [ ] `git tag plan-a-complete`
- [ ] 在 Plan 文档顶部标 ✅ COMPLETED
---
## Self-Review
**1. Spec coverage: **
- 块 A( runtime 基座):Phase A.2 ✅
- 块 E(服务端协议):Phase A.1 ✅
- 块 H1/H2: Phase A.3 + A.6 ✅
- 块 B( Doubao):Phase A.4 ✅
- 块 C(头条迁移):Phase A.5 ✅
- 块 I(测试基建):Phase A.7 ✅
- 块 F( Mac 公证):Phase A.7 ✅
- Spike 矩阵:Phase A.0 ✅
- Exit bar: Phase A.8 ✅
**2. Placeholder scan: ** Plan A 里 Phase A.2 的 Task A.2.2-A.2.9 采用"模块清单 + TDD 模板"的形式,由 subagent 执行时展开为具体 bite-sized 步骤。这是经过权衡的:Plan 0 是"几乎无已知代码的新建"所以全 bite-sized 合理;Plan A 因每个模块接口在 spec 已明确、且 TDD 模式固定,细化到每一行代码会让 plan 变成 5000+ 行文档,反而降低可读性。每个模块子任务数(功能清单条目数)清晰,subagent-driven-development 能把它展开。
**3. Type consistency: ** `LeaseManager` / `SseClient` / `useAccountSelection` / `AccountCard` / `ContentRef` 等命名在 Plan A 各 Phase 间一致,和 Plan 0 的 `Actor / WorkspaceScope / PrimaryWorkspaceID` 接入。
**4. Dependency ordering: ** A.0 阻塞 A.4; A.1/A.2/A.3 可并行;A.4 依赖 A.2+A.0; A.5 依赖 A.2; A.6 依赖 A.1+A.3; A.7 依赖 A.2+A.4+A.5; A.8 依赖所有。DAG 正确。
**5. Gaps check: ** Plan A 未覆盖块 D( 7 家 detect-only 发布适配器从零实现),这部分在 Plan B 里。Plan A 只做头条号这**一家**真实发布适配器,作为骨架验证;Plan B 做剩余 12 家。与 spec §12 一致。
---
## Execution Handoff
两个 plan 都已完成并保存到 `docs/superpowers/plans/` :
- `2026-04-18-plan-0-workspace-foundation.md`
- `2026-04-18-plan-a-desktop-skeleton.md`
Plan 0 是 Plan A 的硬前置。**先执行 Plan 0,打完 `plan-0-complete` tag 后再启 Plan A**。
**两种执行方式: **
1. **Subagent-Driven(推荐) ** — 每个 Task 派一个 fresh subagent 执行,两段式 review,快速迭代。REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`
2. **Inline Execution ** — 当前会话里顺序执行 Task,每 5-10 Task 一个 checkpoint review。REQUIRED SUB-SKILL: `superpowers:executing-plans`
用户可在下一轮对话里选执行方式并指定从哪条 Task 开始。