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