# 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_id,repository 层必须调用 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 错误