Files
geo/docs/superpowers/specs/2026-05-12-keyword-expansion-design.md
T
root 37b0b32327 feat(admin-brand): add question expansion service and related functionality
- Implemented QuestionExpansionService for generating and materializing questions based on combinations and AI distillation.
- Added question metadata classification logic to infer intent and layer of questions.
- Created API handlers for question expansion operations including combination preview, AI distillation, metadata classification, and materialization.
- Introduced database migrations to support new question-related fields and constraints in brand_questions and brand_keywords tables.
- Added caching mechanism for AI distillation results to improve performance.
- Defined JSON schemas for question distillation responses to ensure data integrity.
2026-05-12 21:53:36 +08:00

1159 lines
52 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.
# 问题集扩展(Question Expansion)设计
**Date:** 2026-05-12
**Status:** Draft for Review
**Author:** assistant
**Module:** admin-web · server (tenant-api)
**Target Branch:** `main`
---
## 1. Overview
本设计把原来的“先管理词,再挂问题”的页面心智改成**问题驱动**:
1. 用户进入 **品牌管理**
2. 先看品牌卡片列表。
3. 选中一个品牌后,默认进入 **问题集** Tab。
4. 问题以全宽表格展示,右侧提供 **「批量扩展问题」** 与 **「新增单条问题」**。
5. 用户新增、批量生成、保存的对象都是“问题”,不是中间层实体。
用户可见的信息架构只保留四个概念:**品牌、问题集、竞品库、问题**。
系统仍会在后台保留自动标签和内部兼容记录,用于下游 GEO 监测、漏斗分析、来源追踪和老表结构兼容。但这些不再成为用户创建问题时必须理解的产品概念。
**用户可见原则**
- 页面标题是 **品牌管理**,不是独立扩展页。
- 主按钮是 **新增单条问题**,旁边提供 **批量扩展问题** 进入 page 向导。
- 主表列为 **问题集 / 创建时间 / 操作**
- 不展示列表、卡片或表单字段来要求用户先选择中间层实体。
- `brand_keywords` / `brand_questions.keyword_id` 只作为后端兼容实现细节出现。
> 本文只改设计文档,不包含实施代码。
---
## 2. Current State
现有品牌库模块已经具备:
- `brands` / `brand_keywords` / `brand_questions` / `competitors` 四张表。
- 品牌、问题、竞品的 CRUD。
- 租户配额、Redis 缓存、审计日志。
- 既有 LLM 客户端与 AI 点扣减能力。
- `apps/admin-web/src/views/BrandsView.vue` 作为品牌管理入口。
主要问题:
1. 页面模型过于工程化,用户需要先理解内部层级,再维护问题。
2. 旧 UI 是左侧实体列表 + 右侧问题表的两栏结构,不符合“图二”的品牌卡 + 问题表布局。
3. 批量创建问题能力不足,用户仍需要一条条录入。
4. 问题缺少统一的来源、意图、层级等元数据,下游分析能力受限。
5. 现有表结构里 `brand_questions.keyword_id` 为必填,短期不能直接删除,需要用内部默认桶兼容。
---
## 3. Goals & Non-Goals
### 3.1 Goals
| ID | Goal |
| --- | --- |
| G1 | 把品牌管理页改成问题驱动:品牌卡片 → 问题集 Tab → 全宽问题表 → 单条新增 / 批量扩展 |
| G2 | UI 对齐图二:顶部品牌管理标题、品牌卡片列表、`问题集 / 竞品库` Tab、右侧 `批量扩展问题``新增单条问题` |
| G3 | 用户新增的是问题;批量生成、AI 扩展、手动批量都只产出问题候选 |
| G4 | 用户可见文案不要求理解 `brand_keywords` / `keyword_id` 等内部实现 |
| G5 | 后端保持低风险兼容:继续写入 `brand_questions`,并用内部默认问题桶满足旧外键 |
| G6 | 问题文本在同一品牌下唯一,DB 层兜底防重复 |
| G7 | 系统自动补充 `layer` / `intent` / `source` 元数据,但主表默认不展示这些高级标签 |
| G8 | 所有写入保留审计日志、配额检查、缓存失效和并发保护 |
### 3.2 Non-Goals
- 不在本次设计中删除 `brand_keywords` 表或 `brand_questions.keyword_id` 外键。
- 不重写下游监测 pipeline。
- 不做语义向量召回;V1 用规则 + LLM 生成问题候选。
- 不引入新的前端状态管理框架。
- 不做实时协作编辑。
- 不做第三方数据源接入。
---
## 4. Approach Comparison
| 维度 | 方案 A:继续暴露旧层级 | 方案 B:立即重构数据库 | 方案 C:问题驱动 UI + 内部兼容桶 |
| --- | --- | --- | --- |
| 用户心智 | 仍复杂 | 最干净 | 干净 |
| 工程风险 | 低 | 高 | 中低 |
| 数据迁移 | 少 | 大 | 少 |
| UI 是否贴图二 | 否 | 是 | 是 |
| 是否影响旧服务 | 否 | 是 | 否 |
| V1 落地速度 | 快但产品不对 | 慢 | 推荐 |
### 4.1 Chosen: 方案 C
选择方案 C
- 前端彻底按问题驱动改造,用户不再看到旧层级。
- 后端短期继续使用现有表,不做破坏性迁移。
- 写问题时自动确保当前品牌有一个内部默认问题桶。
- 未来若要彻底移除内部层,可单独做数据库迁移。
---
## 5. User-Facing Outcome
完成后,用户路径如下:
1. 左侧导航进入 **品牌管理**
2. 页面顶部显示标题 **品牌管理**,右上角 **+ 创建新品牌**。
3. 标题下方显示可折叠的 **品牌管理说明**
4. 下方是品牌卡片列表,每张卡展示品牌名、状态、问题数量、竞品数量等摘要。
5. 用户选中某个品牌后,下方详情区默认停在 **问题集** Tab,旁边是 **竞品库** Tab。
6. **问题集** Tab 右侧固定展示 **批量扩展问题****新增单条问题**
7. 表格展示:
- 问题集
- 创建时间
- 操作
8. 点击 **新增单条问题** 后,在当前页弹出轻量表单,只保存一条问题。
9. 点击 **批量扩展问题** 后,进入问题创建 page 向导:
- 拓词工具生成问题。
- AI 扩展生成问题。
- 手动批量粘贴问题。
10. 用户在候选预览中勾选、编辑、删除,确认后保存到当前品牌的问题集。
11. 保存成功后刷新问题表;重复、过短、超配额的问题进入跳过明细。
---
## 6. Requirements
| ID | Requirement |
| --- | --- |
| R1 | 前端用户可见入口统一从 `BrandsView.vue` / 品牌管理路由进入;新建问题使用品牌管理下的子向导页,不新增独立扩展模块 |
| R2 | 页面主按钮必须叫 **新增单条问题**,用于新建单条问题;旁边放 **批量扩展问题** 进入新建问题 page 向导 |
| R3 | 主表只按品牌维度展示问题,不再按内部中间层分组 |
| R4 | 主表列为 `问题集 / 创建时间 / 操作`,可后续追加状态列,但不恢复旧分组列 |
| R5 | 拓词工具采用五列输入:地域词 / 前缀词 / 核心词 / 行业词 / 后缀词;核心词与行业词必填 |
| R6 | 拓词工具候选由后端计算,默认最多返回 500 条候选 |
| R7 | AI 扩展单次最多返回 20 条候选,30s 超时 |
| R8 | AI 扩展必须使用 JSON Schema 强约束输出 |
| R9 | AI 扩展失败必须退还 AI 点:未配置不预留,超时/解析失败已预留则 refund |
| R10 | 用户保存前,所有候选都只是前端临时态;只有点击保存后才写库 |
| R11 | 物化分两阶段:事务外预校验,事务内重算配额和重复;事务异常则本次 accepted 全回滚 |
| R12 | 后端 API 不接受用户选择 `keyword_id` / `keyword_name`;service 内部使用默认问题桶兼容旧表 |
| R13 | 必须新增品牌级问题文本唯一索引:`(brand_id, lower(btrim(question_text))) WHERE deleted_at IS NULL` |
| R14 | 重复判定与 R13 一致:预校验命中返回 `duplicate`,并发命中返回 `duplicate_concurrent` |
| R15 | `layer``intent``source` 是系统元数据,默认自动补齐;主表 V1 不展示,不要求用户填写 |
| R16 | 入库后元数据只读;用户只能改问题文本,元数据纠错路径是删除后重建 |
| R17 | Handler 必须校验枚举:`layer ∈ {L1..L5}``intent ∈ {informational,evaluative,decisional}``source ∈ {manual,combination,ai_distill}` |
| R18 | `source=auto` 仅允许内部默认问题桶使用,外部请求传入一律 `400 invalid_enum` |
| R19 | 所有写入必须走既有 `auditlog.AsyncWriter` |
| R20 | 写入后必须失效当前品牌相关缓存 |
| R21 | 账号级问题配额是硬限制;同一账号下所有品牌共享 `question_limit`;额度来自 `brand_library.question_limits_by_plan` YAML 配置,按套餐 code 可配置,默认 `free=5``plus=25``pro=50` |
| R22 | 如果本次没有任何可写入问题,返回 `400 no_valid_questions`,不得写入空数据 |
| R23 | 旧 `CreateQuestion` / `UpdateQuestion` 命中品牌级唯一索引时必须返回 `409 question_exists` |
| R24 | i18n 必须同步维护 `zh-CN.ts``en-US.ts` |
| R25 | 图二品牌管理页及其数据聚合必须过滤内部默认问题桶(`source=auto`),避免系统兼容记录进入用户可见 UI |
| R26 | 配额配置必须热更新友好:`ConfigStore` reload 后新保存请求读取最新额度,不把 5/25/50 硬编码进 service;旧 `max_keywords` / `max_questions_per_keyword` 不再作为问题驱动页面的额度来源 |
---
## 7. UI Design
### 7.1 主视图:品牌管理
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 品牌管理 [+ 创建新品牌] │
│ │
│ ┌─ 品牌管理说明 ──────────────────────────────────────────────────────────┐ │
│ │ 管理品牌、问题集和竞品库。用于后续 AI 平台监测与 GEO 分析。 [收起] │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 省心推 │ │ 全屋定制Pro │ │ 某教育品牌 │ │ + 添加品牌 │ │
│ │ 问题 18 │ │ 问题 42 │ │ 问题 9 │ │ │ │
│ │ 竞品 4 │ │ 竞品 7 │ │ 竞品 2 │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ [问题集] [竞品库] [批量扩展问题] [新增单条问题] │ │
│ ├──────────────────────────────────────────────────────────────────────────┤ │
│ │ 问题集 创建时间 操作 │ │
│ │ ─────────────────────────────────────────────────────────────────────── │ │
│ │ 省心推是什么平台? 2026-05-09 编辑 │ │
│ │ 省心推怎么帮品牌做 GEO 优化? 2026-05-09 编辑 │ │
│ │ 省心推和传统 SEO 工具有什么区别? 2026-05-10 编辑 │ │
│ │ 中小企业如何用省心推监测 AI 搜索曝光? 2026-05-10 编辑 │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```
**关键设计约束**
- 品牌卡是上层选择器,不是问题分组容器。
- 问题表使用全宽布局,适合扫描和批量操作。
- 右侧主按钮固定为 **新增单条问题**,旁边放次按钮 **批量扩展问题**
- 表格操作区只放编辑、删除、更多菜单。
- L1-L5 / intent 等系统标签不放在主表首版里,避免页面重新回到工程化心智。
### 7.2 新建问题向导页
```
┌─ ← 新建问题 省心推 ────────────────────────────────┐
│ 按向导生成候选,确认后一次性保存到当前品牌的问题集。 │
├──────────────────────────────────────────────────────────────────────────────┤
│ [配置生成方式] ─────────────── [确认候选] │
│ │
│ 创建方式 │
│ [拓词工具] [AI 扩展] [手动批量] │
│ │
│ 问题内容 / 拓词工具 / AI 主题 / 批量文本 │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ 省心推适合哪些企业做 GEO 监测? │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 候选预览 │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ ☑ 省心推是什么平台? │ │
│ │ ☑ 省心推怎么监测 ChatGPT 里的品牌曝光? │ │
│ │ ☑ 中小企业如何判断 AI 搜索有没有提到自己? │ │
│ │ ☐ 省心推 │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ 共 24 条候选 · 已选择 21 条 · 过短 1 条 · 重复 2 条 │
│ │
│ [取消] [上一步] [保存问题 (21)] │
└──────────────────────────────────────────────────────────────────────────────┘
```
**交互规则**
- 三种批量方式互斥,切换方式会清空当前未保存候选。
- 单条新增不放在向导页内,由品牌管理页的 **新增单条问题** 按钮直接完成。
- 候选行支持勾选、编辑文本、删除。
- 过短、重复、明显不像问题的候选默认不勾选并标灰。
- 高级元数据不在默认视图中展示;如后续产品需要,可放入“高级信息”折叠区。
### 7.3 拓词工具面板
```
┌─ 拓词工具 ─────────────────────────────────────────────────────────────────┐
│ │
│ ┌─1.地域词─┐ ┌─2.前缀词─┐ ┌─3.核心词(必填)─┐ ┌─4.行业词(必填)─┐ ┌─5.后缀词─┐ │
│ │ 合肥 │ │ 好用的│ │ GEO │ │ 工具 │ │ 哪家好│ │
│ │ 上海 │ │ 专业的│ │ AI搜索优化 │ │ 平台 │ │ 怎么选│ │
│ │ 华东 │ │ 靠谱的│ │ │ │ 服务商 │ │ 推荐 │ │
│ └───────┘ └───────┘ └────────────┘ └────────────┘ └───────┘ │
│ │
│ 每行一个词;缺位列会被跳过;核心词与行业词不能为空。 │
│ │
│ 预计生成:162 条,最多预览 500 条 │
│ │
│ [生成候选] │
└──────────────────────────────────────────────────────────────────────────────┘
```
**生成规则**
- 每列按换行分词;空行不参与组合。
- 空的可选列按“默认词”处理,即跳过该列。
- 核心词和行业词必填。
- 可选列为空时跳过,不拼接空字符串。
- 结果必须经过后端去重、长度校验、自动补元数据。
- 超过 500 条时后端截断,前端展示截断提示。
### 7.4 AI 扩展状态
```
生成中:
┌─ AI 扩展 ─────────────────────────────────────────────────────────────── × ─┐
│ 正在为「省心推」生成用户真实会问的问题,预计 15-25 秒。 │
│ │
│ [取消任务] │
└──────────────────────────────────────────────────────────────────────────────┘
失败:
┌─ AI 扩展失败 ─────────────────────────────────────────────────────────── × ─┐
│ 大模型暂不可用或输出无法解析。 │
│ AI 点已退还。 │
│ │
│ [重试] [切换为手动批量] [关闭] │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 8. Data Model
### 8.1 Migration
项目当前未发布生产、无真实租户数据。本次 migration 不需要 `CONCURRENTLY` 和历史去重脚本;若未来在线上数据上做同类变更,必须改成预扫描 + 并发建索引方案。
```sql
-- migrations/2026-05-12T14:00:00_add_question_expansion_metadata.up.sql
ALTER TABLE brand_keywords
ADD COLUMN layer VARCHAR(8) NOT NULL DEFAULT 'L2',
ADD COLUMN seed_word VARCHAR(200),
ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'manual';
ALTER TABLE brand_keywords
ADD CONSTRAINT ck_brand_keywords_layer
CHECK (layer IN ('L1','L2','L3','L4','L5')),
ADD CONSTRAINT ck_brand_keywords_source
CHECK (source IN ('manual','combination','ai_distill','auto'));
CREATE INDEX idx_brand_keywords_internal_source
ON brand_keywords (brand_id, source)
WHERE deleted_at IS NULL;
ALTER TABLE brand_questions
ADD COLUMN layer VARCHAR(8) NOT NULL DEFAULT 'L2',
ADD COLUMN intent VARCHAR(16) NOT NULL DEFAULT 'informational',
ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'manual';
ALTER TABLE brand_questions
ADD CONSTRAINT ck_brand_questions_layer
CHECK (layer IN ('L1','L2','L3','L4','L5')),
ADD CONSTRAINT ck_brand_questions_intent
CHECK (intent IN ('informational','evaluative','decisional')),
ADD CONSTRAINT ck_brand_questions_source
CHECK (source IN ('manual','combination','ai_distill'));
CREATE INDEX idx_brand_questions_intent
ON brand_questions (brand_id, intent)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_brand_question_text_active
ON brand_questions (brand_id, lower(btrim(question_text)))
WHERE deleted_at IS NULL;
```
### 8.2 字段语义
| 表 | 字段 | 语义 | 用户是否可见 |
| --- | --- | --- | --- |
| `brand_keywords` | `layer` | 内部默认问题桶的系统标签;兼容旧结构 | 否 |
| `brand_keywords` | `seed_word` | 内部默认问题桶创建时的品牌名快照 | 否 |
| `brand_keywords` | `source` | `auto` 表示系统内部默认问题桶 | 否 |
| `brand_questions` | `layer` | 系统推断的问题层级,用于下游分析 | V1 默认不展示 |
| `brand_questions` | `intent` | 系统推断的问题意图 | V1 默认不展示 |
| `brand_questions` | `source` | 问题来源:手动、拓词工具、AI 扩展 | 可在后续筛选中展示 |
### 8.3 唯一性策略
同一品牌下,归一化后的问题文本只能存在一条 active 记录:
```sql
(brand_id, lower(btrim(question_text))) WHERE deleted_at IS NULL
```
V1 只做 `trim + lower`,不剥离标点。原因是带问号和不带问号可能影响实际 AI 平台回答,先保留文本差异。
---
## 9. Backend Architecture
### 9.1 文件布局
```
server/internal/tenant/app/
question_expansion_service.go ← NEW · 问题生成与物化核心服务
question_metadata_classify.go ← NEW · layer + intent 规则分类器
question_distill_prompt.go ← NEW · AI 扩展提示词模板
brand_service.go ← 扩展 QuestionResponse metadata;创建品牌时创建默认问题桶
cache_support.go ← 复用现有 brand:* 缓存键
server/internal/tenant/transport/
question_expansion_handler.go ← NEW · Gin handler
router.go ← brands group 下挂问题生成路由
brand_handler.go ← 旧问题 CRUD 增加唯一冲突映射
server/internal/bootstrap/
bootstrap.go ← 注入 llmClient + brandService → questionExpansion service
```
### 9.2 `QuestionExpansionService`
```go
type QuestionExpansionService struct {
pool *pgxpool.Pool
llm llm.Client
brand *BrandService
cache sharedcache.Cache
audit *auditlog.AsyncWriter
}
func (s *QuestionExpansionService) GenerateByCombination(
ctx context.Context,
brandID int64,
req QuestionCombinationRequest,
) (*QuestionCandidateResult, error)
func (s *QuestionExpansionService) GenerateByAIDistill(
ctx context.Context,
brandID int64,
req QuestionDistillRequest,
) (*QuestionCandidateResult, error)
func (s *QuestionExpansionService) MaterializeQuestions(
ctx context.Context,
brandID int64,
req MaterializeQuestionsRequest,
) (*MaterializeQuestionsResult, error)
func (s *QuestionExpansionService) ClassifyMetadata(
ctx context.Context,
brandID int64,
texts []string,
) ([]ClassifiedQuestion, error)
```
### 9.3 关键类型
```go
type QuestionCombinationRequest struct {
Region []string `json:"region"`
Prefix []string `json:"prefix"`
Core []string `json:"core" binding:"required"`
Industry []string `json:"industry" binding:"required"`
Suffix []string `json:"suffix"`
MaxItems int `json:"max_items"` // default 500, hard cap 500
}
type QuestionDistillRequest struct {
SeedTopic string `json:"seed_topic" binding:"required,min=2"`
}
type QuestionCandidate struct {
Text string `json:"text"`
Layer string `json:"layer"`
Intent string `json:"intent"`
Source string `json:"source"` // manual / combination / ai_distill
MissingSuffix bool `json:"missing_suffix"`
TooShort bool `json:"too_short"`
Duplicate bool `json:"duplicate"` // brand-level duplicate
SuggestSkip bool `json:"suggest_skip"`
}
type QuestionCandidateResult struct {
Candidates []QuestionCandidate `json:"candidates"`
AIPointsCharged int `json:"ai_points_charged,omitempty"`
CacheHit bool `json:"cache_hit,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type MaterializeQuestionsRequest struct {
Source string `json:"source"` // manual / combination / ai_distill
Questions []MaterializeQuestion `json:"questions" binding:"required,min=1"`
}
type MaterializeQuestion struct {
Text string `json:"text" binding:"required"`
Layer string `json:"layer"`
Intent string `json:"intent"`
Source string `json:"source"`
}
type MaterializeQuestionsResult struct {
CreatedQuestions int `json:"created_questions"`
SkippedQuestions []SkipReason `json:"skipped_questions"`
}
type SkipReason struct {
Text string `json:"text"`
Reason string `json:"reason"`
}
```
`SkipReason.Reason` 取值:
- `too_short`
- `invalid_text`
- `duplicate`
- `duplicate_in_batch`
- `quota_exceeded`
- `duplicate_concurrent`
- `quota_exceeded_concurrent`
### 9.4 自动元数据分类
系统自动为问题补充两个标签:
| 字段 | 用途 | 是否主表展示 |
| --- | --- | --- |
| `intent` | 区分了解、评估、决策类问题 | 否 |
| `layer` | 区分品牌、业务、痛点、长尾、对比等问题层级 | 否 |
#### Intent 规则
| 类别 | 触发词 | 默认 |
| --- | --- | --- |
| `decisional` | 哪家好、哪个好、怎么选、推荐、哪里买、适合谁 | - |
| `evaluative` | 对比、区别、哪个更、优劣、测评、排名、值不值 | - |
| `informational` | 是什么、怎么做、如何、为什么、介绍、原理 | 默认 |
优先级:`decisional > evaluative > informational`
#### Layer 规则
| 优先级 | 层级 | 触发条件 |
| --- | --- | --- |
| 1 | `L5` | 出现两个及以上已知品牌名,或出现对比类词 |
| 2 | `L1` | 只出现当前品牌名 |
| 3 | `L4` | 出现地域、人群、价格、场景修饰 |
| 4 | `L3` | 出现痛点、问题、踩坑、解决等表达 |
| 5 | `L2` | 兜底 |
品牌上下文由 service 根据 `brandID``brands``competitors` 读取,前端不传品牌名数组,避免 body/path 不一致造成边界问题。
### 9.5 AI 扩展提示词
```
你是一个 GEOGenerative Engine Optimization)资深策略师。
请围绕当前品牌和输入主题,生成中国用户在 AI 搜索里真实会问的问题。
【输入】
- 当前品牌: {{brand_name}}
- 竞品列表: {{competitor_names}}
- 主题: {{seed_topic}}
【输出 JSON Schema】
{
"candidates": [
{
"text": "string, 完整中文问句, ≤40 字",
"intent": "informational|evaluative|decisional",
"layer": "L1|L2|L3|L4|L5"
}
]
}
【约束】
1. candidates 长度 ≤ 20
2. 至少覆盖 3 类 intent
3. 问题必须是自然口语问句
4. 至少 4 条包含地域、价位、人群或场景修饰
5. 严格输出 JSON,不要 Markdown,不要解释
```
LLM 返回后仍由本地规则复核标签;规则结果优先。
### 9.6 API Endpoints
挂在既有 `tenantProtected.Group("/brands")` 下:
| Method | Path | 说明 |
| --- | --- | --- |
| `POST` | `/brands/:id/questions/combination-preview` | 拓词工具候选预览 |
| `POST` | `/brands/:id/questions/ai-distill` | AI 扩展候选预览 |
| `POST` | `/brands/:id/questions/materialize` | 保存候选到当前品牌问题集 |
| `POST` | `/brands/:id/questions/classify-metadata` | 批量补充 `layer` / `intent` |
所有接口继承现有认证、租户、工作区和订阅校验中间件。
### 9.7 配额与 AI 点
| 操作 | 问题配额 | AI 点 | 失败处理 |
| --- | --- | --- | --- |
| 单条新增 | 保存时检查 | 0 | 返回表单错误 |
| 拓词工具预览 | 不消耗 | 0 | 参数错误直接返回 |
| AI 扩展预览(缓存命中) | 不消耗 | 0 | - |
| AI 扩展预览(缓存未命中) | 不消耗 | 1 点 | 失败 refund |
| 保存问题 | Phase 1 + Phase 2 检查 | 0 | 见物化算法 |
AI 扩展缓存:
- Key`question_distill:{tenant_id}:{brand_id}:{sha1(seed_topic)}`
- Value:只存 `[]QuestionCandidate`
- TTL300 秒
- 命中时跳过 AI 点预留,返回 `cache_hit=true`
**问题上限配置**
新问题驱动页面使用**账号级问题上限**,同一账号下所有品牌共享 `question_limit`,不再使用旧的 `max_keywords * max_questions_per_keyword` 模型。配置放在 `server/configs/config.yaml``deploy/config.yaml``brand_library` 下:
```yaml
brand_library:
free_brand_limit: 1
paid_brand_limit: 2
# 问题驱动页面使用:当前账号最多保存多少个问题,所有品牌共用。
# key 必须匹配 membership.plans[].code;新增套餐只加一行配置,不改代码。
question_limits_by_plan:
default: 25
free: 5
plus: 25
pro: 50
# Deprecated:旧模型参数。问题驱动页面不再读取这两个字段。
# 若旧关键词 CRUD 仍保留,可在过渡期继续使用;下线旧 CRUD 后删除。
max_keywords: 5
max_questions_per_keyword: 5
```
解析规则:
- 读取当前租户订阅套餐的 `plan_code`
- 优先取 `question_limits_by_plan[plan_code]`
- 未命中或配置值小于等于 0 时,取 `question_limits_by_plan.default`
- `default` 缺失或小于等于 0 时兜底为 25。
- 初始配置建议:`free=5``plus=25``pro=50`,但这些值必须可通过 YAML 修改。
- `BrandLibrarySummaryResponse` 新增 `max_questions``used_questions``remaining_questions`,前端配额提示和保存前置判断都读账号级字段。
- `max_questions_per_brand` 仅作为过渡期兼容别名返回,不再表达产品语义。
### 9.8 物化两阶段算法
#### Phase 1:事务外预校验
```
input:
MaterializeQuestionsRequest{Source, Questions}
brandID
load:
current_q_count := SELECT COUNT(*) FROM brand_questions
WHERE tenant_id = $tenantID AND deleted_at IS NULL
existing_norm_set := SELECT lower(btrim(question_text))
FROM brand_questions
WHERE brand_id = $brandID AND deleted_at IS NULL
process:
maxQuestions := limits.QuestionLimitForPlan(currentPlanCode) // from question_limits_by_plan
remaining := maxQuestions - current_q_count
if remaining < 0: remaining = 0
in_batch_norm_set := {}
accepted := []
skipped := []
for q in req.Questions:
text := trim(q.Text)
norm := lower(text)
if text == "" or len(norm) < 4:
skipped += {text, "too_short"}
continue
if norm in existing_norm_set:
skipped += {text, "duplicate"}
continue
if norm in in_batch_norm_set:
skipped += {text, "duplicate_in_batch"}
continue
if remaining <= 0:
skipped += {text, "quota_exceeded"}
continue
q.Text = text
q.Layer = coalesceValid(q.Layer, InferLayer(text, knownBrands))
q.Intent = coalesceValid(q.Intent, ClassifyIntent(text))
q.Source = coalesceValid(q.Source, req.Source, "manual")
accepted += q
in_batch_norm_set += norm
remaining--
if len(accepted) == 0:
return 400 no_valid_questions + skipped
```
#### Phase 2:事务内写入
```go
tx, _ := pool.Begin(ctx)
defer tx.Rollback(ctx)
// 1. 序列化同一品牌的问题写入
_, err := tx.Exec(ctx, `
SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))
`, fmt.Sprintf("brand:%d", brandID), "questions")
if err != nil { return err }
// 2. 确保内部默认问题桶存在
bucketID, err := ensureDefaultQuestionBucket(ctx, tx, tenantID, brandID)
if err != nil { return err }
_, _ = tx.Exec(ctx, `
SELECT 1 FROM brand_keywords WHERE id = $1 FOR UPDATE
`, bucketID)
// 3. 锁内重算问题配额
var currentCount int
_ = tx.QueryRow(ctx, `
SELECT COUNT(*)::INT
FROM brand_questions
WHERE tenant_id = $1 AND deleted_at IS NULL
`, tenantID).Scan(&currentCount)
maxQuestions := limits.QuestionLimitForPlan(currentPlanCode) // from question_limits_by_plan
remaining := maxQuestions - currentCount
if remaining < 0 { remaining = 0 }
if remaining < len(accepted) {
overflow := accepted[remaining:]
accepted = accepted[:remaining]
for _, q := range overflow {
skipped = append(skipped, SkipReason{Text: q.Text, Reason: "quota_exceeded_concurrent"})
}
}
if len(accepted) == 0 {
return ErrBadRequest("no_valid_questions", skipped)
}
// 4. 批量写入,品牌级唯一索引兜底防并发重复
payload, _ := json.Marshal(accepted)
rows, err := tx.Query(ctx, `
INSERT INTO brand_questions (
tenant_id, brand_id, keyword_id, question_text,
layer, intent, source, status
)
SELECT $1, $2, $3, q.text, q.layer, q.intent, q.source, 'active'
FROM jsonb_to_recordset($4::jsonb)
AS q(text TEXT, layer TEXT, intent TEXT, source TEXT)
ON CONFLICT (brand_id, lower(btrim(question_text)))
WHERE deleted_at IS NULL
DO NOTHING
RETURNING question_text
`, tenantID, brandID, bucketID, payload)
if err != nil { return err }
inserted := map[string]struct{}{}
for rows.Next() {
var text string
_ = rows.Scan(&text)
inserted[normalizeQuestion(text)] = struct{}{}
}
for _, q := range accepted {
if _, ok := inserted[normalizeQuestion(q.Text)]; !ok {
skipped = append(skipped, SkipReason{Text: q.Text, Reason: "duplicate_concurrent"})
}
}
if err := tx.Commit(ctx); err != nil {
return ErrInternal("materialize_failed")
}
return MaterializeQuestionsResult{
CreatedQuestions: len(inserted),
SkippedQuestions: skipped,
}
```
### 9.9 内部默认问题桶
`brand_questions.keyword_id` 当前仍是必填字段,因此每个品牌需要一条内部默认问题桶记录。
| 场景 | 行为 |
| --- | --- |
| 品牌已有 `source=auto` 的 active 桶 | 复用并锁行 |
| 品牌没有默认桶 | 在同一事务内创建 |
| 品牌改名 | V1 不同步桶名;桶不展示给用户 |
| 老数据缺桶 | 首次保存问题时 lazy-create |
| 系统桶创建失败 | 当前事务失败,不写入问题 |
创建规则:
```sql
-- 先查系统桶,避免误复用历史上用户创建的同名记录。
SELECT id
FROM brand_keywords
WHERE tenant_id = $1
AND brand_id = $2
AND source = 'auto'
AND deleted_at IS NULL
ORDER BY id ASC
LIMIT 1
FOR UPDATE;
-- 未命中时创建。name 使用稳定内部名,避免与用户历史数据重名。
INSERT INTO brand_keywords (
tenant_id, brand_id, name, status,
layer, seed_word, source
) VALUES (
$1, $2, '__default_questions__', 'active',
'L1', $brand_name, 'auto'
)
ON CONFLICT (brand_id, name) WHERE deleted_at IS NULL
DO UPDATE
SET source = 'auto',
layer = 'L1',
seed_word = COALESCE(brand_keywords.seed_word, EXCLUDED.seed_word),
updated_at = brand_keywords.updated_at
RETURNING id;
```
说明:
- `name='__default_questions__'` 是稳定内部名,只用于满足旧唯一约束和外键。
- 查询优先按 `source=auto` 找系统桶;只有缺失时才创建。
- `seed_word` 保存创建时的 `brand.name` 快照,用于排查,不参与用户语义。
- 该记录不进入用户可见配额提示,不在图二页面展示。
- `ListKeywords` / 旧列表接口在图二页面的数据源里必须过滤 `source=auto`,避免内部桶渗出到 UI。
- 若未来移除内部层,可先把 `brand_questions.keyword_id` 改为 nullable,再迁移掉该依赖。
### 9.10 创建品牌时的默认桶
`BrandService.Create` 成功插入 brand 后,在同一事务内创建默认问题桶。
不变量:
| 项 | 决策 |
| --- | --- |
| 失败回退 | 默认桶创建异常时回滚 brand 创建 |
| 审计 | `brand.create` + `brand.create_default_question_bucket`after-commit best-effort |
| 缓存 | 复用现有 `invalidateBrandCaches` |
| 返回值 | `BrandResponse` 不暴露内部桶 |
| lazy-create | 保留,用于修复历史或测试数据缺桶场景 |
---
## 10. Frontend Architecture
### 10.1 文件布局
```
apps/admin-web/src/
views/
BrandsView.vue ← 改造为图二的问题驱动品牌管理页
BrandQuestionCreateView.vue ← 新建问题向导页
components/
brand-management/
BrandCardList.vue ← 可后续拆分的品牌卡片列表
BrandGuidePanel.vue ← 可后续拆分的品牌管理说明折叠条
BrandQuestionTable.vue ← 可后续拆分的问题集全宽表格
QuestionExpansionToolPanel.vue ← 可后续拆分的拓词工具面板
QuestionCandidatePreview.vue ← 可后续拆分的候选预览
QuestionSourceBadge.vue ← 可选来源徽标;V1 主表不展示
lib/api.ts ← 加 brandQuestionsApi
i18n/messages/zh-CN.ts ← 加 brandManagement.questions.*
i18n/messages/en-US.ts ← 同上
packages/shared-types/src/index.ts ← 加 QuestionCandidate / MaterializeQuestions 类型
```
### 10.2 状态管理
V1 不引入 Pinia store
- 当前品牌选择存在 `BrandsView.vue` 本地状态。
- 品牌列表、问题列表、竞品列表使用既有查询能力和缓存策略。
- 候选预览只在新建问题向导页内存在,离开即丢弃。
- 可选:向导页未保存草稿写入 localStorageTTL 24h。
### 10.3 关键交互流
```dot
digraph flow {
rankdir=LR;
"进入品牌管理" -> "加载品牌卡片";
"加载品牌卡片" -> "选择品牌";
"选择品牌" -> "问题集 Tab";
"问题集 Tab" -> "点击 新增单条问题";
"问题集 Tab" -> "点击 批量扩展问题";
"点击 新增单条问题" -> "当前页轻量表单";
"点击 批量扩展问题" -> "新建问题向导页";
"新建问题向导页" -> "拓词工具";
"新建问题向导页" -> "AI 扩展";
"新建问题向导页" -> "手动批量";
"拓词工具" -> "POST /brands/:id/questions/combination-preview";
"AI 扩展" -> "POST /brands/:id/questions/ai-distill";
"手动批量" -> "POST /brands/:id/questions/classify-metadata";
"当前页轻量表单" -> "POST /brands/:id/questions/materialize";
"POST /brands/:id/questions/combination-preview" -> "候选预览";
"POST /brands/:id/questions/ai-distill" -> "候选预览";
"POST /brands/:id/questions/classify-metadata" -> "候选预览";
"候选预览" -> "勾选/编辑/删除";
"勾选/编辑/删除" -> "POST /brands/:id/questions/materialize";
"POST /brands/:id/questions/materialize" -> "刷新问题表";
}
```
---
## 11. Error Handling
| 失败场景 | 后端响应 | 前端处理 |
| --- | --- | --- |
| 拓词工具核心为空 | `400 invalid_params: core_required` | 字段内联错误 |
| 拓词工具行业为空 | `400 invalid_params: industry_required` | 字段内联错误 |
| AI provider 未配置 | `503 llm_unavailable` | Toast + 切换手动批量 |
| AI 超时 | `503 llm_timeout`AI 点 refund | Toast + “AI 点已退还” |
| AI 输出无法解析 | `502 llm_invalid_output`AI 点 refund | Toast + 重试 |
| 枚举非法 | `400 invalid_enum` | 向导页内联错误 |
| 没有可保存问题 | `400 no_valid_questions` + skipped | 保留候选预览,黄色提示 |
| 部分问题被跳过 | `200 OK` + skipped | 顶部提示已保存 N 条、跳过 M 条 |
| 并发重复 | `200 OK` + `duplicate_concurrent` | 合并进跳过明细 |
| 并发配额不足 | `200 OK` + `quota_exceeded_concurrent` | 合并进跳过明细 |
| 旧问题 CRUD 撞唯一索引 | `409 question_exists` | 表单提示“该品牌下已存在相同问题” |
| 数据库异常 | `500 materialize_failed` | Toast,提示稍后重试 |
---
## 12. Testing Strategy
| 层 | 类型 | 覆盖 |
| --- | --- | --- |
| `question_metadata_classify.go` | Go 表驱动单测 | intent/layer 规则、优先级、兜底 |
| `question_expansion_service.go` 组合生成 | Go 单测 | 空输入、极大组合、截断、重复、metadata 默认值 |
| `question_expansion_service.go` AI 扩展 | Go 单测 | 成功、超时、JSON 解析失败、缓存命中、AI 点退款 |
| `MaterializeQuestions` | Go 集成测试 | 品牌级重复、批内重复、配额截断、并发写入、无有效问题、事务回滚 |
| `BrandLibraryConfig` | Go 单测 | `question_limits_by_plan` 解析、`free=5``plus=25``pro=50`、未知套餐 fallback 到 `default` |
| `ensureDefaultQuestionBucket` | Go 集成测试 | 已存在复用、缺失创建、创建失败回滚 |
| `BrandService.Create` | Go 集成测试 | 创建品牌时生成默认问题桶、审计 best-effort、缓存失效 |
| handler/router | Go 测试 | 4 个新路由注册、枚举校验、tenant middleware |
| 旧问题 CRUD | Go 集成测试 | 重复问题返回 `409 question_exists` |
| 前端 | 手动 QA + dev-server smoke | 图二布局、Tab、向导页、候选预览、保存刷新 |
后端命令:
```bash
cd server
gofmt -l ./internal/...
go vet ./internal/...
go test ./internal/tenant/app/...
TEST_DATABASE_URL=postgres://... go test -tags=integration ./internal/...
go build ./cmd/tenant-api
```
前端命令:
```bash
pnpm --filter admin-web typecheck
pnpm --filter admin-web build
pnpm --filter admin-web lint
```
---
## 13. Rollout Plan
当前项目未上线生产,V1 可全量发布:
1. 跑 migration:新增元数据列、品牌级问题唯一索引。
2. 上线 server:新增问题生成路由,旧问题 CRUD 增加唯一冲突映射。
3. 上线 admin-web:改造 `BrandsView.vue` 为图二布局。
4. 打开品牌管理入口。
未来若已经有生产数据,再做类似唯一索引变更时必须:
- 先预扫描重复数据。
- 线上使用 `CREATE UNIQUE INDEX CONCURRENTLY`
- 分阶段发布服务与索引。
---
## 14. Compatibility
| 方面 | 决策 |
| --- | --- |
| 数据库 | 只加列、加索引,不删表不删字段 |
| 旧 `brand_questions.keyword_id` | 继续写内部默认问题桶 id |
| 旧问题 CRUD | payload 基本不变,但重复问题返回 `409 question_exists` |
| 旧品牌创建 | 增加内部默认问题桶副作用,响应不变 |
| 旧列表接口 | 若继续服务旧页面,可保留原始响应;图二品牌管理页必须调用过滤后的数据聚合或在前端过滤 `source=auto` |
| 旧配置字段 | `max_keywords` / `max_questions_per_keyword` 不再作为问题驱动页面额度来源;新页面只读 `question_limits_by_plan` 解析出的账号级 `max_questions` |
| 缓存 | 复用现有 brand cache key,写入后失效 |
| 下游监测 | 继续从 `brand_questions` 读取问题 |
| 前端路由 | 新增 `brands/:brandId/questions/create` page 向导;单条新增保留在品牌管理页 |
---
## 15. Effort Estimation
| 任务 | 人天 |
| --- | --- |
| 后端 migration + 唯一索引 | 0.3 |
| 后端问题组合生成 + 元数据分类 | 0.8 |
| 后端 AI 扩展 + JSON Schema + AI 点退款 + 缓存 | 0.8 |
| 后端物化两阶段算法 + 默认问题桶 | 0.9 |
| 后端 BrandService.Create 扩展 + 旧 CRUD 冲突映射 | 0.4 |
| 后端 handler/router/bootstrap | 0.4 |
| 前端 BrandsView 图二布局 | 0.8 |
| 前端新建问题向导页 + 四种方式 | 1.0 |
| 前端候选预览 + 保存刷新 + skipped 提示 | 0.7 |
| shared-types + api.ts + i18n | 0.4 |
| 联调 + QA + bugfix | 0.9 |
| **合计** | **≈ 7.4 人天** |
---
## 16. Open Questions
| # | 问题 | 当前决策 |
| --- | --- | --- |
| Q1 | 主表是否展示 intent/layer 标签 | V1 不展示,避免破坏图二的简洁表格 |
| Q2 | AI 扩展扣点 | 1 点/次,5 分钟缓存命中不扣点 |
| Q3 | 候选草稿是否服务端保存 | V1 不保存,向导页临时态 + 可选 localStorage |
| Q4 | 默认问题桶是否计入用户可见配额 | 不计入用户可见文案,只做系统兼容 |
| Q5 | 品牌改名是否同步默认问题桶 | V1 不同步 |
| Q6 | 组合生成 500 上限 | 保留,超过时截断并提示 |
| Q7 | 后续是否彻底移除内部桶 | 单独立迁移设计,不放入 V1 |
| Q8 | 配额是否按套餐固定字段配置 | 不用固定字段;使用 `question_limits_by_plan` map,新增套餐不改代码 |
---
## 17. Appendix A — 完整流程图
```
┌──────────────────────┐
│ 品牌管理 │
└──────────┬───────────┘
┌──────────────────────┐
│ 品牌管理说明 │
└──────────┬───────────┘
┌──────────────────────┐
│ 品牌卡片列表 │
└──────────┬───────────┘
┌──────────────────────┐
│ 问题集 / 竞品库 Tab │
└──────────┬───────────┘
┌──────────────────────┐
│ 问题集全宽表格 │
└──────────┬───────────┘
┌────────────────────────────────────┐
│ 新增单条问题 / 批量扩展问题 │
└──────────┬─────────────────────────┘
┌────────────────────────────────────────┐
│ 新建问题向导页 │
│ 拓词工具 / AI 扩展 / 手动批量 │
└──────────┬─────────────────────────────┘
┌──────────────────────┐
│ 候选预览 │
└──────────┬───────────┘
┌──────────────────────┐
│ 保存问题 │
└──────────┬───────────┘
┌──────────────────────┐
│ 刷新问题集表格 │
└──────────────────────┘
```
---
## 18. Appendix B — i18n keys
```yaml
brandManagement:
title: 品牌管理
createBrand: 创建新品牌
guide:
title: 品牌管理说明
body: 管理品牌、问题集和竞品库,用于后续 AI 平台监测与 GEO 分析。
tabs:
questions: 问题集
competitors: 竞品库
questions:
create: 新建问题
table:
question: 问题集
createdAt: 创建时间
actions: 操作
modal:
title: 新建问题
currentBrand: 当前品牌
methods:
combination: 拓词工具
aiDistill: AI 扩展
manualBatch: 手动批量
preview: 候选预览
summary: 共 {total} 条候选 · 已选择 {selected} 条 · 过短 {tooShort} 条 · 重复 {duplicate} 条
save: 保存问题 ({count})
combination:
title: 拓词工具生成
region: 1.地域词
prefix: 2.前缀词
core: 3.核心词(必填)
industry: 4.行业词(必填)
suffix: 5.后缀词
coreRequired: 核心不能为空
industryRequired: 行业不能为空
generate: 生成候选 ({count})
errors:
questionExists: 该品牌下已存在相同问题
noValidQuestions: 没有可保存的问题,请调整候选后重试
aiUnavailable: 大模型暂不可用
aiRefunded: AI 点已退还
partialSaved: 已保存 {created} 条,跳过 {skipped} 条
```
---
## 19. Appendix C — Acceptance Criteria
### 19.1 文档自查
- [x] 页面心智改为品牌 → 问题集 → 新建问题。
- [x] 图二关键结构已覆盖:品牌管理标题、品牌卡片、`问题集 / 竞品库` Tab、全宽表格、右侧 `批量扩展问题``新增单条问题`
- [x] 用户可见流程不要求选择内部中间层实体。
- [x] 后端仍兼容 `brand_questions.keyword_id`
- [x] 品牌级问题唯一索引已改为 `(brand_id, lower(btrim(question_text)))`
- [x] 物化返回只包含 created/skipped 问题结果。
- [x] 错误表不再包含旧页面的重名复用或中间层配额错误。
### 19.2 手动 QA
| # | 操作 | 期望 |
| --- | --- | --- |
| 1 | 进入品牌管理 | 看到图二风格布局 |
| 2 | 选中品牌卡 | 下方默认打开问题集 Tab |
| 3 | 查看问题集表格 | 列为问题集、创建时间、操作 |
| 4 | 点击 `新增单条问题` | 当前页打开单条问题表单 |
| 5 | 单条新增并保存 | 表格刷新,新问题出现 |
| 5.1 | 点击 `批量扩展问题` | 进入新建问题 page 向导 |
| 6 | 拓词工具缺核心 | 字段内联错误 |
| 7 | 拓词工具生成 500+ | 候选截断并提示 |
| 7.1 | 拓词工具每列换行输入 | 当前列词数与预计候选数实时更新 |
| 8 | 保存重复问题 | 跳过明细显示 duplicate |
| 9 | AI 扩展成功 | 候选出现,未保存前表格不变 |
| 10 | AI 扩展失败 | Toast 提示,AI 点退还 |
| 11 | 配额不足 | 部分保存或无有效问题提示 |
| 12 | 编辑已入库问题文本为重复文本 | 返回 `409 question_exists` 并内联提示 |
| 13 | 新建品牌 | 响应不暴露内部默认桶,但后端存在兼容记录 |
| 14 | 分别切换 free / plus / pro 套餐配置 | 问题保存上限分别为 5 / 25 / 50,修改 YAML 后 reload 生效 |
---
## Changelog
### v9 — 2026-05-12
| Severity | Finding | 修复 |
| --- | --- | --- |
| P0 | 页面仍按旧中间层组织,和图二的问题集表格不一致 | 全文改为品牌管理页的问题驱动信息架构 |
| P0 | 创建入口仍要求用户先填写中间层名称 | 改为 `新增单条问题` / `批量扩展问题`API 不再接收用户选择的 `keyword_id` / `keyword_name` |
| P1 | 问题唯一性仍绑定旧中间层,跨同品牌会重复 | 唯一索引改为品牌级 `(brand_id, lower(btrim(question_text)))` |
| P1 | 物化响应暴露内部实现字段 | 返回值改为 `created_questions` + `skipped_questions` |
| P1 | 新建品牌自动创建旧可见记录的设计会污染 UI | 改为内部默认问题桶,响应和页面均不展示 |
| P2 | 前端架构新增独立页面,与用户要求图二不符 | 改造 `BrandsView.vue`,不新增独立入口 |
**Next step:** 基于本文输出实施计划到 `docs/superpowers/plans/2026-05-12-question-expansion-plan.md`