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 文档同步口径。
52 KiB
Plan A · 桌面客户端骨架穿透 Implementation Plan
2026-04-20 当前实现口径:desktop 侧二次人工审核与
manual / waiting_user / parked / from_parked链路已废弃。SaaS 完成人工审核后才创建 publish job;desktop client 只负责执行发布,并提供“发布管理”查看待发布队列、历史结果与手动再次发送。
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 基于 Plan 0 的 workspace 底座,端到端打通一条 Doubao 监控任务 + 一条头条发布任务;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 基座 | 块 A:Main 进程 / session-registry / view-pool / vault / lease-manager / rate-limiter + crash-recovery | 2 周 | A.0 | ✅ 与 A.1 并行 |
| A.3 · ui-shared H1 | packages/ui-shared 骨架 + design tokens + useAccountSelection composable + 5 个组件迁入 |
1.5 周 | Plan 0 | ✅ 与 A.1/A.2 并行 |
| A.4 · 1 监控适配器 Doubao | 基于 A.2 + A.0 spike 结论实现 | 1 周 | A.0, A.2 | — |
| A.5 · 1 发布适配器 头条号 | 基于 A.2 的直发状态机 + 队列节流 + 失败可再次发送 | 1 周 | A.2 | ✅ 与 A.4 并行 |
| A.6 · PublishArticleModal + 发布管理 H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal,并补 desktop publish management 页 | 1 周 | A.1, A.2, A.3 | — |
| A.7 · 测试基建 + Mac 公证 | 块 I + F:fake server / fake 平台 / Playwright _electron + Mac 公证 CI | 1 周 | A.2, A.4, A.5 | ✅ 与 A.6 并行 |
| A.8 · Exit bar | E2E + 混沌 + 真实账号冒烟 + reconcile 闭环演练 | 0.5 周 | ALL | — |
合计 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 工作区
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:
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
git add tools/spike-cdp/
git commit -m "chore(spike): minimal Electron CDP spike harness"
Task A.0.2:对 Doubao 做三态 spike
- Step 1:登录 Doubao 账号(手工)
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):
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 写首个条目:
# 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 结果
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)
-- 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)
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 2:down migration
-- 还原: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:跑迁移
cd server && make migrate-up
- Step 4:commit
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 路由反推必须的查询:
-- 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
-- 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(租约协议核心)
-- 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
-- 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 4:sqlc generate + 编译
cd server && sqlc generate && go build ./...
- Step 5:commit
git add server/internal/tenant/repository/
git commit -m "feat(sqlc): desktop task lease queries and publish management listing"
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:
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:
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(列表 + 再次发送)
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 4:Tenant events SSE handler(参考 spec §6.8.4)
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:编译 + 集成测试
cd server && go build ./... && go test ./internal/tenant/transport/
- Step 7:commit
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
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
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 事件。收到时:
queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID)
for _, t := range queuedTasks {
bus.PublishTaskAvailable(clientID, t.ID)
}
- Step 5:commit
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:核心测试用例
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:跑测试
cd server && go test ./internal/tenant/transport/ -run TestDesktop -v
- Step 3:commit
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
{
"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 能启动
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
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)都按如下模板做:
- 写接口 + 失败测试(Vitest)
- 写最小实现
- 跑测试通过
- commit
下面给每个模块的最小功能清单;具体 bite-sized 步骤由 subagent 在 executing-plans 时展开。
Task A.2.2: session-registry.ts + view-pool.ts
功能清单(每条对应一个子 Task,TDD 实现):
SessionRegistry.create(platform)→ 分配新 partition (persist:acc-<uuid>),返回 sessionSessionRegistry.get(accountId)→ 已存在则返回,否则 lazy createViewPool.acquireHot(accountId)→ 创建或复用 WebContentsViewViewPool.demoteToCool(accountId)→ 30 min 空闲后 close view but keep session partition on disk- 单元测试:Hot/Cold transitions + partition 持久化 + RAM baseline 断言
Task A.2.3: vault.ts + vault-export.ts
Vault.writeEncrypted(key, value)→ 用safeStorage.encryptString;Linux 检测 backendVault.readEncrypted(key)→ 解密;backend mismatch 返 error 不 panicVaultExport.exportToFile(path, passphrase)→ PBKDF2 + AES-GCM 用户口令二次加密VaultExport.importFromFile(path, passphrase)→ 解密后用新环境 safeStorage 重加密- 单元测试:Linux basic_text fallback 告警 + 跨环境导出/导入
Task A.2.4: lease-manager.ts(关键)
LeaseManager.acquire(taskId)→POST /tasks/leaseLeaseManager.extend(lease_token)→ 每 60s 自动调LeaseManager.complete(result)→POST /tasks/{id}/resultLeaseManager.cancel(taskId)/clearLocalState()→ 终态或放弃时清本地 lease- 单元测试:409 抢占幂等、断线重连带同一 attempt 的 409 处理、终态后不再续租
Task A.2.5: rate-limiter.ts
- Token bucket per (platform, account),桶容量 =
hourlyBudget - 业务 / 保活 / 探活 同桶扣减
- 429 / captcha 响应识别 → 指数退避
powerMonitor suspend/resume→ 暂停 + 错峰 warm-up- 单元测试:桶耗尽时拒绝;resume 错峰分批
Task A.2.6: keep-alive.ts
- 按 spec §4.4 分层:probe 30 min / heartbeat 8 min(Doubao/Qwen)
- 错峰:
hash(accountId) mod period ± 30s - 静音时段(用户本地时区 0:00–7:00)
- 失败升级 →
health=expired→ Tray 红点 + OS 通知 - 单元测试:多账号错峰不同步;健康状态迁移
Task A.2.7: crash-recovery.ts
- 启动时
SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress' - 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN
- 上报服务端
- 单元测试:publish submitting 崩溃 → UNKNOWN;monitor 崩溃 → 重跑无副作用
Task A.2.8: transport/api-client.ts + transport/sse-client.ts
- 基于
@geo/http-client,所有请求自动注入Authorization: Bearer <client_token> SseClient:EventTarget 类(不依赖 Vue),包含指数退避重连、Last-Event-ID、event_id 去重、snapshot 首帧处理- 60s pull 兜底(SSE disconnect 超 X 秒转 pull)
- 单元测试:重连、重放、去重、fallback switching
Task A.2.9: tracing/ 三件套(Alpha 范围)
Recorder:按 Plan 开关,默认关闭;失败时自动 flush rolling bufferTraceWriter:流式 zip,最多 50 份 / 500 MBRedactor:Cookie / Authorization / password 等自动<redacted>- 单元测试:脱敏不漏字段;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 初始化
mkdir -p packages/ui-shared/src
cd packages/ui-shared && pnpm init
package.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
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
: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 2:AntD theme config
// 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
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:写失败测试
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:跑失败
cd packages/ui-shared && pnpm test -- useAccountSelection
Expected: FAIL。
- Step 3:写实现
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:跑通过
cd packages/ui-shared && pnpm test -- useAccountSelection
Expected: PASS。
- Step 5:commit
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:
useSseSubscriptioncomposable(基于@geo/http-client的SseClient,提供 Vue 响应式绑定)
useSseSubscription 关键点:
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 实施):
probe(ctx)→ 打开doubao.com,检查登录态 DOM 标志keepAlive(ctx)→ 访问私有 API 触发 ms_token 刷新query(ctx, task)→ 按 A.0 matrix 选 transport 分支抓 SSE / fetch-stream- Provider parser(handle 豆包特定 payload schema)
- 所有 CDP 调用 wrap 进
_shared/debugger-helper.ts - 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
模块清单:
probe+keepAlivepublish(ctx, task)状态机:filling → submitting → done/failed/unknown- 接入 rate limiter,离线积压任务按账号节流逐篇外发,避免过快触发平台风控
- 成功/失败/unknown/aborted 任务进入 desktop publish management,可手动再次发送
- 集成测试:连续 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 3:Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选
-
Step 4:提交用新
POST /api/tenant/publish-jobs展开语义 -
Step 5:进度面板走
/api/tenant/events?topic=job:<id>SSE -
Step 6:进度面板展示桌面端“发布管理”入口与排队提示,不再出现二次审核 deep link
-
Step 7:E2E 测试(Playwright admin-web)
-
Step 8:commit
Task A.6.2:desktop-client 发布管理页
Files:
-
Modify:
apps/desktop-client/src/renderer/components/DesktopShell.vue -
Create:
apps/desktop-client/src/renderer/views/PublishManagementView.vue -
Modify:
apps/desktop-client/src/main/transport/api-client.ts -
Step 1:侧栏新增“发布管理”入口
-
Step 2:调用
GET /api/desktop/publish-tasks展示待发布队列 + 历史发送结果 -
Step 3:历史列表覆盖 succeeded / failed / unknown / aborted
-
Step 4:对任一历史 publish task 提供“再次发送”按钮,调用
POST /api/desktop/publish-tasks/{id}/retry -
Step 5:E2E 测试离线积压多篇任务逐条发送与手动补发路径
Phase A.7 · 测试基建 + Mac 公证(1 周)
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
- 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.md2026-04-18-plan-a-desktop-skeleton.md
Plan 0 是 Plan A 的硬前置。先执行 Plan 0,打完 plan-0-complete tag 后再启 Plan A。
两种执行方式:
- Subagent-Driven(推荐) — 每个 Task 派一个 fresh subagent 执行,两段式 review,快速迭代。REQUIRED SUB-SKILL:
superpowers:subagent-driven-development - Inline Execution — 当前会话里顺序执行 Task,每 5-10 Task 一个 checkpoint review。REQUIRED SUB-SKILL:
superpowers:executing-plans
用户可在下一轮对话里选执行方式并指定从哪条 Task 开始。