# 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 工作区** ```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> | | <描述> | <抓包附件 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) 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** ```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 -- 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 + 编译** ```bash cd server && sqlc generate && go build ./... ``` - [ ] **Step 5:commit** ```bash 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`: ```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 { /* ... */ })}) } ``` - [ ] **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 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: EX 30`。 - [ ] **Step 4:offline→online 监听器** 在每个 server 实例启动 worker,订阅 Redis keyspace notification `expired` / `set`,检测 `presence:` 的 set 事件。收到时: ```go queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID) for _, t := range queuedTasks { 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" ``` ### 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 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-`),返回 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 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` 1. 启动时 `SELECT FROM local_tasks WHERE lease_client_id = self AND status = 'in_progress'` 2. 按 task.kind 分治:monitor → 重跑;publish 已进 submitting → UNKNOWN 3. 上报服务端 4. 单元测试:publish submitting 崩溃 → UNKNOWN;monitor 崩溃 → 重跑无副作用 ### Task A.2.8: `transport/api-client.ts` + `transport/sse-client.ts` 1. 基于 `@geo/http-client`,所有请求自动注入 `Authorization: Bearer ` 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 等自动 `` 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: 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** ```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()) const search = ref('') const tagFilter = ref([]) const platformFilter = ref([]) 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() 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(url: string) { const events = ref([]) 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` 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 3:Modal UI:平台折叠块 + tag 筛选 + 搜索 + 三级批量勾选** - [ ] **Step 4:提交用新 `POST /api/tenant/publish-jobs` 展开语义** - [ ] **Step 5:进度面板走 `/api/tenant/events?topic=job:` 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` ```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 开始。