1159 lines
52 KiB
Markdown
1159 lines
52 KiB
Markdown
|
|
# 问题集扩展(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`。
|