Files

1159 lines
52 KiB
Markdown
Raw Permalink Normal View History

# 问题集扩展(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`