Files
geo/docs/superpowers/plans/2026-04-18-plan-0-workspace-foundation.md
T
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

1653 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 6commit**
```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_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_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 4commit**
```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.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` 追加:
```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` 第 59 行:
```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 7commit**
```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.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` 追加:
```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: FAILIssue 签名只有 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 6commit**
```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.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 逻辑**
```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 9commit**
```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.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`
```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: FAILWorkspaceScope 未定义)。
- [ ] **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 5commit**
```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 6commit**
```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.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:定位现有查询**
```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 3sqlc generate**
```bash
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)`
```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 7commit**
```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.2tenant_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 5commit**
```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.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`
```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:跑测试**
```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 3commit**
```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 6commit**
```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.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**
```sql
ALTER TABLE tenant_monitoring_quotas
RENAME COLUMN primary_installation_id TO primary_client_id;
```
- [ ] **Step 2down migration**
```sql
ALTER TABLE tenant_monitoring_quotas
RENAME COLUMN primary_client_id TO primary_installation_id;
```
- [ ] **Step 3sql 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 5commit**
```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.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 查询调用点**
```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 5commit**
```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.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:定位**
```bash
grep -rln "tenant_monitoring\|MonitoringProjection\|monitoring_cleanup" server/internal/
```
- [ ] **Step 2:对每处按 Task 0.6.2 的模式改造**
- [ ] **Step 3seed 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.4Monitoring 改造 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 3commit**
```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.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**
```bash
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:写备忘录**
```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 2commit**
```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 3sqlc generate + 编译**
```bash
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:跑测试**
```bash
cd server && go test ./internal/tenant/transport/
cd apps/admin-web && pnpm test
```
- [ ] **Step 6commit**
```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.1Lint 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 / 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,确保所有现有代码都过**
```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 5commit**
```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 4commit**
```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 3admin-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 2request review,得到 2 个签字才能 close**
- [ ] **Step 3commit 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.4Plan 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` 是临时 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 明确标注)——这是设计上的正确分工,不是遗漏。