# 问题集扩展(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 扩展提示词 ``` 你是一个 GEO(Generative 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` - TTL:300 秒 - 命中时跳过 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(¤tCount) 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` 本地状态。 - 品牌列表、问题列表、竞品列表使用既有查询能力和缓存策略。 - 候选预览只在新建问题向导页内存在,离开即丢弃。 - 可选:向导页未保存草稿写入 localStorage,TTL 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`。