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

25 KiB
Raw Blame History

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 身份与租户

-- 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 模板与文章

-- 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 品牌

-- 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 基础设施

-- 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 返回:

// 成功
{
    "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 字段:

{
    "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

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 实现

// 从 context 中获取 tenant_idrepository 层必须调用
func TenantIDFromCtx(ctx context.Context) int64

所有 tenant repository 方法的 SQL 必须包含 WHERE tenant_id = $tenant_id。sqlc 生成的查询默认带上此参数。

6. 本地开发

6.1 docker-compose.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 命令

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 配置文件

# 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 错误