Files
geo/docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md
root 98f9e95875 docs(plans): add Plan 0 (workspace foundation) and Plan A (desktop skeleton)
Plan 0 · Workspace 底座一等化 (2-3 weeks, blocks Plan A):
  - Phase 0.1 workspaces + workspace_memberships tables with default
    seed; backfill workspace_id onto existing platform_accounts and
    tenant_monitoring_quotas.
  - Phase 0.2 Actor/Claims/JWT add PrimaryWorkspaceID; Login/Refresh
    inject via GetPrimaryWorkspaceForUser query.
  - Phase 0.3 WorkspaceScope middleware driven from Actor only (query
    params rejected); wired to /api/tenant/events / accounts /
    publish-jobs / tasks / monitoring whitelist.
  - Phase 0.4 sqlc queries for platform_accounts and
    tenant_monitoring_quotas filter by workspace_id; ≥10
    cross-workspace penetration test cases.
  - Phase 0.5 workspace-sensitive cache keys.
  - Phase 0.6 monitoring domain migration: rename
    primary_installation_id → primary_client_id; service / handler /
    projection / recovery / cleanup / seed all consume workspace_id;
    regression tests.
  - Phase 0.7 /api/auth/me returns primary_workspace; admin-web memo.
  - Phase 0.8 CI lint guard enforces workspace_id injection on
    desktop lines.
  - Phase 0.9 server-derived SSE topic library + exit-bar smoke +
    code review checklist.

Plan A · 桌面客户端骨架穿透 (5-6 weeks):
  - Phase A.0 CDP spike matrix for Doubao (0.5w, blocks A.4).
  - Phase A.1 server protocol + DB: desktop_clients /
    platform_accounts / desktop_tasks / publish_jobs / attempts /
    traces; lease / LeaseFromParked (strict CAS) / Park / Extend /
    Complete / ReconcileTask / WebCancel queries; RabbitMQ fanout +
    Redis presence + offline→online reclaim; 11-case lease protocol
    integration suite.
  - Phase A.2 Electron desktop-client runtime with modules:
    session-registry, view-pool (hot/cold), vault + vault-export,
    lease-manager, rate-limiter, keep-alive, circuit-breaker,
    crash-recovery, api-client, sse-client, tracing (Alpha scope).
  - Phase A.3 packages/ui-shared H1: design tokens (CSS vars +
    AntD theme light/dark); useAccountSelection composable;
    MarkdownPreview / AccountCard / TagMultiSelect / HealthBadge /
    useSseSubscription components; admin-web no-regress integration.
  - Phase A.4 Doubao monitor adapter based on A.0 matrix.
  - Phase A.5 Toutiao publish adapter with manual parked recovery.
  - Phase A.6 PublishArticleModal H2 rewrite (account-level
    multi-select on useAccountSelection; SSE progress panel; deep
    link for manual review).
  - Phase A.7 fake server + fake platforms + Playwright _electron
    E2E + Mac notarization CI + autoUpdater unit tests.
  - Phase A.8 exit-bar: real-account smoke playbook + chaos test
    (multi-instance SSE routing / parked survival across restarts)
    + plan-a-complete tag.

Plan 0 must complete and tag `plan-0-complete` before Plan A starts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:47:13 +08:00

52 KiB
Raw Permalink Blame History

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
cd server && go test ./... 2>&1 | tee /tmp/baseline-go-tests.log

Expected: all green. 记录当前绿基线。

  • Step 2:运行 admin-web 测试 baseline
cd apps/admin-web && pnpm test 2>&1 | tee /tmp/baseline-admin-web-tests.log

Expected: all green.

  • Step 3:列出所有现有 sqlc 查询涉及的表
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

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

DROP TABLE IF EXISTS workspace_memberships;
DROP TABLE IF EXISTS workspaces;
  • Step 3:本地跑一次迁移
cd server && make migrate-up 2>&1 | tail -20

Expected: "migrated" / "no change",退出码 0。

  • Step 4:检查 seed 数据
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 可逆
cd server && make migrate-down STEPS=1 && make migrate-up

Expected: 两次都成功,数据被重新 seed 相同。

  • Step 6commit
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_idPhase 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_installationsdesktop_clients 的重命名在 Plan A 做。

  • Step 1:写 up migration

文件 server/migrations/20260420100010_add_workspace_to_desktop_tables.up.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

ALTER TABLE platform_accounts DROP COLUMN IF EXISTS workspace_id;
ALTER TABLE tenant_monitoring_quotas DROP COLUMN IF EXISTS workspace_id;
  • Step 3:跑迁移 + 校验 backfill
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 4commit
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.1Actor + 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 追加:

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:跑测试确认失败
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 第 59 行:

type Actor struct {
    UserID             int64
    TenantID           int64
    Role               string
    PrimaryWorkspaceID int64 // Phase 1 窄 workspace 化下每个用户固定一个 primary
}
  • Step 4:跑测试确认通过
cd server && go test ./internal/shared/auth/ -v

Expected: all pass.

  • Step 5:扩 Claims

修改 server/internal/shared/auth/claims.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 测试
cd server && go test ./internal/shared/auth/ -v

Expected: all pass。如果有之前的 JWT 测试 assert claim 字段,后续 Task 0.2.2 里改。

  • Step 7commit
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.2JWT 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 追加:

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:跑确认失败
cd server && go test ./internal/shared/auth/ -run TestIssueCarriesPrimaryWorkspaceID -v

Expected: FAILIssue 签名只有 3 个参数)。

  • Step 3:改 Issue 签名

修改 server/internal/shared/auth/jwt.go 第 34 行起的 Issue 方法签名和内部 claims

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:跑确认新测试通过
cd server && go test ./internal/shared/auth/ -v

Expected: TestIssueCarriesPrimaryWorkspaceID pass;其它测试可能因签名变更失败,下一步一起修

  • Step 5:批量修改所有 Issue 调用点
cd server && grep -rn "\.Issue(" --include="*.go" | grep -v "_test.go"

预期只有 login/refresh 两处需要改(见下 Task 0.2.3)。如果有别的地方,暂用 0 占位,后续根据上下文补真实 primary_workspace_id(可从 user.PrimaryWorkspaceID 字段查)。

  • Step 6commit
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.3Login / 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 逻辑

grep -n "mgr.Issue\|Issue(" server/internal/tenant/transport/auth_handler.go
  • Step 2:在 queries/auth.sql 加查询

追加到 server/internal/tenant/repository/queries/auth.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 代码
cd server && sqlc generate 2>&1
go build ./...

Expected: 无错误,internal/tenant/repository/generated/ 下新增 GetPrimaryWorkspaceForUser 方法。

  • Step 4:修改 Login handler

server/internal/tenant/transport/auth_handler.goLogin 方法里,在验证密码成功后:

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 的转换补上:

actor := Actor{
    UserID:             claims.UserID,
    TenantID:           claims.TenantID,
    PrimaryWorkspaceID: claims.PrimaryWorkspaceID,
    Role:               claims.Role,
}
  • Step 6:跑所有 auth 相关测试
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 追加(若文件不存在就新建):

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 测试
cd server && go test ./internal/tenant/transport/ -run TestLoginReturnsJWTWithPrimaryWorkspace -v

Expected: PASS。

  • Step 9commit
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.1WorkspaceScope 中间件实现

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

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:跑确认失败
cd server && go test ./internal/shared/middleware/ -run TestWorkspaceScope -v

Expected: FAILWorkspaceScope 未定义)。

  • Step 3:写实现

创建 server/internal/shared/middleware/workspace_scope.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:跑测试全绿
cd server && go test ./internal/shared/middleware/ -v

Expected: all pass。

  • Step 5commit
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 定义之前,追加:

// 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(临时)
func notImplementedHandler(c *gin.Context) {
    c.JSON(501, gin.H{"error": "not implemented (Plan 0 placeholder, see Plan A Block E)"})
}
  • Step 3:编译 + 跑路由注册测试
cd server && go build ./...

Expected: 无错误。

  • Step 4:写集成测试 — 这些路由都要求 WorkspaceScope

server/internal/tenant/transport/router_test.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 测试
cd server && go test ./internal/tenant/transport/ -run TestWorkspaceScopeAppliedToDesktopRoutes -v

Expected: PASS。

  • Step 6commit
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.1platform_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:定位现有查询

grep -rn "platform_accounts" server/internal/tenant/repository/queries/ | head -10
  • Step 2:为每条涉及 platform_accounts 的查询加 workspace_id = sqlc.arg(workspace_id)

对每条查询,复制现有条件、追加 workspace_id 过滤。以 ListPlatformAccounts 为例:

原:

-- name: ListPlatformAccounts :many
SELECT * FROM platform_accounts WHERE tenant_id = sqlc.arg(tenant_id) ORDER BY created_at DESC;

改为:

-- 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 3sqlc generate
cd server && sqlc generate
go build ./...

Expected: 编译可能有 N 处调用点 missing 参数,不修——下一步让测试驱动。

  • Step 4service 层传 workspace_id

server/internal/tenant/app/media_service.go 为例,所有调用 ListPlatformAccounts(ctx, ...) 的地方加 middleware.WorkspaceIDFromCtx(ctx)

accounts, err := s.repo.ListPlatformAccounts(ctx, generated.ListPlatformAccountsParams{
    TenantID:    middleware.TenantIDFromCtx(ctx),
    WorkspaceID: middleware.WorkspaceIDFromCtx(ctx),
})
  • Step 5:编译并修完所有调用点
cd server && go build ./... 2>&1 | grep "error:" | head -20

逐个修直到编译通过。

  • Step 6:跑全部测试
cd server && go test ./...

Expected: 可能有 fixture 测试因缺 workspace_id 报错,逐个修。

  • Step 7commit
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.2tenant_monitoring_quotas queries 加 workspace_id

重复 Task 0.4.1 的模式,针对 server/internal/tenant/repository/queries/ 里所有涉及 tenant_monitoring_quotas 的查询。

  • Step 1:定位
grep -rln "tenant_monitoring_quotas" server/internal/tenant/repository/queries/
  • Step 2:每条 select/update/insert 加 workspace_id 过滤(同 Task 0.4.1

  • Step 3:生成 + 传参 + 编译

cd server && sqlc generate && go build ./...
  • Step 4:跑测试
cd server && go test ./...
  • Step 5commit
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.3Cross-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

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 下创建两个 workspaceA 和 B),
// 同一个 user 是 A 的 primaryB 里 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()

    // SeedwsB 里有一个账号
    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:跑测试
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 3commit
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 函数

grep -n "func.*CacheKey\|cacheKey" server/internal/tenant/app/cache_support.go
  • Step 2:识别需要 workspace-sensitive 的 key

Phase 1 范围里需要 workspace-sensitiveplatformAccountListCacheKeymonitoringQuotaCacheKeydesktopClientListCacheKey 等。 不需要(因为 tenant-scope 不变):workspaceOverviewCacheKey(老 workspace 页面)、articleListCacheKey 等。

注: cache_support.go:51-60workspaceOverviewCacheKey 虽然名字带 workspace 但其实是 "dashboard 总览"Phase 1 不动。

  • Step 3:改 key 签名
func platformAccountListCacheKey(tenantID, workspaceID int64) string {
    return fmt.Sprintf("platform_accounts:%d:%d", tenantID, workspaceID)
}

对每个 workspace-sensitive key 同样改。更新所有调用点传 middleware.WorkspaceIDFromCtx(ctx)

  • Step 4:编译 + 跑测试
cd server && go build ./... && go test ./internal/tenant/app/...
  • Step 5:写缓存隔离测试
func TestCacheKey_PerWorkspaceIsolation(t *testing.T) {
    // 在 wsA 写入 → 在 wsB 读应该 miss(不同 cache key
}
  • Step 6commit
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.1tenant_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 1up migration

ALTER TABLE tenant_monitoring_quotas
    RENAME COLUMN primary_installation_id TO primary_client_id;
  • Step 2down migration
ALTER TABLE tenant_monitoring_quotas
    RENAME COLUMN primary_client_id TO primary_installation_id;
  • Step 3sql search-and-replace + sqlc generate
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:跑测试
cd server && go test ./...

修所有因字段名变化破的测试。

  • Step 5commit
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.2monitoring_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 查询调用点

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:编译 + 全部测试

cd server && go build ./... && go test ./...
  • Step 5commit
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.3Monitoring 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:定位

grep -rln "tenant_monitoring\|MonitoringProjection\|monitoring_cleanup" server/internal/
  • Step 2:对每处按 Task 0.6.2 的模式改造

  • Step 3seed data 脚本同步

grep -rln "INSERT INTO tenant_monitoring_quotas" server/ internal/ sql/

有 seed 脚本就补 workspace_id 默认值。

  • Step 4:编译 + 测试 + commit
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.4Monitoring 改造 regression 测试

Files:

  • Create: server/internal/tenant/app/monitoring_regression_test.go

  • Step 1:写 monitoring quota 在两 workspace 下的隔离测试

func TestMonitoringQuota_IsolatedByWorkspace(t *testing.T) {
    // 创建两 workspace,各 seed quota,验证 A 查不到 B 的 quota
}

func TestMonitoringTaskLease_IsolatedByWorkspace(t *testing.T) {
    // 同上,对 lease 动作
}
  • Step 2:跑测试
cd server && go test ./internal/tenant/app/ -run TestMonitoring.*Isolated -v
  • Step 3commit
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.1API client 注入 workspace headerPhase 1 下 header = JWT claim 即可,不需要额外 header

Files:

  • Inspect: apps/admin-web/src/lib/api.ts

  • Step 1:确认 API client 已自动带 Authorization: Bearer <JWT> header

grep -n "Authorization" apps/admin-web/src/lib/api.ts | head -5

说明JWT 已经包含 primary_workspace_id claimserver 端会自动从 Actor 派生 workspace。API client 不需要新增 workspace header 或 query param

  • Step 2commit(空 placeholder,保留一致性)

如无改动则跳过此 task 的 commit。

Task 0.7.25 个页面各自验证对新 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:写备忘录
# 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 2commit
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.goMe handler

  • Modify: apps/admin-web/src/lib/api.ts 的 me 响应类型(如需)

  • Step 1:改 Me handler 增加 primary_workspace

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
-- name: GetWorkspaceByID :one
SELECT id, tenant_id, name, slug, is_default FROM workspaces WHERE id = sqlc.arg(id);
  • Step 3sqlc generate + 编译
cd server && sqlc generate && go build ./...
  • Step 4admin-web 侧更新 AuthMe 类型

apps/admin-web/src/lib/api.ts 找到 auth.me 定义,加 primary_workspace: { id, name, slug }

  • Step 5:跑测试
cd server && go test ./internal/tenant/transport/
cd apps/admin-web && pnpm test
  • Step 6commit
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.1Lint rule — 白名单路由的新代码必须注入 workspace_id

Files:

  • Create: server/scripts/lint_workspace_scope.go

  • Modify: .github/workflows/ci.yml(或同等位置)

  • Step 1:写 lint 脚本

// 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 / DeleteXX 是白名单里的表对应的类型),
// 其 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,确保所有现有代码都过
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 之前加:

      - name: Workspace scope lint
        run: |
          cd server
          go run scripts/lint_workspace_scope.go
  • Step 4:故意制造违规验证 lint 有效

临时改一个调用点去掉 WorkspaceID,跑 lint,确认报错。恢复后。

  • Step 5commit
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.goPlan A 创建 handler,但 topic 派生逻辑 Plan 0 就要能测)

说明Plan 0 的事件 handler 还不存在,但 topic 派生函数 应先独立成库函数让 Plan 0 覆盖测试。

  • Step 1:新建 topic 派生库

server/internal/tenant/events/topic_derive.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

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:跑测试
cd server && go test ./internal/tenant/events/ -v

Expected: all pass。

  • Step 4commit
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:本地启动完整栈
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 3admin-web 自动化测试

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

# 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 2request review,得到 2 个签字才能 close

  • Step 3commit checklist

git add docs/reviews/plan-0-exit-bar.md
git commit -m "docs: Plan 0 exit bar code review checklist"

Task 0.9.4Plan 0 最终 sign-off

  • Step 1:所有前置 Task 都 close

手动检查 todo 清单,确认没有未完成的 Task。

  • Step 2:跑最后一次全量测试
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」标记
- # Plan 0 · Workspace 底座一等化 Implementation Plan
+ # Plan 0 · Workspace 底座一等化 Implementation Plan ✅ COMPLETED <日期>
  • Step 4:打 tag
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 是临时 placeholderPlan 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 明确标注)——这是设计上的正确分工,不是遗漏。