Files
geo/docs/superpowers/plans/2026-04-18-plan-a-desktop-skeleton.md
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

52 KiB
Raw Permalink Blame History

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-typesrenderer 基于新建 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 工作区

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')

  // 实测矩阵 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
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 AEventSource):记录 params.data 字段。 Path BFetch):

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 6Go / No-Go 决策

  • supported → A.4 可开工,parser 按实测 schema 写。

  • degraded → 记 workaround 条目,A.4 按 workaround 实现。

  • blocked(所有 CDP 路径都拿不到 body)→ 升级到 fallback 决策DOM-only 降级 或 移出 Plan A 范围)。此时继续,回产品线做范围决策。

  • Step 7commit 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.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

-- 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
-- 还原: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 4commit
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 路由反推必须的查询:

-- 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 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 + 编译
cd server && sqlc generate && go build ./...
  • Step 5commit
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

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

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 4Tenant 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 5router.go 把所有新 handler 挂到路由白名单(替换 Plan 0 的 notImplementedHandler

把 Task 0.3.2 的占位 handler 一一替换成上面的真实 handler。

  • Step 6:编译 + 集成测试
cd server && go build ./... && go test ./internal/tenant/transport/
  • Step 7commit
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

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
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 事件。收到时:

queuedTasks, _ := repo.ListQueuedTasksByClient(ctx, clientID)
for _, t := range queuedTasks {
    bus.PublishTaskAvailable(clientID, t.ID)
}
  • Step 5commit
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 3commit
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

{
  "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 能启动
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
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.encryptStringLinux 检测 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. SseClientEventTarget 类(不依赖 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. RedactorCookie / 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 初始化

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 2commit
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

: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
// 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
git add packages/ui-shared/src/tokens/
git commit -m "feat(ui-shared): design tokens (CSS variables + AntD theme light/dark)"

Task A.3.3useAccountSelection 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 5commit
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.vuetag 多选控件)
  • A.3.4.4 HealthBadge.vue
  • A.3.4.5 useSseSubscription composable(基于 @geo/http-clientSseClient,提供 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.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
- 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 开始。