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

52 KiB
Raw Blame 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 layerintentsource 是系统元数据,默认自动补齐;主表 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=5plus=25pro=50
R22 如果本次没有任何可写入问题,返回 400 no_valid_questions,不得写入空数据
R23 CreateQuestion / UpdateQuestion 命中品牌级唯一索引时必须返回 409 question_exists
R24 i18n 必须同步维护 zh-CN.tsen-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 和历史去重脚本;若未来在线上数据上做同类变更,必须改成预扫描 + 并发建索引方案。

-- 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 记录:

(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

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 关键类型

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 根据 brandIDbrandscompetitors 读取,前端不传品牌名数组,避免 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 扩展缓存:

  • Keyquestion_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.yamldeploy/config.yamlbrand_library 下:

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=5plus=25pro=50,但这些值必须可通过 YAML 修改。
  • BrandLibrarySummaryResponse 新增 max_questionsused_questionsremaining_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:事务内写入

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
系统桶创建失败 当前事务失败,不写入问题

创建规则:

-- 先查系统桶,避免误复用历史上用户创建的同名记录。
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_bucketafter-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 关键交互流

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_timeoutAI 点 refund Toast + “AI 点已退还”
AI 输出无法解析 502 llm_invalid_outputAI 点 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=5plus=25pro=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、向导页、候选预览、保存刷新

后端命令:

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

前端命令:

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

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 文档自查

  • 页面心智改为品牌 → 问题集 → 新建问题。
  • 图二关键结构已覆盖:品牌管理标题、品牌卡片、问题集 / 竞品库 Tab、全宽表格、右侧 批量扩展问题新增单条问题
  • 用户可见流程不要求选择内部中间层实体。
  • 后端仍兼容 brand_questions.keyword_id
  • 品牌级问题唯一索引已改为 (brand_id, lower(btrim(question_text)))
  • 物化返回只包含 created/skipped 问题结果。
  • 错误表不再包含旧页面的重名复用或中间层配额错误。

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