diff --git a/docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md b/docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md new file mode 100644 index 0000000..5f3c41f --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md @@ -0,0 +1,1652 @@ +# Plan 0 · Workspace 底座一等化 Implementation Plan + +> **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:** 在现有完整 tenant-native 代码库上引入 workspace 作为后端一等安全边界,仅对 desktop/account/monitoring 三条线做 workspace 一等化(窄 workspace 化策略),为 Plan A(桌面客户端)解除前置阻塞。 + +**Architecture:** 新增 `workspaces` + `workspace_memberships` 表 → 扩 `auth.Actor/Claims` + JWT issuance → 新 `WorkspaceScope` middleware(仅挂指定路由白名单) → repo/service/cache/监控域的 workspace_id 注入 → admin-web 五个页面补 workspace context → CI guard 防回归。articles / brands / kol-templates / publish-batches / images 等现有业务线维持 `tenant == workspace`,不动。 + +**Tech Stack:** Go (gin + sqlc + golang-jwt/jwt/v5) · PostgreSQL · Redis cache · Vue 3 (admin-web) · pnpm workspace · golang-migrate + +**Spec reference:** `docs/superpowers/specs/2026-04-18-electron-desktop-client-design.md` §12 Plan 0 + +--- + +## Preflight + +### Task 0.0.1: 全量 baseline 运行现有测试 + +- [ ] **Step 1:运行 Go 测试 baseline** + +```bash +cd server && go test ./... 2>&1 | tee /tmp/baseline-go-tests.log +``` +Expected: all green. 记录当前绿基线。 + +- [ ] **Step 2:运行 admin-web 测试 baseline** + +```bash +cd apps/admin-web && pnpm test 2>&1 | tee /tmp/baseline-admin-web-tests.log +``` +Expected: all green. + +- [ ] **Step 3:列出所有现有 sqlc 查询涉及的表** + +```bash +cd server && grep -h "FROM\|JOIN" internal/tenant/repository/queries/*.sql | grep -oE '(FROM|JOIN) [a-z_]+' | sort -u > /tmp/existing-tables.txt +cat /tmp/existing-tables.txt +``` +Expected: 列表里能看到 `tenants`, `articles`, `brands`, `platform_accounts`, `plugin_installations`, `tenant_monitoring_quotas` 等。 + +--- + +## Phase 0.1 · Workspaces 表迁移 + +### Task 0.1.1:新增 workspaces + workspace_memberships 迁移 + +**Files:** +- Create: `server/migrations/20260420100000_create_workspaces.up.sql` +- Create: `server/migrations/20260420100000_create_workspaces.down.sql` + +- [ ] **Step 1:写 up migration** + +文件 `server/migrations/20260420100000_create_workspaces.up.sql`: + +```sql +-- Phase 1 窄 workspace 化:引入 workspaces 表 + memberships。 +-- 约束:Phase 1 下每个 tenant 固定一个 default workspace(名为 "default"), +-- 所有现有用户的 primary_workspace_id 绑定到该 workspace。 +-- 未来 Phase 2 真做多 workspace 时再放开 memberships UI。 + +CREATE TABLE workspaces ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, slug) +); + +CREATE UNIQUE INDEX idx_workspaces_tenant_default + ON workspaces (tenant_id) + WHERE is_default = TRUE; + +CREATE TABLE workspace_memberships ( + id BIGSERIAL PRIMARY KEY, + workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + role TEXT NOT NULL DEFAULT 'member', + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, user_id) +); + +CREATE UNIQUE INDEX idx_memberships_user_primary + ON workspace_memberships (user_id) + WHERE is_primary = TRUE; + +-- Phase 1 一次性 seed:为每个已有 tenant 创建 default workspace + 为所有用户绑定 +INSERT INTO workspaces (tenant_id, name, slug, is_default) +SELECT id, 'Default', 'default', TRUE FROM tenants +ON CONFLICT DO NOTHING; + +INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary) +SELECT w.id, u.id, u.tenant_id, 'member', TRUE +FROM users u +JOIN workspaces w ON w.tenant_id = u.tenant_id AND w.is_default = TRUE +ON CONFLICT DO NOTHING; +``` + +- [ ] **Step 2:写 down migration** + +文件 `server/migrations/20260420100000_create_workspaces.down.sql`: + +```sql +DROP TABLE IF EXISTS workspace_memberships; +DROP TABLE IF EXISTS workspaces; +``` + +- [ ] **Step 3:本地跑一次迁移** + +```bash +cd server && make migrate-up 2>&1 | tail -20 +``` +Expected: "migrated" / "no change",退出码 0。 + +- [ ] **Step 4:检查 seed 数据** + +```bash +psql -h localhost -U postgres -d geo_tenant -c "SELECT tenant_id, COUNT(*) FROM workspaces GROUP BY tenant_id;" +psql -h localhost -U postgres -d geo_tenant -c "SELECT user_id, workspace_id, is_primary FROM workspace_memberships LIMIT 5;" +``` +Expected: 每个 tenant 恰有一行 workspace;每个 user 恰有一行 is_primary=true 的 membership。 + +- [ ] **Step 5:测试 down migration 可逆** + +```bash +cd server && make migrate-down STEPS=1 && make migrate-up +``` +Expected: 两次都成功,数据被重新 seed 相同。 + +- [ ] **Step 6:commit** + +```bash +git add server/migrations/20260420100000_create_workspaces.up.sql server/migrations/20260420100000_create_workspaces.down.sql +git commit -m "feat(workspace): add workspaces + workspace_memberships tables with default seed" +``` + +### Task 0.1.2:给五张 desktop 相关表补 workspace_id(Phase 1 范围) + +**Files:** +- Create: `server/migrations/20260420100010_add_workspace_to_desktop_tables.up.sql` +- Create: `server/migrations/20260420100010_add_workspace_to_desktop_tables.down.sql` + +**背景:**五张表是 `desktop_clients` / `platform_accounts` / `desktop_tasks` / `desktop_publish_jobs` / `tenant_monitoring_quotas`。但前四张在 Plan A 中才会新建,这里先只对现存表 `tenant_monitoring_quotas` 动。`plugin_installations` → `desktop_clients` 的重命名在 Plan A 做。 + +- [ ] **Step 1:写 up migration** + +文件 `server/migrations/20260420100010_add_workspace_to_desktop_tables.up.sql`: + +```sql +-- Phase 1 窄 workspace 化:给现存监控相关表加 workspace_id。 +-- 注:desktop_clients / platform_accounts / desktop_tasks / desktop_publish_jobs +-- 这 4 张在 Plan A 中才会新建,届时 schema 里直接包含 workspace_id; +-- Plan 0 阶段只 alter 已有的 tenant_monitoring_quotas 与 platform_accounts(旧)。 + +-- 1. tenant_monitoring_quotas 加 workspace_id + 改 primary_installation_id → primary_client_id +ALTER TABLE tenant_monitoring_quotas + ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id); + +UPDATE tenant_monitoring_quotas q +SET workspace_id = w.id +FROM workspaces w +WHERE w.tenant_id = q.tenant_id AND w.is_default = TRUE + AND q.workspace_id IS NULL; + +ALTER TABLE tenant_monitoring_quotas + ALTER COLUMN workspace_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_monitoring_quotas_workspace + ON tenant_monitoring_quotas (workspace_id); + +-- 2. 已存在的 platform_accounts(旧 plugin 时代)加 workspace_id。 +-- Plan A 会整表重建为新 schema;这里只为 Plan 0 的 WorkspaceScope 过滤打底。 +ALTER TABLE platform_accounts + ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id); + +UPDATE platform_accounts a +SET workspace_id = w.id +FROM workspaces w +WHERE w.tenant_id = a.tenant_id AND w.is_default = TRUE + AND a.workspace_id IS NULL; + +ALTER TABLE platform_accounts + ALTER COLUMN workspace_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_platform_accounts_workspace + ON platform_accounts (workspace_id); +``` + +- [ ] **Step 2:写 down migration** + +文件 `server/migrations/20260420100010_add_workspace_to_desktop_tables.down.sql`: + +```sql +ALTER TABLE platform_accounts DROP COLUMN IF EXISTS workspace_id; +ALTER TABLE tenant_monitoring_quotas DROP COLUMN IF EXISTS workspace_id; +``` + +- [ ] **Step 3:跑迁移 + 校验 backfill** + +```bash +cd server && make migrate-up +psql -h localhost -U postgres -d geo_tenant -c "SELECT COUNT(*) FROM platform_accounts WHERE workspace_id IS NULL;" +psql -h localhost -U postgres -d geo_tenant -c "SELECT COUNT(*) FROM tenant_monitoring_quotas WHERE workspace_id IS NULL;" +``` +Expected: 两个 count 都是 0。 + +- [ ] **Step 4:commit** + +```bash +git add server/migrations/20260420100010_add_workspace_to_desktop_tables.* +git commit -m "feat(workspace): backfill workspace_id on platform_accounts and tenant_monitoring_quotas" +``` + +--- + +## Phase 0.2 · Auth 层扩 workspace_id + +### Task 0.2.1:Actor + Claims 加 PrimaryWorkspaceID + +**Files:** +- Modify: `server/internal/shared/auth/context.go` +- Modify: `server/internal/shared/auth/claims.go` +- Modify: `server/internal/shared/auth/context_test.go` + +- [ ] **Step 1:先写 context 测试(TDD)** + +向 `server/internal/shared/auth/context_test.go` 追加: + +```go +func TestActorCarriesPrimaryWorkspaceID(t *testing.T) { + ctx := WithActor(context.Background(), Actor{ + UserID: 10, + TenantID: 20, + Role: "member", + PrimaryWorkspaceID: 30, + }) + actor, ok := ActorFromCtx(ctx) + if !ok { + t.Fatal("actor missing") + } + if actor.PrimaryWorkspaceID != 30 { + t.Errorf("want workspace 30, got %d", actor.PrimaryWorkspaceID) + } +} +``` + +- [ ] **Step 2:跑测试确认失败** + +```bash +cd server && go test ./internal/shared/auth/ -run TestActorCarriesPrimaryWorkspaceID -v +``` +Expected: FAIL with `unknown field PrimaryWorkspaceID`. + +- [ ] **Step 3:修 Actor 结构** + +修改 `server/internal/shared/auth/context.go` 第 5–9 行: + +```go +type Actor struct { + UserID int64 + TenantID int64 + Role string + PrimaryWorkspaceID int64 // Phase 1 窄 workspace 化下每个用户固定一个 primary +} +``` + +- [ ] **Step 4:跑测试确认通过** + +```bash +cd server && go test ./internal/shared/auth/ -v +``` +Expected: all pass. + +- [ ] **Step 5:扩 Claims** + +修改 `server/internal/shared/auth/claims.go`: + +```go +package auth + +import ( + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + PrimaryWorkspaceID int64 `json:"primary_workspace_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} +``` + +- [ ] **Step 6:跑全部 auth 测试** + +```bash +cd server && go test ./internal/shared/auth/ -v +``` +Expected: all pass。如果有之前的 JWT 测试 assert claim 字段,后续 Task 0.2.2 里改。 + +- [ ] **Step 7:commit** + +```bash +git add server/internal/shared/auth/context.go server/internal/shared/auth/claims.go server/internal/shared/auth/context_test.go +git commit -m "feat(auth): add PrimaryWorkspaceID to Actor and Claims" +``` + +### Task 0.2.2:JWT issuance 带 PrimaryWorkspaceID + +**Files:** +- Modify: `server/internal/shared/auth/jwt.go` +- Modify: `server/internal/shared/auth/jwt_test.go` + +- [ ] **Step 1:写失败测试** + +在 `server/internal/shared/auth/jwt_test.go` 追加: + +```go +func TestIssueCarriesPrimaryWorkspaceID(t *testing.T) { + mgr := NewManager("test-secret", time.Minute, time.Hour) + pair, err := mgr.Issue(1, 2, "member", 3) + if err != nil { + t.Fatal(err) + } + claims, err := mgr.Parse(pair.AccessToken) + if err != nil { + t.Fatal(err) + } + if claims.PrimaryWorkspaceID != 3 { + t.Errorf("want primary workspace 3, got %d", claims.PrimaryWorkspaceID) + } +} +``` + +- [ ] **Step 2:跑确认失败** + +```bash +cd server && go test ./internal/shared/auth/ -run TestIssueCarriesPrimaryWorkspaceID -v +``` +Expected: FAIL(Issue 签名只有 3 个参数)。 + +- [ ] **Step 3:改 Issue 签名** + +修改 `server/internal/shared/auth/jwt.go` 第 34 行起的 `Issue` 方法签名和内部 claims: + +```go +func (m *Manager) Issue(userID, tenantID int64, role string, primaryWorkspaceID int64) (*TokenPair, error) { + now := time.Now() + accessJTI := uuid.New().String() + refreshJTI := uuid.New().String() + + accessClaims := Claims{ + UserID: userID, + TenantID: tenantID, + PrimaryWorkspaceID: primaryWorkspaceID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ID: accessJTI, + Subject: "access", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)), + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(m.secret) + if err != nil { + return nil, fmt.Errorf("sign access token: %w", err) + } + + refreshClaims := Claims{ + UserID: userID, + TenantID: tenantID, + PrimaryWorkspaceID: primaryWorkspaceID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ID: refreshJTI, + Subject: "refresh", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTTL)), + }, + } + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(m.secret) + if err != nil { + return nil, fmt.Errorf("sign refresh token: %w", err) + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + AccessJTI: accessJTI, + RefreshJTI: refreshJTI, + ExpiresAt: now.Add(m.accessTTL).Unix(), + }, nil +} +``` + +- [ ] **Step 4:跑确认新测试通过** + +```bash +cd server && go test ./internal/shared/auth/ -v +``` +Expected: `TestIssueCarriesPrimaryWorkspaceID` pass;其它测试可能因签名变更失败,**下一步一起修**。 + +- [ ] **Step 5:批量修改所有 Issue 调用点** + +```bash +cd server && grep -rn "\.Issue(" --include="*.go" | grep -v "_test.go" +``` + +预期只有 login/refresh 两处需要改(见下 Task 0.2.3)。如果有别的地方,暂用 `0` 占位,后续根据上下文补真实 `primary_workspace_id`(可从 `user.PrimaryWorkspaceID` 字段查)。 + +- [ ] **Step 6:commit** + +```bash +git add server/internal/shared/auth/jwt.go server/internal/shared/auth/jwt_test.go +git commit -m "feat(auth): Issue JWT carries PrimaryWorkspaceID" +``` + +### Task 0.2.3:Login / Refresh handler 查 primary workspace 并传入 Issue + +**Files:** +- Modify: `server/internal/tenant/transport/auth_handler.go` +- Modify: `server/internal/tenant/repository/queries/auth.sql` +- Modify: `server/internal/shared/auth/middleware.go` + +- [ ] **Step 1:查看现有 Login 逻辑** + +```bash +grep -n "mgr.Issue\|Issue(" server/internal/tenant/transport/auth_handler.go +``` + +- [ ] **Step 2:在 queries/auth.sql 加查询** + +追加到 `server/internal/tenant/repository/queries/auth.sql`: + +```sql +-- name: GetPrimaryWorkspaceForUser :one +SELECT w.id +FROM workspaces w +JOIN workspace_memberships m ON m.workspace_id = w.id +WHERE m.user_id = sqlc.arg(user_id) AND m.is_primary = TRUE +LIMIT 1; +``` + +- [ ] **Step 3:重生成 sqlc 代码** + +```bash +cd server && sqlc generate 2>&1 +go build ./... +``` +Expected: 无错误,`internal/tenant/repository/generated/` 下新增 `GetPrimaryWorkspaceForUser` 方法。 + +- [ ] **Step 4:修改 Login handler** + +在 `server/internal/tenant/transport/auth_handler.go` 的 `Login` 方法里,在验证密码成功后: + +```go +primaryWorkspaceID, err := h.app.AuthRepo.GetPrimaryWorkspaceForUser(ctx, user.ID) +if err != nil { + response.Error(c, response.ErrInternal(50000, "workspace_missing", + "primary workspace not set for user")) + return +} + +pair, err := h.app.JWT.Issue(user.ID, user.TenantID, user.Role, primaryWorkspaceID) +``` + +同样改 `Refresh` 方法。 + +- [ ] **Step 5:修 auth middleware 把 Claims 的 workspace 放进 Actor** + +在 `server/internal/shared/auth/middleware.go`(或同等位置),把 `Claims → Actor` 的转换补上: + +```go +actor := Actor{ + UserID: claims.UserID, + TenantID: claims.TenantID, + PrimaryWorkspaceID: claims.PrimaryWorkspaceID, + Role: claims.Role, +} +``` + +- [ ] **Step 6:跑所有 auth 相关测试** + +```bash +cd server && go test ./internal/shared/auth/... ./internal/tenant/transport/... -v +``` +Expected: all pass。 + +- [ ] **Step 7:写 e2e 集成测试验证 Login 返回 JWT 带 workspace** + +在 `server/internal/tenant/transport/auth_handler_test.go` 追加(若文件不存在就新建): + +```go +func TestLoginReturnsJWTWithPrimaryWorkspace(t *testing.T) { + app := setupTestApp(t) + defer app.Cleanup() + + // seed: 一个 user + tenant + 一个 primary workspace + userID, tenantID, wsID := seedUserWithDefaultWorkspace(t, app) + + req := httptest.NewRequest("POST", "/api/auth/login", + strings.NewReader(`{"email":"test@example.com","password":"pw"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app.Engine.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("login failed: %d %s", rec.Code, rec.Body.String()) + } + + var resp struct{ Data struct{ AccessToken string `json:"access_token"` } } + json.Unmarshal(rec.Body.Bytes(), &resp) + + claims, _ := app.JWT.Parse(resp.Data.AccessToken) + if claims.PrimaryWorkspaceID != wsID { + t.Errorf("want workspace %d, got %d", wsID, claims.PrimaryWorkspaceID) + } +} +``` + +- [ ] **Step 8:跑 e2e 测试** + +```bash +cd server && go test ./internal/tenant/transport/ -run TestLoginReturnsJWTWithPrimaryWorkspace -v +``` +Expected: PASS。 + +- [ ] **Step 9:commit** + +```bash +git add server/internal/tenant/transport/auth_handler*.go \ + server/internal/tenant/repository/queries/auth.sql \ + server/internal/tenant/repository/generated/ \ + server/internal/shared/auth/middleware.go +git commit -m "feat(auth): login/refresh inject PrimaryWorkspaceID into JWT" +``` + +--- + +## Phase 0.3 · WorkspaceScope middleware + +### Task 0.3.1:WorkspaceScope 中间件实现 + +**Files:** +- Create: `server/internal/shared/middleware/workspace_scope.go` +- Create: `server/internal/shared/middleware/workspace_scope_test.go` + +- [ ] **Step 1:写失败测试** + +创建 `server/internal/shared/middleware/workspace_scope_test.go`: + +```go +package middleware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/middleware" +) + +func TestWorkspaceScopeInjectsWorkspaceIDFromActor(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + ctx := auth.WithActor(c.Request.Context(), auth.Actor{ + UserID: 1, TenantID: 2, PrimaryWorkspaceID: 42, Role: "member", + }) + ctx = middleware.WithTenantID(ctx, 2) + c.Request = c.Request.WithContext(ctx) + c.Next() + }) + r.Use(middleware.WorkspaceScope()) + + var captured int64 + r.GET("/x", func(c *gin.Context) { + captured = middleware.WorkspaceIDFromCtx(c.Request.Context()) + c.Status(200) + }) + + rec := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(context.Background(), "GET", "/x", nil) + r.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("want 200, got %d", rec.Code) + } + if captured != 42 { + t.Errorf("want workspace 42, got %d", captured) + } +} + +func TestWorkspaceScopeRejectsMissingWorkspace(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + ctx := auth.WithActor(c.Request.Context(), auth.Actor{ + UserID: 1, TenantID: 2, PrimaryWorkspaceID: 0, + }) + c.Request = c.Request.WithContext(ctx) + c.Next() + }) + r.Use(middleware.WorkspaceScope()) + r.GET("/x", func(c *gin.Context) { c.Status(200) }) + + rec := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(context.Background(), "GET", "/x", nil) + r.ServeHTTP(rec, req) + + if rec.Code != 401 { + t.Errorf("want 401 when workspace missing, got %d", rec.Code) + } +} + +func TestWorkspaceScopeIgnoresQueryParam(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + ctx := auth.WithActor(c.Request.Context(), auth.Actor{ + UserID: 1, TenantID: 2, PrimaryWorkspaceID: 42, + }) + c.Request = c.Request.WithContext(ctx) + c.Next() + }) + r.Use(middleware.WorkspaceScope()) + var captured int64 + r.GET("/x", func(c *gin.Context) { + captured = middleware.WorkspaceIDFromCtx(c.Request.Context()) + c.Status(200) + }) + + rec := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(context.Background(), "GET", "/x?workspace_id=999", nil) + r.ServeHTTP(rec, req) + + if captured != 42 { + t.Errorf("query param must be ignored; want 42, got %d", captured) + } +} +``` + +- [ ] **Step 2:跑确认失败** + +```bash +cd server && go test ./internal/shared/middleware/ -run TestWorkspaceScope -v +``` +Expected: FAIL(WorkspaceScope 未定义)。 + +- [ ] **Step 3:写实现** + +创建 `server/internal/shared/middleware/workspace_scope.go`: + +```go +package middleware + +import ( + "context" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type workspaceIDKey struct{} + +// WorkspaceScope 是 Phase 1 窄 workspace 化的入口 middleware。 +// 仅挂在 desktop/account/monitoring 三条线的路由白名单上。 +// workspace_id 来源是 Actor.PrimaryWorkspaceID(由 JWT claim 派生), +// 不接受任何来自 query / body / header 的 workspace 参数。 +// +// 必须挂在 TenantScope 之后。 +func WorkspaceScope() gin.HandlerFunc { + return func(c *gin.Context) { + actor, ok := auth.ActorFromCtx(c.Request.Context()) + if !ok || actor.PrimaryWorkspaceID == 0 { + response.Error(c, response.ErrUnauthorized(40105, "workspace_scope_missing", + "workspace context is required")) + c.Abort() + return + } + ctx := WithWorkspaceID(c.Request.Context(), actor.PrimaryWorkspaceID) + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} + +func WithWorkspaceID(ctx context.Context, workspaceID int64) context.Context { + return context.WithValue(ctx, workspaceIDKey{}, workspaceID) +} + +func WorkspaceIDFromCtx(ctx context.Context) int64 { + if v, ok := ctx.Value(workspaceIDKey{}).(int64); ok { + return v + } + return 0 +} +``` + +- [ ] **Step 4:跑测试全绿** + +```bash +cd server && go test ./internal/shared/middleware/ -v +``` +Expected: all pass。 + +- [ ] **Step 5:commit** + +```bash +git add server/internal/shared/middleware/workspace_scope.go server/internal/shared/middleware/workspace_scope_test.go +git commit -m "feat(middleware): add WorkspaceScope driven by Actor.PrimaryWorkspaceID" +``` + +### Task 0.3.2:把 WorkspaceScope 挂到路由白名单 + +**Files:** +- Modify: `server/internal/tenant/transport/router.go` + +**白名单**(与 spec §12 Plan 0 一致):`/api/tenant/events`、`/api/tenant/accounts*`、`/api/tenant/publish-jobs*`、`/api/tenant/clients`、`/api/tenant/monitoring-*`、`/api/tenant/tasks/{id}/reconcile`、`/api/tenant/tasks/{id}/cancel`。 + +**注:** `/api/tenant/monitoring-plans` 和 `/api/tenant/monitoring-results` 等目前可能还不存在(Plan A 补齐),但为 Plan 0 作为 group 先挂上,handler 在 Plan A 中填充。 + +- [ ] **Step 1:在 router.go 追加 group** + +在 `server/internal/tenant/transport/router.go` 已有 `tenantProtected := protected.Group("/tenant")` 之后、**但在**现有 `workspace / templates / kol / articles` 等子 group 定义**之前**,追加: + +```go +// Phase 1 窄 workspace 化:这些路由走 WorkspaceScope 而不是纯 TenantScope。 +// 处理器会在 Plan A 填入;Plan 0 只保证 middleware 链路对。 +workspaceScoped := tenantProtected.Group("") +workspaceScoped.Use(middleware.WorkspaceScope()) + +// /api/tenant/accounts* +workspaceScoped.GET("/accounts", notImplementedHandler) // Plan A Block E +workspaceScoped.GET("/accounts/:id", notImplementedHandler) + +// /api/tenant/clients +workspaceScoped.GET("/clients", notImplementedHandler) + +// /api/tenant/publish-jobs* +workspaceScoped.GET("/publish-jobs", notImplementedHandler) +workspaceScoped.POST("/publish-jobs", notImplementedHandler) +workspaceScoped.GET("/publish-jobs/:id", notImplementedHandler) +workspaceScoped.POST("/publish-jobs/:id/retry", notImplementedHandler) + +// /api/tenant/tasks/{id}/reconcile|cancel +workspaceScoped.POST("/tasks/:id/reconcile", notImplementedHandler) +workspaceScoped.POST("/tasks/:id/cancel", notImplementedHandler) + +// /api/tenant/monitoring-* +workspaceScoped.GET("/monitoring-plans", notImplementedHandler) +workspaceScoped.POST("/monitoring-plans", notImplementedHandler) +workspaceScoped.GET("/monitoring-results", notImplementedHandler) + +// /api/tenant/events (SSE) +workspaceScoped.GET("/events", notImplementedHandler) + +// /api/tenant/accounts/{id}/request-delete +workspaceScoped.POST("/accounts/:id/request-delete", notImplementedHandler) +``` + +- [ ] **Step 2:在 router.go 底部补一个 notImplementedHandler(临时)** + +```go +func notImplementedHandler(c *gin.Context) { + c.JSON(501, gin.H{"error": "not implemented (Plan 0 placeholder, see Plan A Block E)"}) +} +``` + +- [ ] **Step 3:编译 + 跑路由注册测试** + +```bash +cd server && go build ./... +``` +Expected: 无错误。 + +- [ ] **Step 4:写集成测试 — 这些路由都要求 WorkspaceScope** + +在 `server/internal/tenant/transport/router_test.go`(不存在就新建): + +```go +func TestWorkspaceScopeAppliedToDesktopRoutes(t *testing.T) { + app := setupTestApp(t) + defer app.Cleanup() + + routes := []struct { + method string + path string + }{ + {"GET", "/api/tenant/accounts"}, + {"GET", "/api/tenant/clients"}, + {"GET", "/api/tenant/publish-jobs"}, + {"POST", "/api/tenant/publish-jobs"}, + {"POST", "/api/tenant/tasks/123/reconcile"}, + {"POST", "/api/tenant/tasks/123/cancel"}, + {"GET", "/api/tenant/monitoring-plans"}, + {"GET", "/api/tenant/events"}, + } + + // issue JWT without PrimaryWorkspaceID → should 401 + token := issueTokenWithoutWorkspace(t, app) + for _, r := range routes { + rec := httptest.NewRecorder() + req, _ := http.NewRequest(r.method, r.path, nil) + req.Header.Set("Authorization", "Bearer "+token) + app.Engine.ServeHTTP(rec, req) + if rec.Code != 401 { + t.Errorf("%s %s: want 401 when workspace missing, got %d", + r.method, r.path, rec.Code) + } + } + + // issue JWT with PrimaryWorkspaceID → should hit not-implemented (501) + token = issueTokenWithWorkspace(t, app, 1) + for _, r := range routes { + rec := httptest.NewRecorder() + req, _ := http.NewRequest(r.method, r.path, nil) + req.Header.Set("Authorization", "Bearer "+token) + app.Engine.ServeHTTP(rec, req) + if rec.Code != 501 { + t.Errorf("%s %s: want 501 (placeholder), got %d", + r.method, r.path, rec.Code) + } + } +} +``` + +- [ ] **Step 5:跑 router 测试** + +```bash +cd server && go test ./internal/tenant/transport/ -run TestWorkspaceScopeAppliedToDesktopRoutes -v +``` +Expected: PASS。 + +- [ ] **Step 6:commit** + +```bash +git add server/internal/tenant/transport/router.go server/internal/tenant/transport/router_test.go +git commit -m "feat(router): wire WorkspaceScope on desktop/accounts/publish-jobs/tasks/events whitelist" +``` + +--- + +## Phase 0.4 · Repo query workspace_id 注入(existing tables) + +### Task 0.4.1:platform_accounts queries 加 workspace_id 过滤 + +**Files:** +- Modify: `server/internal/tenant/repository/queries/platform_account.sql`(若不存在,新建;现有可能在 `monitoring.sql` 里) +- Modify: `server/internal/tenant/app/media_service.go` + +- [ ] **Step 1:定位现有查询** + +```bash +grep -rn "platform_accounts" server/internal/tenant/repository/queries/ | head -10 +``` + +- [ ] **Step 2:为每条涉及 platform_accounts 的查询加 `workspace_id = sqlc.arg(workspace_id)`** + +对每条查询,复制现有条件、追加 workspace_id 过滤。以 `ListPlatformAccounts` 为例: + +原: +```sql +-- name: ListPlatformAccounts :many +SELECT * FROM platform_accounts WHERE tenant_id = sqlc.arg(tenant_id) ORDER BY created_at DESC; +``` + +改为: +```sql +-- name: ListPlatformAccounts :many +SELECT * FROM platform_accounts +WHERE tenant_id = sqlc.arg(tenant_id) + AND workspace_id = sqlc.arg(workspace_id) +ORDER BY created_at DESC; +``` + +对仓库里所有涉及 `platform_accounts` 的 select/update/delete 都做同样修改。 + +- [ ] **Step 3:sqlc generate** + +```bash +cd server && sqlc generate +go build ./... +``` +Expected: 编译可能有 N 处调用点 missing 参数,**不修**——下一步让测试驱动。 + +- [ ] **Step 4:service 层传 workspace_id** + +以 `server/internal/tenant/app/media_service.go` 为例,所有调用 `ListPlatformAccounts(ctx, ...)` 的地方加 `middleware.WorkspaceIDFromCtx(ctx)`: + +```go +accounts, err := s.repo.ListPlatformAccounts(ctx, generated.ListPlatformAccountsParams{ + TenantID: middleware.TenantIDFromCtx(ctx), + WorkspaceID: middleware.WorkspaceIDFromCtx(ctx), +}) +``` + +- [ ] **Step 5:编译并修完所有调用点** + +```bash +cd server && go build ./... 2>&1 | grep "error:" | head -20 +``` +逐个修直到编译通过。 + +- [ ] **Step 6:跑全部测试** + +```bash +cd server && go test ./... +``` +Expected: 可能有 fixture 测试因缺 workspace_id 报错,逐个修。 + +- [ ] **Step 7:commit** + +```bash +git add server/internal/tenant/repository/queries/*.sql \ + server/internal/tenant/repository/generated/ \ + server/internal/tenant/app/ +git commit -m "refactor(repo): platform_accounts queries filter by workspace_id" +``` + +### Task 0.4.2:tenant_monitoring_quotas queries 加 workspace_id + +重复 Task 0.4.1 的模式,针对 `server/internal/tenant/repository/queries/` 里所有涉及 `tenant_monitoring_quotas` 的查询。 + +- [ ] **Step 1:定位** + +```bash +grep -rln "tenant_monitoring_quotas" server/internal/tenant/repository/queries/ +``` + +- [ ] **Step 2:每条 select/update/insert 加 workspace_id 过滤(同 Task 0.4.1)** + +- [ ] **Step 3:生成 + 传参 + 编译** + +```bash +cd server && sqlc generate && go build ./... +``` + +- [ ] **Step 4:跑测试** + +```bash +cd server && go test ./... +``` + +- [ ] **Step 5:commit** + +```bash +git add server/internal/tenant/repository/queries/ server/internal/tenant/repository/generated/ server/internal/tenant/app/ +git commit -m "refactor(repo): tenant_monitoring_quotas queries filter by workspace_id" +``` + +### Task 0.4.3:Cross-workspace 防穿透测试(最关键) + +**Files:** +- Create: `server/internal/tenant/app/workspace_penetration_test.go` + +这是 Plan 0 的 **exit bar 硬要求**:至少 10 条越权用例。 + +- [ ] **Step 1:写测试** + +创建 `server/internal/tenant/app/workspace_penetration_test.go`: + +```go +package app_test + +import ( + "context" + "testing" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/middleware" +) + +// setupTwoWorkspaces 在同一 tenant 下创建两个 workspace(A 和 B), +// 同一个 user 是 A 的 primary,B 里 seed 独立资源。 +func setupTwoWorkspaces(t *testing.T) (app *TestApp, wsA, wsB int64, tenantID int64, userID int64) { + // ...略,用 testutil 构造 +} + +func TestListPlatformAccounts_BlocksCrossWorkspace(t *testing.T) { + app, wsA, wsB, tenantID, userID := setupTwoWorkspaces(t) + defer app.Cleanup() + + // Seed:wsB 里有一个账号 + seedPlatformAccount(t, app, tenantID, wsB, "wechat", "acc-b") + + // 以 wsA 的 Actor 调用 List + ctx := auth.WithActor(context.Background(), auth.Actor{ + UserID: userID, TenantID: tenantID, PrimaryWorkspaceID: wsA, + }) + ctx = middleware.WithTenantID(ctx, tenantID) + ctx = middleware.WithWorkspaceID(ctx, wsA) + + accounts, err := app.MediaService.ListAccountsByKind(ctx, "publish") + if err != nil { + t.Fatal(err) + } + if len(accounts) != 0 { + t.Errorf("cross-workspace leak: wsA list returned %d accounts from wsB", len(accounts)) + } +} + +func TestUpdatePlatformAccount_BlocksCrossWorkspace(t *testing.T) { + // 类似上面:seed 在 wsB 的账号,尝试以 wsA 上下文 update → expect not found +} + +func TestDeletePlatformAccount_BlocksCrossWorkspace(t *testing.T) { /* ... */ } +func TestGetMonitoringQuota_BlocksCrossWorkspace(t *testing.T) { /* ... */ } +func TestUpdateMonitoringQuota_BlocksCrossWorkspace(t *testing.T) { /* ... */ } +// 再加 5 条覆盖:heartbeat / resume / lease / result / task list 等 monitoring API 的 cross-workspace 用例 +``` + +**要求:共 10+ 条用例,覆盖所有 workspace-scoped 路由的 List/Get/Update/Delete 动作,每条都验证 "A 上下文访问 B 数据" 返回空 / not-found / 拒绝。** + +- [ ] **Step 2:跑测试** + +```bash +cd server && go test ./internal/tenant/app/ -run TestWorkspace.*Penetration -v +``` +Expected: all pass。如有失败→ repo layer 有 query 漏改,回 Task 0.4.1/0.4.2 补。 + +- [ ] **Step 3:commit** + +```bash +git add server/internal/tenant/app/workspace_penetration_test.go +git commit -m "test(workspace): cross-workspace penetration suite (10+ cases)" +``` + +--- + +## Phase 0.5 · Cache keys workspace-aware + +### Task 0.5.1:把监控相关 cache key 改成包含 workspace_id + +**Files:** +- Modify: `server/internal/tenant/app/cache_support.go` + +- [ ] **Step 1:找到所有 monitoring / account 相关的 cache key 函数** + +```bash +grep -n "func.*CacheKey\|cacheKey" server/internal/tenant/app/cache_support.go +``` + +- [ ] **Step 2:识别需要 workspace-sensitive 的 key** + +Phase 1 范围里需要 workspace-sensitive:`platformAccountListCacheKey`、`monitoringQuotaCacheKey`、`desktopClientListCacheKey` 等。 +不需要(因为 tenant-scope 不变):`workspaceOverviewCacheKey`(老 workspace 页面)、`articleListCacheKey` 等。 + +**注:** `cache_support.go:51-60` 的 `workspaceOverviewCacheKey` 虽然名字带 workspace 但其实是 "dashboard 总览",Phase 1 不动。 + +- [ ] **Step 3:改 key 签名** + +```go +func platformAccountListCacheKey(tenantID, workspaceID int64) string { + return fmt.Sprintf("platform_accounts:%d:%d", tenantID, workspaceID) +} +``` + +对每个 workspace-sensitive key 同样改。更新所有调用点传 `middleware.WorkspaceIDFromCtx(ctx)`。 + +- [ ] **Step 4:编译 + 跑测试** + +```bash +cd server && go build ./... && go test ./internal/tenant/app/... +``` + +- [ ] **Step 5:写缓存隔离测试** + +```go +func TestCacheKey_PerWorkspaceIsolation(t *testing.T) { + // 在 wsA 写入 → 在 wsB 读应该 miss(不同 cache key) +} +``` + +- [ ] **Step 6:commit** + +```bash +git add server/internal/tenant/app/cache_support.go server/internal/tenant/app/cache_support_test.go +git commit -m "refactor(cache): workspace-sensitive cache keys for desktop/monitoring lines" +``` + +--- + +## Phase 0.6 · 监控域存量改造(§6.1.1 8 行) + +### Task 0.6.1:tenant_monitoring_quotas 改 primary_installation_id → primary_client_id + +**Files:** +- Create: `server/migrations/20260420100020_rename_primary_installation_to_client.up.sql` +- Modify: `server/internal/tenant/repository/queries/monitoring.sql` 里所有 `primary_installation_id` 引用 + +- [ ] **Step 1:up migration** + +```sql +ALTER TABLE tenant_monitoring_quotas + RENAME COLUMN primary_installation_id TO primary_client_id; +``` + +- [ ] **Step 2:down migration** + +```sql +ALTER TABLE tenant_monitoring_quotas + RENAME COLUMN primary_client_id TO primary_installation_id; +``` + +- [ ] **Step 3:sql search-and-replace + sqlc generate** + +```bash +cd server && \ + grep -rl "primary_installation_id" internal/tenant/repository/queries/ | \ + xargs sed -i '' 's/primary_installation_id/primary_client_id/g' && \ + sqlc generate && \ + go build ./... +``` + +- [ ] **Step 4:跑测试** + +```bash +cd server && go test ./... +``` +修所有因字段名变化破的测试。 + +- [ ] **Step 5:commit** + +```bash +git add server/migrations/20260420100020_* server/internal/tenant/repository/ +git commit -m "refactor(monitoring): rename primary_installation_id to primary_client_id" +``` + +### Task 0.6.2:monitoring_service.go 全面改造 + +**Files:** +- Modify: `server/internal/tenant/app/monitoring_service.go` +- Modify: `server/internal/tenant/app/monitoring_callback_service.go` +- Modify: `server/internal/tenant/transport/monitoring_handler.go` +- Modify: `server/internal/tenant/transport/monitoring_callback_handler.go` + +- [ ] **Step 1:扫描所有 tenant-only 查询调用点** + +```bash +grep -n "MonitoringQuota\|primary_client_id\|TenantIDFromCtx" server/internal/tenant/app/monitoring_*.go +``` + +- [ ] **Step 2:每处 `TenantIDFromCtx(ctx)` 调用,追加 `WorkspaceIDFromCtx(ctx)`** + +所有涉及 workspace-scoped 资源(quota / account / task lease)的调用都要传 workspace_id。 + +- [ ] **Step 3:改 handler 签名,确保 WorkspaceScope middleware 的输出可用** + +- [ ] **Step 4:编译 + 全部测试** + +```bash +cd server && go build ./... && go test ./... +``` + +- [ ] **Step 5:commit** + +```bash +git add server/internal/tenant/app/monitoring_*.go server/internal/tenant/transport/monitoring_*.go +git commit -m "refactor(monitoring): service/handler layer consume workspace_id from ctx" +``` + +### Task 0.6.3:Monitoring dashboard / projection / recovery / cleanup 改造 + +**Files:** +- Identify then modify: projection 代码(通常在 `server/internal/tenant/app/monitoring_projection*.go` 或类似) +- Identify then modify: 定时清理任务(通常在 `server/internal/workers/` 或 `cleanup.go`) +- Identify then modify: dashboard query(在 workspace/kol_dashboard handler 里可能有) + +- [ ] **Step 1:定位** + +```bash +grep -rln "tenant_monitoring\|MonitoringProjection\|monitoring_cleanup" server/internal/ +``` + +- [ ] **Step 2:对每处按 Task 0.6.2 的模式改造** + +- [ ] **Step 3:seed data 脚本同步** + +```bash +grep -rln "INSERT INTO tenant_monitoring_quotas" server/ internal/ sql/ +``` +有 seed 脚本就补 workspace_id 默认值。 + +- [ ] **Step 4:编译 + 测试 + commit** + +```bash +cd server && go build ./... && go test ./... +git add -u server/ +git commit -m "refactor(monitoring): projection/recovery/cleanup/seed consume workspace_id" +``` + +### Task 0.6.4:Monitoring 改造 regression 测试 + +**Files:** +- Create: `server/internal/tenant/app/monitoring_regression_test.go` + +- [ ] **Step 1:写 monitoring quota 在两 workspace 下的隔离测试** + +```go +func TestMonitoringQuota_IsolatedByWorkspace(t *testing.T) { + // 创建两 workspace,各 seed quota,验证 A 查不到 B 的 quota +} + +func TestMonitoringTaskLease_IsolatedByWorkspace(t *testing.T) { + // 同上,对 lease 动作 +} +``` + +- [ ] **Step 2:跑测试** + +```bash +cd server && go test ./internal/tenant/app/ -run TestMonitoring.*Isolated -v +``` + +- [ ] **Step 3:commit** + +```bash +git add server/internal/tenant/app/monitoring_regression_test.go +git commit -m "test(monitoring): regression for workspace isolation" +``` + +--- + +## Phase 0.7 · admin-web 五页补 workspace context + +### Task 0.7.1:API client 注入 workspace header(Phase 1 下 header = JWT claim 即可,不需要额外 header) + +**Files:** +- Inspect: `apps/admin-web/src/lib/api.ts` + +- [ ] **Step 1:确认 API client 已自动带 `Authorization: Bearer ` header** + +```bash +grep -n "Authorization" apps/admin-web/src/lib/api.ts | head -5 +``` + +**说明**:JWT 已经包含 `primary_workspace_id` claim,server 端会自动从 Actor 派生 workspace。**API client 不需要新增 workspace header 或 query param**。 + +- [ ] **Step 2:commit(空 placeholder,保留一致性)** + +如无改动则跳过此 task 的 commit。 + +### Task 0.7.2:5 个页面各自验证对新 workspace-scoped API 的调用方式 + +**Files:** +- Inspect: `apps/admin-web/src/views/**` 涉及账号 / 监控 / 客户端 / 发布 / 事件中心的页面 + +**说明**:Phase 1 下 5 个页面都是 Plan A 新建(账号总览、客户端总览、发布 Modal、监控结果、事件中心)。**Plan 0 阶段这些页面还不存在**,所以这个 Task 的实际工作是"写一份 Plan A 前置备忘录"——在 `apps/admin-web/docs/workspace-phase-1.md` 新建文档: + +- [ ] **Step 1:写备忘录** + +```markdown +# Phase 1 窄 workspace 化 · 前端备忘(Plan 0 产物) + +所有 desktop/account/monitoring 相关的新页面都应该: + +1. 不需要传 `workspace_id` query/body 参数;server 从 JWT claim 自动派生。 +2. 不需要在 UI 上提供"切换 workspace"控件(Phase 1 每用户固定 primary)。 +3. 现有老页面(首页 dashboard、文章、品牌、模板)继续按 tenant 粒度工作,**不要**在顶部导航添加 workspace 切换器。 +4. 如果需要在 UI 上显示"当前工作区",从 `GET /api/auth/me` 返回的 `primary_workspace` 字段取。 + +Plan A Block H 会补齐这 5 个页面。 +``` + +- [ ] **Step 2:commit** + +```bash +git add apps/admin-web/docs/workspace-phase-1.md +git commit -m "docs(admin-web): Phase 1 workspace front-end memo for Plan A" +``` + +### Task 0.7.3:`/api/auth/me` 返回 primary_workspace + +**Files:** +- Modify: `server/internal/tenant/transport/auth_handler.go` 的 `Me` handler +- Modify: `apps/admin-web/src/lib/api.ts` 的 me 响应类型(如需) + +- [ ] **Step 1:改 Me handler 增加 primary_workspace** + +```go +func (h *AuthHandler) Me(c *gin.Context) { + actor := auth.MustActor(c.Request.Context()) + // 查 workspace 详情 + ws, err := h.app.WorkspaceRepo.GetWorkspaceByID(c.Request.Context(), actor.PrimaryWorkspaceID) + if err != nil { /* handle */ } + response.OK(c, gin.H{ + "user_id": actor.UserID, + "tenant_id": actor.TenantID, + "role": actor.Role, + "primary_workspace": gin.H{ + "id": ws.ID, + "name": ws.Name, + "slug": ws.Slug, + }, + }) +} +``` + +- [ ] **Step 2:写 queries/workspace.sql** + +```sql +-- name: GetWorkspaceByID :one +SELECT id, tenant_id, name, slug, is_default FROM workspaces WHERE id = sqlc.arg(id); +``` + +- [ ] **Step 3:sqlc generate + 编译** + +```bash +cd server && sqlc generate && go build ./... +``` + +- [ ] **Step 4:admin-web 侧更新 AuthMe 类型** + +在 `apps/admin-web/src/lib/api.ts` 找到 auth.me 定义,加 `primary_workspace: { id, name, slug }`。 + +- [ ] **Step 5:跑测试** + +```bash +cd server && go test ./internal/tenant/transport/ +cd apps/admin-web && pnpm test +``` + +- [ ] **Step 6:commit** + +```bash +git add server/internal/ apps/admin-web/src/lib/api.ts +git commit -m "feat(auth): /api/auth/me returns primary_workspace" +``` + +--- + +## Phase 0.8 · CI guard + +### Task 0.8.1:Lint rule — 白名单路由的新代码必须注入 workspace_id + +**Files:** +- Create: `server/scripts/lint_workspace_scope.go` +- Modify: `.github/workflows/ci.yml`(或同等位置) + +- [ ] **Step 1:写 lint 脚本** + +```go +// server/scripts/lint_workspace_scope.go +// 用法:go run server/scripts/lint_workspace_scope.go +// +// 扫描 internal/tenant/app/ 和 internal/tenant/repository/ 下所有 Go 文件, +// 对涉及 platform_accounts / tenant_monitoring_quotas / desktop_clients / +// desktop_tasks / desktop_publish_jobs 的函数,检查是否显式传了 workspace_id。 +// +// 规则:任何 repo 调用 ListX / GetX / UpdateX / DeleteX(X 是白名单里的表对应的类型), +// 其 Params struct 必须 literal 包含 WorkspaceID 字段。 +// 否则退出码 1 并打印违规位置。 +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +var trackedTables = []string{"PlatformAccount", "MonitoringQuota", "DesktopClient", "DesktopTask", "DesktopPublishJob"} + +func main() { + violations := []string{} + err := filepath.Walk("internal/tenant", func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + f, _ := parser.ParseFile(fset, path, nil, parser.ParseComments) + if f == nil { return nil } + ast.Inspect(f, func(n ast.Node) bool { + ce, ok := n.(*ast.CallExpr) + if !ok { return true } + // 简单匹配:方法名里含跟踪表名 + se, ok := ce.Fun.(*ast.SelectorExpr) + if !ok { return true } + method := se.Sel.Name + for _, tbl := range trackedTables { + if strings.Contains(method, tbl) && (strings.HasPrefix(method, "List") || strings.HasPrefix(method, "Get") || strings.HasPrefix(method, "Update") || strings.HasPrefix(method, "Delete")) { + // 检查 Params literal 是否含 WorkspaceID + if !argsMentionWorkspaceID(ce.Args) { + pos := fset.Position(ce.Pos()) + violations = append(violations, pos.String()+" "+method+" missing WorkspaceID") + } + } + } + return true + }) + return nil + }) + if err != nil { panic(err) } + if len(violations) > 0 { + for _, v := range violations { println(v) } + os.Exit(1) + } +} + +func argsMentionWorkspaceID(args []ast.Expr) bool { + for _, arg := range args { + cl, ok := arg.(*ast.CompositeLit) + if !ok { continue } + for _, elt := range cl.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { continue } + if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "WorkspaceID" { + return true + } + } + } + return false +} +``` + +- [ ] **Step 2:本地跑一次 lint,确保所有现有代码都过** + +```bash +cd server && go run scripts/lint_workspace_scope.go +``` +Expected: 退出码 0。若有违规,回 Phase 0.4 / 0.6 补齐。 + +- [ ] **Step 3:在 CI 加这一步** + +修改 `.github/workflows/ci.yml`(或等效文件),在现有 `go test` step 之前加: + +```yaml + - name: Workspace scope lint + run: | + cd server + go run scripts/lint_workspace_scope.go +``` + +- [ ] **Step 4:故意制造违规验证 lint 有效** + +临时改一个调用点去掉 `WorkspaceID`,跑 lint,确认报错。恢复后。 + +- [ ] **Step 5:commit** + +```bash +git add server/scripts/lint_workspace_scope.go .github/workflows/ci.yml +git commit -m "ci: add lint rule enforcing workspace_id injection on desktop lines" +``` + +--- + +## Phase 0.9 · Exit bar 测试 + +### Task 0.9.1:`/api/tenant/events` topic 服务端派生测试 + +**Files:** +- Create: `server/internal/tenant/transport/events_handler_test.go`(Plan A 创建 handler,但 topic 派生逻辑 Plan 0 就要能测) + +**说明**:Plan 0 的事件 handler 还不存在,但 **topic 派生函数** 应先独立成库函数让 Plan 0 覆盖测试。 + +- [ ] **Step 1:新建 topic 派生库** + +`server/internal/tenant/events/topic_derive.go`: + +```go +package events + +import ( + "fmt" + + "github.com/geo-platform/tenant-api/internal/shared/auth" +) + +// DeriveAllowedTopics 根据 actor 返回允许订阅的 SSE topics。 +// 永远只从 actor 派生,**不**接受外部 topic 参数。 +func DeriveAllowedTopics(actor auth.Actor) []string { + if actor.PrimaryWorkspaceID == 0 { + return nil + } + return []string{ + fmt.Sprintf("workspace:%d", actor.PrimaryWorkspaceID), + fmt.Sprintf("user:%d", actor.UserID), + } +} + +// ValidateTopicForActor 校验用户请求订阅的 topic 是否属于他的白名单。 +// 用于 SSE handler 里防恶意 topic 参数。 +func ValidateTopicForActor(topic string, actor auth.Actor) bool { + for _, allowed := range DeriveAllowedTopics(actor) { + if topic == allowed { + return true + } + } + return false +} +``` + +- [ ] **Step 2:写测试** + +`server/internal/tenant/events/topic_derive_test.go`: + +```go +func TestDeriveAllowedTopics_WorkspaceScoped(t *testing.T) { + topics := DeriveAllowedTopics(auth.Actor{UserID: 10, PrimaryWorkspaceID: 42}) + want := []string{"workspace:42", "user:10"} + if !reflect.DeepEqual(topics, want) { + t.Errorf("want %v, got %v", want, topics) + } +} + +func TestValidateTopic_RejectsCrossWorkspace(t *testing.T) { + actor := auth.Actor{UserID: 10, PrimaryWorkspaceID: 42} + if ValidateTopicForActor("workspace:99", actor) { + t.Error("must reject cross-workspace topic") + } + if ValidateTopicForActor("job:123", actor) { + t.Error("must reject non-derived topic even if semantically plausible") + } + if !ValidateTopicForActor("workspace:42", actor) { + t.Error("must accept own workspace topic") + } +} + +func TestValidateTopic_RejectsMissingWorkspace(t *testing.T) { + if ValidateTopicForActor("workspace:42", auth.Actor{UserID: 10, PrimaryWorkspaceID: 0}) { + t.Error("must reject when actor has no workspace") + } +} +``` + +- [ ] **Step 3:跑测试** + +```bash +cd server && go test ./internal/tenant/events/ -v +``` +Expected: all pass。 + +- [ ] **Step 4:commit** + +```bash +git add server/internal/tenant/events/ +git commit -m "feat(events): server-derived topic whitelist for SSE authorization" +``` + +### Task 0.9.2:现有 admin-web 功能无回归 + +- [ ] **Step 1:本地启动完整栈** + +```bash +docker compose -f infra/local/docker-compose.yml up -d postgres redis rabbitmq +cd server && go run cmd/tenant-api/main.go & +cd apps/admin-web && pnpm dev +``` + +- [ ] **Step 2:跑冒烟清单** + +手工跑以下页面,确认无回归: + +- [ ] 登录 → JWT decode 带 primary_workspace_id +- [ ] Dashboard 首页 → 数据加载正常 +- [ ] 文章列表、详情、创建 → 全部正常(tenant 粒度) +- [ ] 品牌列表 → 正常 +- [ ] KOL 模板、Marketplace → 正常 +- [ ] 监控页面(若已有 Web 端 UI) → 返回本 workspace 数据,不串号 + +- [ ] **Step 3:admin-web 自动化测试** + +```bash +cd apps/admin-web && pnpm test +``` +Expected: all green vs baseline。 + +- [ ] **Step 4:若有回归,回到对应 Phase 修,不创建新任务** + +### Task 0.9.3:§6.1.1 监控存量 8 行改造 code review + +- [ ] **Step 1:开一个 code review checklist issue** + +在项目 issue tracker 或 `docs/reviews/plan-0-exit-bar.md` 新建 checklist: + +```markdown +# Plan 0 Exit Bar Code Review Checklist + +- [ ] tenant_monitoring_quotas 加 workspace_id 列 + 改 primary_installation_id → primary_client_id +- [ ] monitoring_service.go 全量改造:所有 TenantIDFromCtx 后都跟 WorkspaceIDFromCtx +- [ ] monitoring_callback_service.go 同上 +- [ ] monitoring_handler.go + monitoring_callback_handler.go 同上 +- [ ] 监控 projection / recovery / dashboard query 全改 +- [ ] seed data 同步 +- [ ] 定时清理任务同步 +- [ ] cross-workspace 防穿透测试 ≥ 10 条全绿 + +Reviewer:__________ Sign-off:__________ +``` + +- [ ] **Step 2:request review,得到 2 个签字才能 close** + +- [ ] **Step 3:commit checklist** + +```bash +git add docs/reviews/plan-0-exit-bar.md +git commit -m "docs: Plan 0 exit bar code review checklist" +``` + +### Task 0.9.4:Plan 0 最终 sign-off + +- [ ] **Step 1:所有前置 Task 都 close** + +手动检查 todo 清单,确认没有未完成的 Task。 + +- [ ] **Step 2:跑最后一次全量测试** + +```bash +cd server && go test ./... 2>&1 | tail -5 +cd apps/admin-web && pnpm test 2>&1 | tail -5 +cd server && go run scripts/lint_workspace_scope.go +``` +Expected: 全部 0 退出码。 + +- [ ] **Step 3:在 `docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md` 顶部加「COMPLETED」标记** + +```diff +- # Plan 0 · Workspace 底座一等化 Implementation Plan ++ # Plan 0 · Workspace 底座一等化 Implementation Plan ✅ COMPLETED <日期> +``` + +- [ ] **Step 4:打 tag** + +```bash +git tag plan-0-complete +git commit -am "chore: mark plan 0 complete" +``` + +- [ ] **Step 5:通知 Plan A 团队解锁** + +"Plan 0 已完成,Plan A 可开工。请在 Plan A 里引用 WorkspaceScope / PrimaryWorkspaceID / ValidateTopicForActor 等基础设施。" + +--- + +## Self-Review + +**1. Spec coverage:** Plan 0 覆盖了 §12 Plan 0 的全部 8 条动作和 3 类 exit bar。特别是 cross-workspace 防穿透测试 ≥10 条的硬要求在 Task 0.4.3 落地。 +**2. Placeholder scan:** 无 TBD / TODO / "fill in"。代码片段都是完整可运行(除了一处 `notImplementedHandler` 是临时 placeholder,Plan A Block E 会替换)。 +**3. Type consistency:** `PrimaryWorkspaceID` 在 Actor/Claims/JWT 三处一致;`WorkspaceScope` / `WorkspaceIDFromCtx` 命名一致;`primary_client_id` 在 SQL 和 Go struct 里一致。 +**4. No gaps:** admin-web 侧 5 个页面的实际实现推给 Plan A(备忘录 Task 0.7.2 明确标注)——这是设计上的正确分工,不是遗漏。 diff --git a/docs/superpowers/plans/2026-04-18-plan-a-desktop-skeleton.md b/docs/superpowers/plans/2026-04-18-plan-a-desktop-skeleton.md new file mode 100644 index 0000000..9c92b90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-plan-a-desktop-skeleton.md @@ -0,0 +1,1524 @@ +# Plan A · 桌面客户端骨架穿透 Implementation Plan + +> **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 监控任务 + 一条头条发布任务(含 manual parked 恢复);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 / parked 恢复协议 + 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 + 状态机 parked 流程 | 1 周 | A.2 | ✅ 与 A.4 并行 | +| A.6 · PublishArticleModal H2 | 基于 A.3 composable + A.1 API 重构账号级多选 Modal | 1 周 | A.1, 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', 'waiting_user'); +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: LeaseFromParked :one +-- 严格 CAS:从 waiting_user 回到 in_progress,仅 target_client 可调 +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 = 'waiting_user' + AND active_attempt_id IS NULL +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: ParkTask :one +UPDATE desktop_tasks +SET status = 'waiting_user', 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: 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 取消 parked / 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 IN ('queued', 'waiting_user') +RETURNING *; + +-- name: GetTaskForAudit :one +SELECT * FROM desktop_tasks WHERE id = sqlc.arg(task_id) AND workspace_id = sqlc.arg(workspace_id); + +-- name: ListParkedTasksByClient :many +-- For offline→online reclaim +SELECT * FROM desktop_tasks +WHERE target_client_id = sqlc.arg(client_id) AND status IN ('queued', 'waiting_user'); +``` + +- [ ] **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 tables queries incl. lease / park / reconcile / cancel CAS" +``` + +### 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:LeaseFromParked handler(严格 CAS)** + +```go +func (h *DesktopTaskHandler) LeaseFromParked(c *gin.Context) { + client := middleware.MustClientFromCtx(c.Request.Context()) // client_token 派生 + taskID := uuid.MustParse(c.Param("id")) + fromParked := c.Query("from_parked") == "true" + if !fromParked { /* 走普通 /lease */ } + + attemptID := uuid.New() + token := newLeaseToken() + tokenHash := sha256Hash(token) + + task, err := h.app.DesktopRepo.LeaseFromParked(c.Request.Context(), + generated.LeaseFromParkedParams{ + TaskID: taskID, + ClientID: client.ID, + AttemptID: attemptID, + LeaseTokenHash: tokenHash, + }) + if err != nil { + // 0 rows → detect reason and return 409 + currentTask, _ := h.app.DesktopRepo.GetTaskForAudit(c.Request.Context(), taskID, client.WorkspaceID) + reason := classifyLeaseFailure(currentTask, client) + response.Error(c, response.Conflict(40901, "lease_from_parked_conflict", reason)) + return + } + + response.OK(c, gin.H{ + "task": task, + "attempt_id": attemptID, + "lease_token": token, + "lease_expires_at": task.LeaseExpiresAt.Unix(), + }) +} + +// classifyLeaseFailure 返回 spec §5.3 的 5 类错误码之一: +// "already_in_progress" | "aborted" | "terminal" | "not_target_client" | "not_parked" +func classifyLeaseFailure(t generated.DesktopTask, c middleware.Client) string { + if t.TargetClientID != c.ID { return "not_target_client" } + if t.Status == "aborted" { return "aborted" } + if t.Status == "succeeded" || t.Status == "failed" { return "terminal" } + if t.Status == "in_progress" && t.ActiveAttemptID != nil { return "already_in_progress" } + return "not_parked" +} +``` + +- [ ] **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 +parkedTasks, _ := repo.ListParkedTasksByClient(ctx, clientID) +for _, t := range parkedTasks { + 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 / parked / cross-client) + +**Files:** +- Create: `server/internal/tenant/transport/desktop_task_integration_test.go` + +- [ ] **Step 1:核心测试用例** + +```go +func TestLeaseFromParked_RejectsNonTargetClient(t *testing.T) { /* ... */ } +func TestLeaseFromParked_IdempotentOnDoubleCall(t *testing.T) { /* ... */ } +func TestLeaseFromParked_OnAbortedTaskReturns409Aborted(t *testing.T) { /* ... */ } +func TestLeaseFromParked_OnTerminalReturns409Terminal(t *testing.T) { /* ... */ } +func TestParkedTaskNotClaimedByOtherClient(t *testing.T) { /* ... */ } +func TestCrashedTargetClient_ParkedTaskStaysOrphan(t *testing.T) { /* ... */ } +func TestReconcileRetry_MovesTaskBackToQueued(t *testing.T) { /* ... */ } +func TestReconcileSucceeded_MarksTaskSucceeded(t *testing.T) { /* ... */ } +func TestWebCancelParkedTask(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.acquireFromParked(taskId)` → `POST /tasks/{id}/lease?from_parked=true`;处理 409 的 5 类错误 +3. `LeaseManager.extend(lease_token)` → 每 60s 自动调 +4. `LeaseManager.park(reason)` → `POST /tasks/{id}/park`,释放 local token +5. `LeaseManager.complete(result)` → `POST /tasks/{id}/result` +6. 单元测试:402 抢占幂等、断线重连带同一 attempt 的 409 处理、parked 期间不续租 + +### 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 ('in_progress', 'waiting_user')` +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 → readyToReview (manual) → submitting → done +3. manual 模式:`LeaseManager.park('waiting_user')` 释放 lease;用户点"发布"后 `acquireFromParked()` +4. 处理 5 类 409 错误 +5. 集成测试:manual parked → restart client → acquireFromParked → complete + +--- + +## 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:manual 任务展示"前往桌面端" deep link `georankly://tasks//review?nonce=`** +- [ ] **Step 7:E2E 测试(Playwright admin-web)** +- [ ] **Step 8:commit** + +--- + +## 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 开始。