Files
geo/docs/superpowers/plans/2026-04-18-plan-a-desktop-skeleton.md
T
root a617d39a4a 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 文档同步口径。
2026-04-20 09:52:48 +08:00

1502 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan A · 桌面客户端骨架穿透 Implementation Plan
> **2026-04-20 当前实现口径**desktop 侧二次人工审核与 `manual / waiting_user / parked / from_parked` 链路已废弃。SaaS 完成人工审核后才创建 publish jobdesktop client 只负责执行发布,并提供“发布管理”查看待发布队列、历史结果与手动再次发送。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务;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 复用同一套共享组件。
**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 并行 |
| A.2 · Desktop runtime 基座 | 块 AMain 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
| A.3 · ui-shared H1 | `packages/ui-shared` 骨架 + design tokens + `useAccountSelection` composable + 5 个组件迁入 | 1.5 周 | Plan 0 | ✅ 与 A.1/A.2 并行 |
| A.4 · 1 监控适配器 Doubao | 基于 A.2 + A.0 spike 结论实现 | 1 周 | A.0, A.2 | — |
| A.5 · 1 发布适配器 头条号 | 基于 A.2 的直发状态机 + 队列节流 + 失败可再次发送 | 1 周 | A.2 | ✅ 与 A.4 并行 |
| A.6 · PublishArticleModal + 发布管理 H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal,并补 desktop publish management 页 | 1 周 | A.1, A.2, A.3 | — |
| A.7 · 测试基建 + Mac 公证 | 块 I + Ffake server / fake 平台 / Playwright _electron + Mac 公证 CI | 1 周 | A.2, A.4, A.5 | ✅ 与 A.6 并行 |
| A.8 · Exit bar | E2E + 混沌 + 真实账号冒烟 + reconcile 闭环演练 | 0.5 周 | ALL | — |
**合计 56 工程周**(如果团队 ≥ 3 人并行 A.1+A.2+A.3)。
---
## Phase A.0 · §5.2 CDP Spike0.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')
// 实测矩阵 AEventSource → 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))
}
})
// 实测矩阵 Bfetch-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 3commit 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 AEventSource):记录 `params.data` 字段。
Path BFetch):
```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 6Go / No-Go 决策**
-`supported` → A.4 可开工,parser 按实测 schema 写。
-`degraded` → 记 workaround 条目,A.4 按 workaround 实现。
-`blocked`(所有 CDP 路径都拿不到 body)→ 升级到 **fallback 决策**DOM-only 降级 或 移出 Plan A 范围)。此时**停**继续,回产品线做范围决策。
- [ ] **Step 7commit 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.1DB schema 迁移
**Files:**
- Create: `server/migrations/20260421100000_create_desktop_tables.up.sql`
- Create: `server/migrations/20260421100000_create_desktop_tables.down.sql`
- [ ] **Step 1up 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 为新 schemadrop 旧 + 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)
WHERE status IN ('queued', 'in_progress');
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 2down 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 4commit**
```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.2sqlc 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 reconcilestatus 可为 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
-- 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 = 'queued'
RETURNING *;
-- name: GetTaskForAudit :one
SELECT * FROM desktop_tasks WHERE id = sqlc.arg(task_id) AND workspace_id = sqlc.arg(workspace_id);
-- name: ListPublishTasksByClient :many
-- 发布管理:排队任务优先,随后返回历史发送记录
SELECT * FROM desktop_tasks
WHERE target_client_id = sqlc.arg(client_id)
AND kind = 'publish'
ORDER BY
CASE WHEN status IN ('queued', 'in_progress') THEN 0 ELSE 1 END,
updated_at DESC
LIMIT sqlc.arg(limit_count);
-- name: ListQueuedTasksByClient :many
SELECT * FROM desktop_tasks
WHERE target_client_id = sqlc.arg(client_id) AND status = 'queued';
```
- [ ] **Step 4sqlc generate + 编译**
```bash
cd server && sqlc generate && go build ./...
```
- [ ] **Step 5commit**
```bash
git add server/internal/tenant/repository/
git commit -m "feat(sqlc): desktop task lease queries and publish management listing"
```
### Task A.1.3Handler 层实现所有 `/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 1Client 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 2Publish 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 { /* ... */ })})
}
```
- [ ] **Step 3:发布管理 handlers(列表 + 再次发送)**
```go
func (h *DesktopTaskHandler) ListPublish(c *gin.Context) {
client := middleware.MustClientFromCtx(c.Request.Context())
limit := clampInt(queryInt(c, "limit", 100), 1, 200)
tasks, err := h.app.DesktopTaskService.ListPublishTasks(c.Request.Context(), client, limit)
if err != nil { /* ... */ return }
response.OK(c, gin.H{"items": tasks})
}
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)
}
```
- [ ] **Step 4Tenant 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 5router.go 把所有新 handler 挂到路由白名单(替换 Plan 0 的 notImplementedHandler**
把 Task 0.3.2 的占位 handler 一一替换成上面的真实 handler。
- [ ] **Step 6:编译 + 集成测试**
```bash
cd server && go build ./... && go test ./internal/tenant/transport/
```
- [ ] **Step 7commit**
```bash
git add server/internal/tenant/transport/
git commit -m "feat(api): desktop_clients/accounts/tasks/publish_jobs/tenant_events handlers"
```
### Task A.1.4RabbitMQ 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 2RabbitMQ 实现(fanout exchange**
略(标准 amqp091-go 使用方式;每个实例订阅 fanout,本地 filter 感兴趣 topic)。
- [ ] **Step 3Redis 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 4offline→online 监听器**
在每个 server 实例启动 worker,订阅 Redis keyspace notification `expired` / `set`,检测 `presence:<client>` 的 set 事件。收到时:
```go
queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID)
for _, t := range queuedTasks {
bus.PublishTaskAvailable(clientID, t.ID)
}
```
- [ ] **Step 5commit**
```bash
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 / publish retry / cross-client
**Files:**
- Create: `server/internal/tenant/transport/desktop_task_integration_test.go`
- [ ] **Step 1:核心测试用例**
```go
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 TestWebCancelQueuedTask(t *testing.T) { /* ... */ }
func TestPublishJobRejectsContentRefFromAnotherWorkspace(t *testing.T) { /* 422 */ }
```
- [ ] **Step 2:跑测试**
```bash
cd server && go test ./internal/tenant/transport/ -run TestDesktop -v
```
- [ ] **Step 3commit**
```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.1pnpm 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 1package.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 2electron-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 4commit 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`
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`
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 minDoubao/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`
1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress'`
2. 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN
3. 上报服务端
4. 单元测试:publish submitting 崩溃 → UNKNOWNmonitor 崩溃 → 重跑无副作用
### 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 H11.5 周 · 与 A.1/A.2 并行)
### Task A.3.1packages/ui-shared 骨架
**Files:**
- Create: `packages/ui-shared/package.json`
- Create: `packages/ui-shared/src/index.ts`
- [ ] **Step 1package 初始化**
```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 2commit**
```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.2Design 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 1CSS 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: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--radius-sm: 4px;
--radius-md: 8px;
}
html[data-theme='dark'] {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
```
- [ ] **Step 2AntD 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 3commit**
```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 5commit**
```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.5admin-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.1DoubaoAdapter 基于 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 parserhandle 豆包特定 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`
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
---
## 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 3Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选**
- [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义**
- [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:<id>` SSE**
- [ ] **Step 6:进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link**
- [ ] **Step 7E2E 测试(Playwright admin-web**
- [ ] **Step 8commit**
### Task A.6.2desktop-client 发布管理页
**Files:**
- Modify: `apps/desktop-client/src/renderer/components/DesktopShell.vue`
- Create: `apps/desktop-client/src/renderer/views/PublishManagementView.vue`
- Modify: `apps/desktop-client/src/main/transport/api-client.ts`
- [ ] **Step 1:侧栏新增“发布管理”入口**
- [ ] **Step 2:调用 `GET /api/desktop/publish-tasks` 展示待发布队列 + 历史发送结果**
- [ ] **Step 3:历史列表覆盖 succeeded / failed / unknown / aborted**
- [ ] **Step 4:对任一历史 publish task 提供“再次发送”按钮,调用 `POST /api/desktop/publish-tasks/{id}/retry`**
- [ ] **Step 5:E2E 测试离线积压多篇任务逐条发送与手动补发路径**
---
## Phase A.7 · 测试基建 + Mac 公证(1 周)
### Task A.7.1Fake 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.2Playwright `_electron.launch` 跑端到端
- 启动 fake server + fake 平台
- 注入伪账号
- 派监控任务 + 发布任务
- 断言 result 回调格式
### Task A.7.3Mac 公证 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 2CI 跑通一次 `.dmg` 产出 + notarize 成功**
### Task A.7.4autoUpdater smoke unit tests
- 版本比较
- 签名验签
- `allowDowngrade` 决策
- `minClientVersion` 校验
---
## Phase A.8 · Exit bar0.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.3Sign-off + 打 tag
- [ ] 所有 Phase 的 exit bar 都 close
- [ ] `git tag plan-a-complete`
- [ ] 在 Plan 文档顶部标 ✅ COMPLETED
---
## Self-Review
**1. Spec coverage:**
- 块 Aruntime 基座):Phase A.2 ✅
- 块 E(服务端协议):Phase A.1 ✅
- 块 H1/H2Phase A.3 + A.6 ✅
- 块 BDoubao):Phase A.4 ✅
- 块 C(头条迁移):Phase A.5 ✅
- 块 I(测试基建):Phase A.7 ✅
- 块 FMac 公证):Phase A.7 ✅
- Spike 矩阵:Phase A.0 ✅
- Exit barPhase 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.4A.1/A.2/A.3 可并行;A.4 依赖 A.2+A.0A.5 依赖 A.2A.6 依赖 A.1+A.3A.7 依赖 A.2+A.4+A.5A.8 依赖所有。DAG 正确。
**5. Gaps check** Plan A 未覆盖块 D7 家 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 开始。