feat: add knowledge management functionality with CRUD operations and database schema

- Implemented KnowledgeHandler for managing knowledge groups and items, including listing, creating, updating, and deleting operations.
- Added database migration scripts to create necessary tables for knowledge management, including knowledge_groups, knowledge_items, knowledge_parse_tasks, and knowledge_chunks_meta.
- Introduced prompt_rule_knowledge_groups table to associate prompt rules with knowledge groups.
This commit is contained in:
2026-04-05 17:14:13 +08:00
parent 134dd063c3
commit 446f865cdf
62 changed files with 9503 additions and 2664 deletions
@@ -1,675 +0,0 @@
# 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}/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 错误