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>
52 KiB
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 6:commit
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:
-- 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 4:commit
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 追加:
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 第 5–9 行:
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 7:commit
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 追加:
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: FAIL(Issue 签名只有 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 6:commit
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 逻辑
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.go 的 Login 方法里,在验证密码成功后:
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 9:commit
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:
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: FAIL(WorkspaceScope 未定义)。
- 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 5:commit
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 6:commit
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:定位现有查询
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 3:sqlc generate
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):
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 7:commit
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:定位
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 5:commit
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:
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:跑测试
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
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-sensitive:platformAccountListCacheKey、monitoringQuotaCacheKey、desktopClientListCacheKey 等。
不需要(因为 tenant-scope 不变):workspaceOverviewCacheKey(老 workspace 页面)、articleListCacheKey 等。
注: cache_support.go:51-60 的 workspaceOverviewCacheKey 虽然名字带 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 6:commit
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
ALTER TABLE tenant_monitoring_quotas
RENAME COLUMN primary_installation_id TO primary_client_id;
- Step 2:down migration
ALTER TABLE tenant_monitoring_quotas
RENAME COLUMN primary_client_id TO primary_installation_id;
- Step 3:sql 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 5:commit
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 查询调用点
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 5:commit
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:定位
grep -rln "tenant_monitoring\|MonitoringProjection\|monitoring_cleanup" server/internal/
-
Step 2:对每处按 Task 0.6.2 的模式改造
-
Step 3:seed 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.4:Monitoring 改造 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 3:commit
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 <JWT>header
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:写备忘录
# 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
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的Mehandler -
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 3:sqlc generate + 编译
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:跑测试
cd server && go test ./internal/tenant/transport/
cd apps/admin-web && pnpm test
- Step 6:commit
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 脚本
// 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,确保所有现有代码都过
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 5:commit
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:
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 4:commit
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 3:admin-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 2:request review,得到 2 个签字才能 close
-
Step 3:commit checklist
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:跑最后一次全量测试
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 是临时 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 明确标注)——这是设计上的正确分工,不是遗漏。