Files
geo/docs/superpowers/specs/2026-03-31-admin-web-backend-core-design.md
T
root de30497f59 feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including:
  - Tenant creation and management with associated migrations.
  - User creation and management with associated migrations.
  - Tenant membership management with associated migrations.
  - Platform user roles management with associated migrations.
  - Quota management with associated migrations.
  - Article and template management with associated migrations.
- Added HTTP handlers for templates and workspaces.
- Created tests for protected and public routes.
- Introduced a script to check tenant scope in SQL queries.
- Documented task plan for backend completion and frontend foundation.
2026-04-01 00:58:42 +08:00

677 lines
25 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.
# GEO admin-web 后端核心链路设计
## 1. 概述
### 1.1 目标
搭建 GEO 平台 tenant-api 后端核心链路,为 admin-web 前端提供可用的 API 服务。第一阶段聚焦四个核心模块:认证、工作台、文章/模板、品牌词库,让前端能跑通内容生成主流程。
### 1.2 决策摘要
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 进程模型 | 单进程 tenant-api | 当前只有 tenant 侧,gateway 无分发对象;鉴权中间件写在 shared 层,后续零成本拆出 |
| 脚手架 | 手工初始化 | sponge 生成的 handler/dao/service 命名与本项目 transport/app/domain/repository 四层结构不符,手工初始化成本更低 |
| Web 框架 | Gin | 架构文档指定 |
| DB 访问 | pgx + sqlc | 类型安全、性能最优、SQL 控制力强 |
| Migration | golang-migrate | 架构文档 §13.13.8 推荐 |
| 认证 | JWT (access + refresh) | 架构文档 §6 指定 |
| 日志 | zap 结构化 JSON | 架构文档 §13.13.10 推荐 |
| 配置 | YAML + env 覆盖 (viper) | 架构文档 §13.13.6 指定 |
| 错误码 | 统一格式 `{code, message, detail, request_id}` | 架构文档 §13.6.4 |
### 1.3 不包含
第一阶段明确不做:
- gateway 独立进程
- platform-api
- scheduler / worker-*
- prompt-rule、schedule、optimization、knowledge、media、tracking、account 模块
- RabbitMQ / Qdrant / 对象存储集成
- SSE 推送
这些在后续迭代中加入。
## 2. 项目结构
### 2.1 项目初始化
手工初始化 Go 项目(放弃 sponge),按架构文档 §5.2 的分层要求直接建目录结构:
- `transport` — HTTP handler、路由注册
- `app` — 业务逻辑 service
- `domain` — 领域模型(纯 Go 结构体,无框架依赖)
- `repository` — 数据访问(pgx + sqlc
### 2.2 目录结构
```
server/
├── cmd/
│ └── tenant-api/
│ └── main.go # 入口:初始化依赖、注册路由、启动 HTTP
├── internal/
│ ├── bootstrap/
│ │ └── bootstrap.go # 依赖容器:DB/Redis/Config 初始化
│ ├── shared/
│ │ ├── auth/
│ │ │ ├── jwt.go # JWT 颁发 (access 15min + refresh 7day)
│ │ │ ├── middleware.go # Gin 中间件:Token 校验 + 身份注入 ctx
│ │ │ └── claims.go # JWT Claims 结构体
│ │ ├── config/
│ │ │ └── config.go # Viper 配置加载
│ │ ├── middleware/
│ │ │ ├── cors.go
│ │ │ ├── request_id.go # 生成/复用 request_id
│ │ │ ├── recovery.go
│ │ │ ├── logger.go # 请求日志 (zap)
│ │ │ └── tenant_scope.go # 从 JWT 提取 tenant_id 注入 ctx
│ │ ├── response/
│ │ │ ├── response.go # 统一响应封装 Success/Error
│ │ │ └── errors.go # 错误码枚举与 AppError 类型
│ │ ├── observability/
│ │ │ └── logger.go # zap 初始化
│ │ └── repository/
│ │ ├── postgres/
│ │ │ └── postgres.go # pgx 连接池
│ │ └── redis/
│ │ └── redis.go # Redis 连接
│ ├── tenant/
│ │ ├── transport/
│ │ │ ├── router.go # 注册所有 tenant 路由组
│ │ │ ├── auth_handler.go # /api/auth/* handlers
│ │ │ ├── workspace_handler.go
│ │ │ ├── template_handler.go
│ │ │ ├── article_handler.go
│ │ │ └── brand_handler.go
│ │ ├── app/
│ │ │ ├── auth_service.go
│ │ │ ├── workspace_service.go
│ │ │ ├── template_service.go
│ │ │ ├── article_service.go
│ │ │ └── brand_service.go
│ │ ├── domain/
│ │ │ ├── user.go
│ │ │ ├── tenant.go
│ │ │ ├── article.go
│ │ │ ├── article_template.go
│ │ │ ├── article_version.go
│ │ │ ├── generation_task.go
│ │ │ ├── brand.go
│ │ │ ├── brand_keyword.go
│ │ │ ├── brand_question.go
│ │ │ └── competitor.go
│ │ └── repository/
│ │ ├── queries/ # sqlc SQL 文件
│ │ │ ├── user.sql
│ │ │ ├── tenant.sql
│ │ │ ├── article.sql
│ │ │ ├── article_template.sql
│ │ │ ├── brand.sql
│ │ │ └── ...
│ │ ├── sqlc.yaml # sqlc 配置
│ │ ├── generated/ # sqlc 生成的 Go 代码
│ │ ├── user_repo.go # Repository 接口 + 实现
│ │ ├── article_repo.go
│ │ ├── template_repo.go
│ │ └── brand_repo.go
│ └── gateway/ # 预留空目录
├── migrations/ # golang-migrate SQL 文件
│ ├── 20260331100000_create_tenants.up.sql
│ ├── 20260331100000_create_tenants.down.sql
│ ├── 20260331100001_create_users.up.sql
│ ├── ...
├── configs/
│ ├── config.yaml # 默认配置
│ └── config.local.yaml # 本地开发覆盖 (gitignore)
├── go.mod
├── go.sum
├── Makefile # dev-init, dev-api, migrate-up, sqlc-generate
└── docker-compose.yaml # PostgreSQL + Redis (本地开发)
```
## 3. 数据库 Schema
### 3.1 Migration 文件规划
按架构文档 §13.11,第一阶段需要以下表。命名规则:`YYYYMMDDHHMMSS_description.up.sql`
#### 3.1.1 身份与租户
```sql
-- 20260331100000_create_tenants.up.sql
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, frozen
frozen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_tenant_name UNIQUE (name)
);
-- 20260331100001_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
avatar_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_users_email UNIQUE (email)
);
-- 20260331100002_create_tenant_memberships.up.sql
CREATE TABLE tenant_memberships (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_role VARCHAR(20) NOT NULL, -- tenant_admin, editor, kol
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_tenant_user UNIQUE (tenant_id, user_id)
);
-- 20260331100003_create_platform_user_roles.up.sql
CREATE TABLE platform_user_roles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
platform_role VARCHAR(20) NOT NULL, -- super_admin, ops, sre
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_platform_user_role UNIQUE (user_id, platform_role)
);
-- 20260331100004_create_plans.up.sql
CREATE TABLE plans (
id BIGSERIAL PRIMARY KEY,
plan_code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
quota_policy_json JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_plan_code UNIQUE (plan_code)
);
-- 20260331100005_create_tenant_plan_subscriptions.up.sql
CREATE TABLE tenant_plan_subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
plan_id BIGINT NOT NULL REFERENCES plans(id),
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_tenant_plan_active ON tenant_plan_subscriptions(tenant_id) WHERE status = 'active'; -- 同一时间只有一个活跃订阅
-- 20260331100006_create_tenant_quota_ledgers.up.sql
CREATE TABLE tenant_quota_ledgers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
quota_type VARCHAR(50) NOT NULL, -- article_generation
delta INT NOT NULL,
balance_after INT NOT NULL,
reason VARCHAR(255),
reference_type VARCHAR(50),
reference_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tenant_quota_tenant_type_created ON tenant_quota_ledgers(tenant_id, quota_type, created_at DESC);
-- 20260331100007_create_quota_reservations.up.sql
CREATE TABLE quota_reservations (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
quota_type VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id BIGINT NOT NULL,
reserved_amount INT NOT NULL,
consumed_amount INT NOT NULL DEFAULT 0,
refunded_amount INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, confirmed, refunded, expired
expire_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status);
```
#### 3.1.2 模板与文章
```sql
-- 20260331100010_create_article_templates.up.sql
CREATE TABLE article_templates (
id BIGSERIAL PRIMARY KEY,
scope VARCHAR(20) NOT NULL, -- platform, tenant
tenant_id BIGINT REFERENCES tenants(id),
origin_type VARCHAR(20) NOT NULL DEFAULT 'platform', -- platform, tenant_custom, kol_share
share_code_id BIGINT,
template_key VARCHAR(100) NOT NULL,
template_name VARCHAR(200) NOT NULL,
schema_json JSONB NOT NULL DEFAULT '{}',
prompt_template TEXT,
prompt_visibility VARCHAR(20) NOT NULL DEFAULT 'visible', -- visible, sealed
protected_prompt_asset_key TEXT,
card_config_json JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
version_no INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_template_scope_key_version UNIQUE (scope, tenant_id, template_key, version_no)
);
-- 20260331100011_create_articles.up.sql
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
source_type VARCHAR(30) NOT NULL, -- template, prompt_rule, optimization
template_id BIGINT REFERENCES article_templates(id),
prompt_rule_id BIGINT,
current_version_id BIGINT,
generate_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, generating, completed, failed
publish_status VARCHAR(20) NOT NULL DEFAULT 'unpublished', -- unpublished, publishing, success, failed, pending_review
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_article_tenant_created ON articles(tenant_id, created_at DESC);
CREATE INDEX idx_article_tenant_status ON articles(tenant_id, generate_status, publish_status, created_at DESC);
-- 20260331100012_create_article_versions.up.sql
CREATE TABLE article_versions (
id BIGSERIAL PRIMARY KEY,
article_id BIGINT NOT NULL REFERENCES articles(id),
version_no INT NOT NULL,
title VARCHAR(500),
html_content TEXT,
markdown_content TEXT,
word_count INT NOT NULL DEFAULT 0,
source_label VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_article_version_no UNIQUE (article_id, version_no)
);
-- 20260331100013_create_generation_tasks.up.sql
CREATE TABLE generation_tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
article_id BIGINT REFERENCES articles(id),
task_batch_id VARCHAR(100),
quota_reservation_id BIGINT REFERENCES quota_reservations(id),
task_type VARCHAR(50) NOT NULL, -- template, prompt, batch_item, schedule_item, optimize
request_hash VARCHAR(64),
input_params_json JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_generation_task_status_created ON generation_tasks(status, created_at);
CREATE INDEX idx_generation_task_tenant ON generation_tasks(tenant_id, created_at DESC);
```
#### 3.1.3 品牌
```sql
-- 20260331100020_create_brands.up.sql
CREATE TABLE brands (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
name VARCHAR(200) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_brand_tenant_name UNIQUE (tenant_id, name)
);
-- 20260331100021_create_brand_keywords.up.sql
CREATE TABLE brand_keywords (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
name VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uk_brand_keyword_name UNIQUE (brand_id, name)
);
-- 20260331100022_create_brand_questions.up.sql
CREATE TABLE brand_questions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
keyword_id BIGINT NOT NULL REFERENCES brand_keywords(id),
current_version_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_brand_questions_keyword ON brand_questions(keyword_id);
-- 20260331100023_create_brand_question_versions.up.sql
CREATE TABLE brand_question_versions (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES brand_questions(id),
question_text TEXT NOT NULL,
question_hash VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_question_version_no UNIQUE (question_id, version_no)
);
CREATE INDEX idx_question_hash ON brand_question_versions(question_hash);
-- 20260331100024_create_competitors.up.sql
CREATE TABLE competitors (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
name VARCHAR(200) NOT NULL,
website TEXT,
description TEXT,
product_lines_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_competitor_brand ON competitors(brand_id);
```
#### 3.1.4 基础设施
```sql
-- 20260331100030_create_task_records.up.sql
CREATE TABLE task_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
task_type VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
task_batch_id VARCHAR(100),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_task_record_status_type ON task_records(status, task_type);
-- 20260331100031_create_audit_logs.up.sql
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
operator_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT,
module VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
before_json JSONB,
after_json JSONB,
result VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_module_created ON audit_logs(module, created_at DESC);
```
### 3.2 种子数据
`make dev-init` 需要写入最小种子数据:
- 1 个默认租户
- 1 个 tenant_admin 用户 (admin@geo.local / 默认密码)
- 1 个 tenant_membership
- 1 个免费套餐 (plan)
- 1 个订阅关系
- 初始额度记录
- 3-4 个平台预设模板 (Top X 文章、产品评测文章、研究报告文章、品牌词搜索扩写)
## 4. API 设计
### 4.1 统一响应格式
所有 API 返回:
```json
// 成功
{
"code": 0,
"message": "ok",
"data": { ... },
"request_id": "req_xxx"
}
// 失败
{
"code": 40301,
"message": "quota_insufficient",
"detail": "生成额度不足,请升级套餐或等待额度重置",
"request_id": "req_xxx"
}
```
错误码段 (§13.6.4)`400xx` 参数错误, `401xx` 认证失败, `403xx` 权限/额度不足, `404xx` 资源不存在, `409xx` 状态冲突, `429xx` 频率限制, `500xx` 内部错误, `503xx` 下游不可用。
### 4.2 分页格式
请求:`?page=1&page_size=20&sort=created_at&order=desc`
响应 data 字段:
```json
{
"items": [...],
"total": 100,
"page": 1,
"page_size": 20
}
```
### 4.3 Auth API
| Method | Path | 说明 |
|--------|------|------|
| POST | `/api/auth/login` | 邮箱+密码登录,返回 access_token + refresh_token |
| POST | `/api/auth/refresh` | 刷新 access_token |
| GET | `/api/auth/me` | 返回当前用户信息、tenant_id、tenant_role、permissions[] |
| POST | `/api/auth/logout` | 登出 (可选:加入 token 黑名单) |
**JWT Claims**
```go
type Claims struct {
UserID int64 `json:"user_id"`
TenantID int64 `json:"tenant_id"`
Role string `json:"role"` // tenant_admin, editor
jwt.RegisteredClaims
}
```
- Access token TTL: 15 分钟
- Refresh token TTL: 7 天
- Refresh token 存 Redis,支持主动失效
### 4.4 Workspace API
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/tenant/workspace/overview` | 聚合统计:文章数、发布数、品牌数、媒体平台数 |
| GET | `/api/tenant/workspace/recent-articles` | 最近 10 篇文章 (带生成状态/发布状态) |
| GET | `/api/tenant/workspace/quota-summary` | 当前套餐额度:已用/总量/重置时间 |
| GET | `/api/tenant/workspace/template-cards` | 可用模板卡片列表 (平台预设 + 租户自建) |
workspace_service 聚合多个 repository 数据,单次返回,避免前端首屏发起过多散请求 (§13.2)。
### 4.5 Template API
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/tenant/templates` | 模板列表 (scope=platform 或 tenant_id 匹配) |
| GET | `/api/tenant/templates/{id}` | 模板详情 (prompt_visibility=sealed 时不返回 prompt_template) |
| GET | `/api/tenant/templates/{id}/schema` | 模板表单 schema (JSON) |
| POST | `/api/tenant/templates/{id}/generate` | 提交生成任务 |
**生成流程** (§10.1)
1. 校验租户身份、额度、参数
2. 在单个 PG 事务内:额度预扣 + generation_tasks 创建 + articles 占位记录
3. 返回 task_id 和 article_id
4. (V1 第一阶段:任务状态模拟为同步完成或标记为 queued 等待 worker)
### 4.6 Article API
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/tenant/articles` | 文章列表 (分页、筛选:generate_status, publish_status, source_type, template_id, keyword, date_range) |
| GET | `/api/tenant/articles/{id}` | 文章详情 (含当前版本内容) |
| GET | `/api/tenant/articles/{id}/versions` | 文章版本列表 |
| DELETE | `/api/tenant/articles/{id}` | 删除文章 |
### 4.7 Brand API
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/tenant/brands` | 品牌列表 |
| POST | `/api/tenant/brands` | 创建品牌 |
| GET | `/api/tenant/brands/{id}` | 品牌详情 |
| PUT | `/api/tenant/brands/{id}` | 更新品牌 |
| DELETE | `/api/tenant/brands/{id}` | 删除品牌 |
| GET | `/api/tenant/brands/{id}/keywords` | 关键词列表 |
| POST | `/api/tenant/brands/{id}/keywords` | 创建关键词 |
| PUT | `/api/tenant/brands/{id}/keywords/{kid}` | 更新关键词 |
| DELETE | `/api/tenant/brands/{id}/keywords/{kid}` | 删除关键词 |
| GET | `/api/tenant/brands/{id}/questions` | 问题列表 (按关键词筛选) |
| POST | `/api/tenant/brands/{id}/questions` | 创建问题 (自动创建 v1 版本) |
| GET | `/api/tenant/brands/{id}/question-versions` | 问题版本列表 |
| GET | `/api/tenant/brands/{id}/competitors` | 竞品列表 |
| POST | `/api/tenant/brands/{id}/competitors` | 创建竞品 |
| PUT | `/api/tenant/brands/{id}/competitors/{cid}` | 更新竞品 |
| DELETE | `/api/tenant/brands/{id}/competitors/{cid}` | 删除竞品 |
### 4.8 Health Check
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/health/live` | 存活检查 |
| GET | `/api/health/ready` | 就绪检查 (DB + Redis 可达) |
## 5. 中间件链
请求处理顺序 (§6.3)
1. Recovery (panic 恢复)
2. RequestID (生成/复用 request_id)
3. Logger (请求日志)
4. CORS
5. Auth (JWT 校验 + 身份注入 ctx) — `/api/auth/login``/api/health/*` 除外
6. TenantScope (从 JWT 提取 tenant_id 注入 ctx)
7. Handler
### 5.1 TenantScope 实现
```go
// 从 context 中获取 tenant_idrepository 层必须调用
func TenantIDFromCtx(ctx context.Context) int64
```
所有 tenant repository 方法的 SQL 必须包含 `WHERE tenant_id = $tenant_id`。sqlc 生成的查询默认带上此参数。
## 6. 本地开发
### 6.1 docker-compose.yaml
仅启动基础设施:
```yaml
services:
postgres:
image: postgres:16
ports: ["5432:5432"]
environment:
POSTGRES_DB: geo
POSTGRES_USER: geo
POSTGRES_PASSWORD: geo_dev
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
pgdata:
```
### 6.2 Makefile 命令
```makefile
dev-init: # docker compose up -d + migrate up + seed data
dev-api: # go run ./server/cmd/tenant-api
migrate-up: # golang-migrate up
migrate-down: # golang-migrate down 1
migrate-create: # 创建新 migration 文件
sqlc-generate: # sqlc generate
lint: # golangci-lint run
test: # go test ./...
```
### 6.3 配置文件
```yaml
# configs/config.yaml
server:
port: 8080
mode: debug # debug / release
database:
host: localhost
port: 5432
user: geo
password: geo_dev
dbname: geo
sslmode: disable
max_open_conns: 25
max_idle_conns: 5
redis:
addr: localhost:6379
db: 0
jwt:
secret: "${JWT_SECRET:dev-secret-change-in-production}"
access_ttl: 15m
refresh_ttl: 168h # 7 days
log:
level: debug
format: json
```
## 7. 验证方式
1. `make dev-init` 启动基础设施并初始化数据库
2. `make dev-api` 启动 tenant-api
3. `POST /api/auth/login` 登录获取 token
4. `GET /api/auth/me` 验证身份
5. `GET /api/tenant/workspace/overview` 验证工作台数据
6. `GET /api/tenant/templates` 查看预设模板
7. `POST /api/tenant/brands` 创建品牌
8. `GET /api/tenant/brands/{id}/keywords` 查看关键词
9. `make test` 所有测试通过
10. `make lint` 无 lint 错误