98f9e95875
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>
1653 lines
52 KiB
Markdown
1653 lines
52 KiB
Markdown
# 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 <JWT>` 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 明确标注)——这是设计上的正确分工,不是遗漏。
|