Files
geo/docs/ai-brand-monitoring-tech-design-v5.md
T

2070 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI 品牌曝光监测系统技术方案 V5
## 1. 文档信息
| 项目 | 内容 |
| -------- | --------------------------------------------------------------------- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V5(插件采样首期交付 / API 精确采集预留) |
| 文档版本 | V5.9 |
| 文档状态 | 待评审 |
| 创建日期 | 2026-04-09 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v4.md` |
| 适用范围 | 数据追踪模块全部页面 |
| 关联文档 | `docs/geo-platform-prd-v1.md`(PRD 8.4 数据追踪) |
| 关联文档 | `docs/question-driven-monitoring-design-v1.md`(问题驱动模型) |
| 核心约束 | 单租户仅 1 个监测插件实例,插件所在设备不保证全天在线 |
## 2. V5 核心结论
V5 在架构上定义了两种采集模式,但**首期只实现插件采集模式**:
1. **插件采集模式(首期实现)**:免费,依赖本地插件与设备在线率,结果用于趋势参考。
2. **API 采集模式(`[V6]` 预留)**:付费,走服务端统一 provider 调用,结果作为精确口径。
3. **数据模型和协议层预留双模式扩展点**,首期所有租户固定 `collection_mode = plugin`
4. **页面百分比按实际采样成功样本计算**,展示覆盖率和置信度。
5. **引入 RabbitMQ 和独立 Monitoring PostgreSQL 隔离写链路**,插件结果通过统一 ingest 队列处理。
6. **数据详情页按“每日单快照”展示**:用户可手动催发,同步存在自动调度,但同一自然日只保留一份展示快照。
### 2.1 相比 V4 的根本变化
| 维度 | V4 | V5(首期) |
| ------------ | -------------------------- | ----------------------------------------------------- |
| 产品定位 | 全量品牌曝光监测 | 插件采样趋势参考(数据模型预留 API 精确模式) |
| 采集目标 | 尽量覆盖全部问题 × 全平台 | 插件模式尽量覆盖,受预算和在线时长限制 |
| 插件节点假设 | 大量用户端节点可互补 | 单租户单主插件实例 |
| 数据时效 | 准实时 + T+1 | T+1 为主,允许延迟 |
| 数据精度承诺 | 接近真实全量 | 趋势参考,精度受覆盖率影响 |
| 任务处理 | PG + Redis 锁 + RabbitMQ | Monitoring PG 任务表 + 插件租约 + RabbitMQ 结果异步化 |
| 存储边界 | 单业务库承载配置与监测结果 | 主业务 PG 存配置,Monitoring PG 存监测数据 |
| 页面表达 | 指标卡直接展示百分比 | 指标卡 + 采样覆盖信息 + 置信度提示 |
### 2.2 首期实施范围
V5.9 首期**只实现插件采集模式**。API 采集模式在数据模型和协议层预留扩展点,但不进入首期实施:
1. 不实现 API provider adapter 和 API worker
2. 不实现 API 模式三级限流
3. 不实现 `collection_mode` 切换状态机和切换 UI
4. 不对外暴露模式选择入口,所有租户固定 `collection_mode = plugin`
5. 数据模型中的 `collector_type``collection_mode` 字段首期保留并写入 `plugin`,为后续扩展做好铺垫
文档中标注 **`[V6]`** 的段落为预留设计,首期跳过实现。
### 2.3 非目标
V5 明确不做以下承诺:
1. 不承诺所有问题每天都有结果。
2. 不承诺所有 AI 平台每天都有样本。
3. 不承诺分钟级实时更新。
4. 不承诺百分比可用于财务、法务、投放结算或精确 KPI 审计。
---
## 3. 产品口径与页面表达
### 3.1 页面定位
数据追踪页展示的是:
- 在当前采集模式下的 AI 曝光结果
- 在当前采集模式下的品牌表现
- 在当前采集模式下的文章引用趋势
不是:
- 品牌在全网 AI 场景中的绝对真实份额
- 所有问题、所有平台、所有时间点的完整监控
### 3.2 页面必须新增的展示项
为避免误导,数据追踪页和 Dashboard composite 接口必须返回并展示:
1. `collection_mode``plugin | api`
2. `desired_sample_count`:按全部启用问题推导出的目标采样数
3. `planned_sample_count`:受预算截断后实际下发的计划采样数
4. `actual_sample_count`:实际采样成功数
5. `coverage_rate`:覆盖率 = `actual / planned`
6. `last_sampled_at`:最近成功采样时间
7. `confidence_level``high / medium / low`
8. `last_trigger_source`:最近一次成功更新来自 `manual | automatic`
推荐前端呈现方式:
- 顶部右侧信息条:首期展示 `插件采样``API 精确采集``[V6]` 预留
- 插件模式副文案:`目标 60,计划 30,已采样 18,覆盖率 60%,更新于 04-08 09:20 · 自动`
- `[V6]` API 模式副文案:`已按 API 精确模式执行,更新于 04-08 09:20`
- 仅插件模式下展示 `样本不足,仅供参考`
- 品牌时序区首期使用**离散采样时序视图**,不使用连续折线图
- 数据详情页提供 `立即采集` 入口,但文案必须明确:**同一自然日只保留最新一份有效快照**
### 3.3 低覆盖场景的展示规则
| 覆盖率 | 置信度 | 页面表现 |
| --------- | ------ | -------------------------------------------- |
| >= 70% | high | 正常展示百分比和趋势 |
| 40% - 69% | medium | 展示百分比,并提示“样本偏少” |
| < 40% | low | 保留原始计数,百分比弱化展示或标记“仅供参考” |
`collection_mode = plugin``actual_sample_count < 5` 时:
1. 不展示“精确到整数”的百分比变化解释。
2. 不使用连续趋势线,改为“快照脉冲条 + 当期聚合指标”。
3. 引用排行允许为空,不用补零。
4. 当日尚无有效快照时,页面显示“等待下一次有效快照”,而不是大面积重复展示“未采样”字样。
### 3.4 数据详情页首屏设计
首期数据详情页采用**「结果驱动 + 按需探索」**信息层次,主视觉聚焦品牌最新表现,而非采样过程:
1. **第一层:顶部筛选 + 操作区**
- 筛选器:品牌、关键词
- 信息条:`插件采样 · 最近更新: 04-08 09:20 · 自动`
- 操作区:`立即采集` 按钮
- 模型筛选器仅影响指标卡数值,不影响平台对比矩阵
2. **第二层左:最新快照概览(约 60% 宽度)**
- 4 个指标卡(2x2 网格):提及率、首位提及率、首选推荐率、正面提及率
- 每个卡片内容:指标名 + 大号百分比数字 + 趋势箭头(对比上一份有效快照)+ mini sparkline 离散点(最近 N 个有效快照点位,不连线)
- 当窗口内有效快照 < 2 时,不展示趋势箭头,只展示当前值
- 当窗口内无有效快照时,指标卡显示 `--`mini sparkline 区域留白
3. **第二层右:平台对比矩阵(约 40% 宽度)**
- 紧凑表格,列头:`平台 | 提及率 | 首位提及 | 状态`
- 始终展示全部已接入平台,不受模型筛选器影响
- 状态值:`已采样 / 等待采样 / 未登录 / 不可用`
- 已采样平台展示指标数值;未采样或未登录平台对应指标显示 `--`
- 点击某平台行可展开该平台的详细采样记录(tooltip 或抽屉)
4. **第三层左:采样覆盖条(约 60% 宽度)**
- 水平进度条:展示覆盖率百分比
- 文案:`目标 30 · 已采样 21 · 覆盖率 70%`
- 日指示点行:最近 N 天每天一个圆点,实心点 = 有效快照日,空心点 = 无数据日(类 GitHub 贡献图极简版)
- 日指示点悬浮时 tooltip 展示该日 `sample_status / trigger_source / actual_sample_count`
5. **第三层右:高频问题(约 40% 宽度,可搜索)**
- 顶部搜索框,搜索范围为当前所选关键词下的全部问题
- 问题列表:按采样成功次数排序,每行展示问题文案 + 采样次数 + 提及率
- 每个问题行右侧展示展开图标,点击后弹出**问题详情面板**(见 3.5 节)
- 列表底部提供链接跳转到品牌词库问题管理,方便用户提报新问题
6. **第四层:底部明细区**
- 引用排行榜(保留原设计):饼图 + 模型平台引用表格(模型平台 | 引用文章数 | 引用率)
- 被引用文章列表
设计原则:
1. 主视觉传达品牌在各 AI 平台的最新表现,而非采样过程是否完整。
2. 采样覆盖信息降维为第三层的辅助进度条,不喧宾夺主。
3. 未采样日不以 `0` 值或错误提示呈现,而是日指示点留空 + tooltip 按需解释。
4. `sample_status` 只作为日指示点 tooltip 和 API 字段,不作为逐日主文案。
5. 平台对比矩阵是品牌监测最有价值的洞察维度,独立成区常驻展示。
6. 高频问题支持搜索和问题提报闭环,实现问题驱动的监控体验。
### 3.5 高频问题展开详情面板设计
![](refer/问题驱动的问题数据.png)
每个高频问题均可点击展开,展示该问题在各 AI 平台的完整回答及引用信息。详情面板分为以下四个区域:
#### 3.5.1 答案内容区
- **位置**:面板左上主区域(约 60% 宽度)
- **内容**:展示当前所选 AI 平台对该问题的完整回答原文(支持 Markdown 渲染)
- **平台切换器**:面板右上角展示平台切换按钮组(如 `DeepSeek` / `通义千问` / `豆包`),支持左右箭头翻页或 Tab 点击
- 切换时答案内容区实时更新为对应平台的回答原文
- 若某平台当日未采样或登录态失效,对应 Tab 显示为灰色,回答区展示 `暂无数据`
- 默认展示最近采样成功的平台回答
- **回答元信息**:回答区底部展示:`平台名称 · 采样时间 · 模型版本(如有)`
- **品牌高亮**:回答中涉及当前监测品牌的词汇自动高亮显示
#### 3.5.2 引用来源区
- **位置**:面板右侧列表区(约 40% 宽度,与答案内容区并排)
- **内容**:展示当前所选平台在该问题回答中引用的外部链接列表
- **每条引用**展示:
- 来源站点图标(favicon)+ 来源站点名称(`registrable_domain` 映射后的 `site_name`
- 引用 URL 标题(截断展示,完整 title 可 hover 查看)
- 引用 URL 链接(可点击跳转)
- 是否为本品牌内部文章(标记 `内容引用` 徽标)
- **空状态**:若当前平台回答无可解析引用,展示 `该平台无引用来源`
- 引用来源区随平台切换联动更新
#### 3.5.3 引用分析区
- **位置**:面板下半部分左侧(答案内容区下方)
- **内容**:以表格形式汇总该问题在各平台的引用来源分布
- **表格列**`来源平台 | 引用次数 | 引用站点数 | 是否含品牌内容`
- **表格行**:每行对应一个已有引用记录的 AI 平台(按引用次数降序)
- **说明**:该分析基于当前时间窗口内所有采样成功记录中该问题的全部引用事实聚合,而不仅限于当日单次回答
- 若当日仅有一次采样,引用分析等同于单次引用列表;若多次采样(手动 + 自动),则聚合所有采样的引用事实
#### 3.5.4 内容引用区
- **位置**:面板下半部分右侧(引用来源区下方)
- **内容**:展示在该问题回答中被 AI 引用的品牌内部文章列表
- **每条内容引用**展示:
- 文章标题(可点击进入文章管理页面)
- 发布平台名称
- 被引用次数(当前时间窗口内)
- 引用率(`该文章引用次数 / 该问题采样成功次数`
- **空状态**:若无内部文章被引用,展示 `暂无内容引用`
- 内容引用区随平台切换联动更新(仅展示当前所选平台的内容引用记录)
#### 3.5.5 面板交互规则
1. 点击问题行展开详情面板,再次点击或点击面板外区域收起
2. 同一时刻只有一个问题详情面板处于展开状态
3. 面板展开时,问题列表区域内容保持可见和可滚动
4. 平台切换器 Tab 状态在面板展开期间持久化,切换问题时重置为默认平台
5. 详情面板内数据加载失败时展示错误提示,不影响外层问题列表展示
---
## 4. 采样配额与容量模型
### 4.1 套餐定义
V5.9 架构支持双采集模式,但**首期只开放插件模式**。
| 模式 | 计费 | 数据特征 | 适用场景 | 首期状态 |
| -------- | ---- | -------------------------------- | -------- | ------------ |
| `plugin` | 免费 | 依赖插件在线率,结果可能不稳定 | 趋势参考 | **首期实现** |
| `api` | 付费 | 服务端统一执行,结果更稳定更准确 | 精确监测 | `[V6]` 预留 |
首期规则:
1. 所有租户固定 `collection_mode = plugin`,不提供切换入口
2. 数据模型保留 `collection_mode` / `collector_type` 字段,查询始终带模式过滤条件
3. 后续引入 API 模式时,只需实现 adapter + worker,不需要迁移历史数据
| 配额项 | Free | Pro | Enterprise |
| ------------ | ------------------------------ | ------------------------------ | --------------------------------- |
| 最大品牌数 | 1 | 3 | 自定义 |
| 题库总量 | 不限于监测能力,只做配置资产 | 不限于监测能力,只做配置资产 | 自定义 |
| 问题覆盖策略 | 尽量覆盖全部启用问题 | 尽量覆盖全部启用问题 | 自定义 |
| 采集频率 | 每天 | 每天 | 每天 / 自定义 |
| 平台范围 | 当前已接入且满足访问条件的平台 | 当前已接入且满足访问条件的平台 | 同左 |
| 采集模式 | `plugin`(首期唯一) | `plugin`(首期唯一) | `plugin``api` `[V6]` |
| 数据承诺 | 趋势参考 | 趋势参考 | 趋势参考;API 模式精确口径 `[V6]` |
### 4.2 问题执行策略
系统不再限制“每日活跃问题数”,而是采用 **尽量覆盖全部启用问题** 的策略。
实现原则:
1. `brand_questions` 保留全部问题题库
2. 只要问题为 `monitor_enabled = true`,就进入当日候选集合
3. 调度器会尽量让每个品牌的全部启用问题去各个模型提问
4. 若当日预算、在线时长或平台可访问性不足,则按优先级做 best-effort 截断
这意味着:
1. 约束从“问题数限制”转成“执行预算限制”
2. 对用户的承诺是“尽量多覆盖问题”,不是“每天只跑固定几题”
3. 页面通过 `desired_sample_count / planned_sample_count / actual_sample_count` 解释覆盖差异
### 4.3 单实例前提下的目标任务量
按“每个品牌尽量覆盖全部启用问题 × 全部可访问平台”的原则,理论任务量为:
```text
desired_sample_count =
启用品牌数
× 每品牌启用问题数
× 当日可访问平台数
```
但实际下发任务量受 `task_daily_hard_cap` 和设备在线约束影响:
```text
planned_sample_count = min(desired_sample_count, task_daily_hard_cap, runtime_accessible_capacity)
```
说明:
1. `desired_sample_count` 代表理想覆盖量。
2. `planned_sample_count` 才是当天真正发出的任务量。
3. 实际完成量取决于:
- 主监测插件是否在线
- 插件机器当日在线时长
- 平台是否要求登录,以及对应登录态是否可用
- 平台风控和采集成功率
### 4.4 核心调度原则
V5 的调度器不追求“补齐所有漏采任务”,而追求“稳定输出可比较样本”。
因此:
1. 每天只生成当天计划任务,不补历史大积压。
2. **在线就采集,不在线就放弃**;当日未执行任务不顺延、不补跑。
3. 超过当天统计窗口的未执行任务直接过期,不回补到新一天的分母中。
4. 读侧按 `planned_sample_count``actual_sample_count` 共同解释结果。
### 4.5 主插件在线约束
单租户只指定 1 个 `primary_monitoring_installation` 参与自动监测。
调度前先判断该插件是否活跃:
- `last_seen_at <= 24h`:生成当天任务
- `last_seen_at > 24h`:不生成新任务,页面沿用最近一次成功数据并提示“近期无新采样”
其中:
- `last_seen_at` 由插件心跳接口持续刷新
- 插件心跳除刷新安装实例活跃时间外,还同步上报监测平台 `detect()` 结果,供 `monitoring_platform_access_snapshots` 更新
这条规则的目的:
1. 防止设备长期离线时任务无限堆积
2. 防止“未执行任务”污染覆盖率统计
3. 让系统行为和单实例现实约束一致
这意味着:
1. 系统不是“补数型监控”,而是“机会型采样”
2. 用户离线期间缺失的数据天然放弃
3. 趋势连续性依赖设备在线率,而不是依赖补采机制
---
## 5. 数据口径定义
### 5.1 顶部指标口径
所有百分比的分母统一为 **实际采样成功回答数**,不是计划任务数,也不是题库总数。
前提:
1. 所有口径都必须在同一 `collection_mode` 下计算
2. 插件模式和 API 模式的数据禁止在同一指标中混算
| 指标 | 定义 |
| ---------- | ------------------------------------------------ |
| 提及率 | `mentioned_count / actual_sample_count` |
| 首位提及率 | `top1_mentioned_count / actual_sample_count` |
| 首选推荐率 | `first_recommended_count / actual_sample_count` |
| 正面提及率 | `positive_mentioned_count / actual_sample_count` |
| 品牌可见率 | 与提及率同源,可作为 UI 别名展示 |
| 文章引用率 | `cited_answer_count / actual_sample_count` |
### 5.2 品牌时序视图口径
首期 plugin 模式下,品牌数据**不适合使用连续折线图**,因为插件不保证 24h 在线,日样本天然可能中断。
V5.9 将时序展示从独立主图区降维为两个辅助元素:
1. **指标卡内 mini sparkline**:在最新快照概览的每个指标卡右侧展示离散点,每个点代表一个有效快照日的 `metric_value`,点之间不连线
2. **采样覆盖条内日指示点行**:最近 N 天每天一个圆点,实心 = 有效快照日,空心 = 无数据日
数据层仍沿用完整时间桶结构,每个时间桶必须同时具备:
- `date`
- `sample_status``sampled | partial | unsampled`
- `metric_value`
- `planned_sample_count`
- `actual_sample_count`
- `coverage_rate`
- `trigger_source``manual | automatic | null`
- `snapshot_updated_at`
展示规则:
1. `sample_status = unsampled` 时,`metric_value = null`mini sparkline 跳过该日(不画点),日指示行渲染为空心点
2. `sample_status = partial` 时,mini sparkline 画半透明点,日指示行渲染为半实心点
3. `sample_status` 只作为日指示点 tooltip 的解释依据,不作为任何逐日直出标签
4. 只有 API 精确模式 `[V6]` 才可考虑恢复独立连续折线图
5. 同一 `business_date` 只展示一个日快照;手动催发成功后刷新当日点位,不追加第二个点
首期推荐:
1. 默认展示最近 7/14/30 天的时间桶
2. mini sparkline 最多展示最近 7 个有效快照点,超出部分截断
3. 当窗口内 `sampled + partial < 2` 时,mini sparkline 不展示,指标卡只展示当前值不展示趋势箭头
### 5.2A 平台对比矩阵口径
平台对比矩阵为 V5.9 新增的展示区域,用于直观对比各 AI 平台的品牌表现差异。
数据来源:
1.`(tenant_id, brand_id, business_date, ai_platform_id)` 维度从 `monitoring_*_daily` 汇总
2. 始终展示全部已接入平台,不受页面模型筛选器影响
3. 各平台独立计算 `mention_rate / top1_mention_rate / actual_sample_count`
状态判定规则:
| 条件 | `platform_sample_status` | 说明 |
| --------------------------------------------------------- | ------------------------ | ------------ |
| `actual_sample_count >= 1` | `sampled` | 已有有效样本 |
| 平台在计划内但尚未采样 | `pending` | 等待采样 |
| 平台 `auth_requirement = login_required` 且未检测到登录态 | `not_logged_in` | 未登录 |
| 平台 adapter 不可用 | `unavailable` | 不可用 |
展示规则:
1. `sampled` 状态展示具体指标数值
2. 其他状态指标列显示 `--`
3. 平台行默认按 `actual_sample_count` 降序排列
4. 悬浮或点击平台行可查看该平台最近采样详情
### 5.3 高频问题口径
高频问题区不再解释为"全站高频触发问题",而是:
> 在当前时间窗口内,被实际采样成功次数最多的问题
**问题列表展示字段:**
- 问题文案
- 成功采样次数
- 提及率
- 最近采样时间
**问题详情面板数据口径:**
问题详情面板通过独立接口(见 12.1A 节)按需加载,数据口径如下:
1. **答案内容**:按 `ai_platform_id` 分组,取最近一次 `status = succeeded``raw_answer_text`;随平台切换按需请求,不在首屏预加载全部平台回答
2. **引用来源**:从 `monitoring_citation_facts``(run_id, ai_platform_id)` 聚合,每条引用对应 `cited_url / site_name / article_id`;随平台切换联动更新
3. **引用分析**:按 `ai_platform_id` 聚合,跨平台汇总展示各平台 `citation_count / distinct_site_count / has_brand_article_citation`;不受平台切换过滤影响,始终展示全平台汇总
4. **内容引用**:关联 `monitoring_article_url_aliases`,过滤 `article_id IS NOT NULL`,聚合 `article_citation_count``article_citation_rate = article_citation_count / question_sample_count`;随平台切换联动
**口径边界:**
- 引用分析和内容引用的聚合时间窗口与外层问题列表保持一致(默认当日快照)
- 若当日有多次采样(手动 + 自动),则聚合全部成功运行的引用事实
- 平台切换仅影响「答案内容」和「引用来源」;「引用分析」默认展示所有平台的聚合不随切换变化
- 「内容引用」随平台切换联动,仅展示当前所选平台中归因成功的内部文章记录
### 5.4 引用排行口径
引用排行按模型平台展示:
- 有过可归因引用的回答数
- 引用文章数
- 引用率
若平台当日无样本,或平台要求登录但当前未登录:
- 在 tooltip 或侧边说明中展示为 `暂无快照`
- 不强行按 `0` 并入分母
---
## 6. 数据模型修订
### 6.1 双 PostgreSQL 存储边界
V5.9 将数据按“业务配置”和“监测结果”拆到两个 PostgreSQL
**主业务 PostgreSQL**
- `tenants`
- `brands`
- `brand_keywords`
- `brand_questions`
- `articles`
- `publish_records`
- `plugin_installations`
- `tenant_monitoring_quotas`
- `tenant_collection_mode_switches` `[V6]`
- `monitoring_config_outbox`
**Monitoring PostgreSQL**
- `monitoring_collect_tasks`
- `monitoring_question_config_snapshots`
- `monitoring_platform_access_snapshots`
- `question_monitor_runs`
- `question_monitor_parse_results`
- `monitoring_citation_facts`
- `monitoring_article_url_aliases`
- `site_domain_mappings`
- `monitoring_*_daily`
设计原则:
1. 不做跨库物理外键,只保留逻辑 ID 引用。
2. 监测链路的高频写入、重试、归档不影响主业务 PG。
3. 读侧监测查询优先从 Monitoring PG 获取。
4. 当前代码库只有单数据库配置与单套 sqlc 产物,V5.9 需新增 `monitoring_database` 配置、第二个 `pgxpool.Pool`、第二套 sqlc query/codegen。
### 6.2 `question_version_id` 移除后的替代方案
V5 接受原系统不再维护 `question_version_id`,但仍然保留“输入快照不可变”原则。
替代方案:
1. 任务生成时在任务表写入 `question_hash`
2. 运行结果入库时在运行表写入 `question_text_snapshot`
3. 历史汇总按 `question_hash` 区分口径,必要时可回查 `question_text_snapshot`
这样即使 `question_id` 不变、文案发生修改,历史数据也不会被新文案污染。
实现建议:
1. 文档中继续使用 `question_hash` 表达语义
2. 实际落库使用定长二进制指纹或等价短哈希,而不是 `VARCHAR(64)`
3. 任务表负责调度,尽量不承载大文本;`question_text_snapshot` 只保留在运行表
4. 任务执行时需要的题目文案,由服务端按 `(tenant_id, question_id, question_hash)``monitoring_question_config_snapshots` 读取并组装到执行请求
5. Go 层建议封装 `QuestionHash` 值对象,统一存储层 `[]byte` 与 API 层字符串之间的编解码,避免 sqlc 写入点散落转换逻辑
6. 目的是保留版本语义,同时控制复合索引体积和任务表膨胀
7. `question_hash` 统一建议使用 `SHA-256``16` 字节截断(`128bit`)的定长二进制指纹;在 `question_id` 已参与主键约束的前提下,碰撞概率可忽略
8. 存储层落为 Monitoring PG 的 `BYTEA(16)` 等价二进制指纹;API / JSON / MQ envelope 的 wire format 统一为 `v1:<lowercase-hex>`,其中 `v1` 表示当前哈希算法与编码版本
### 6.3 `tenant_monitoring_quotas`
```sql
CREATE TABLE tenant_monitoring_quotas (
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id),
max_brands INT NOT NULL DEFAULT 1,
collection_mode VARCHAR(20) NOT NULL DEFAULT 'plugin',
question_selection_mode VARCHAR(20) NOT NULL DEFAULT 'best_effort_all',
collect_frequency VARCHAR(20) NOT NULL DEFAULT 'daily',
enabled_platforms JSONB NOT NULL DEFAULT '["deepseek","qwen","doubao"]',
primary_installation_id BIGINT REFERENCES plugin_installations(id),
task_daily_hard_cap INT NOT NULL DEFAULT 36,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
建议默认值:
- Free`collection_mode = plugin``question_selection_mode = best_effort_all``task_daily_hard_cap = 36`
- Pro`collection_mode = plugin``question_selection_mode = best_effort_all``task_daily_hard_cap = 72`
说明:
1. `tenant_monitoring_quotas` 放在主业务 PostgreSQL。
2. `primary_installation_id` 继续引用主业务库中的 `plugin_installations`
3. `collection_mode` 首期固定写入 `plugin`,字段保留是为了后续 API 模式 `[V6]` 扩展时无需迁移。
4. `question_selection_mode` 当前默认 `best_effort_all`,预留模式位。
### 6.4 `monitoring_collect_tasks`
V5.9 使用 Monitoring PG 单表状态机进行任务租约,并用 RabbitMQ 解耦结果写入。
```sql
CREATE TABLE monitoring_collect_tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
question_hash BYTEA NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin',
trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard',
business_date DATE NOT NULL,
planned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
lease_token_hash VARCHAR(128),
leased_to_executor VARCHAR(100),
leased_at TIMESTAMPTZ,
lease_expires_at TIMESTAMPTZ,
callback_received_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
skip_reason VARCHAR(50),
request_payload_json JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_collect_task_idempotent
UNIQUE (tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)
);
CREATE INDEX idx_collect_tasks_lease
ON monitoring_collect_tasks(tenant_id, status, business_date, lease_expires_at);
```
状态流转:
- `pending`
- `leased`
- `received`
- `completed`
- `skipped`
- `expired`
- `failed`
补充说明:
1. `trigger_source` 表示该任务最终由“自动调度”还是“用户手动催发”进入执行链路。
2. 首期**不为手动采集创建第二套同日任务**;手动采集的本质是把当日尚未完成的任务提权/刷新为 `manual` 来源。
3. 因为同日同题同平台仍只有一条任务记录,所以页面天然只会生成一个日快照点位。
4. 同一 `business_date` 内若问题文案发生修改,已生成任务**不重新分裂出第二条同日任务**;任务继续使用生成时锁定的 `question_hash`
说明:
1. 任务租约状态在 Monitoring PG 中维护。
2. `received` 表示执行通道已把结果成功提交到 ingest 队列,但 worker 尚未完成最终入库。
3. `collector_type` 首期固定为 `plugin`,字段保留为 `[V6]` API 模式扩展预留。
4. `leased_to_executor` 首期填入插件 installation ID;后续 API 模式 `[V6]` 可填入 API worker 标识。
5. `question_hash` 仍然落库,用于锁定该任务对应的题目版本和后续运行快照,但不再参与同日任务幂等键
6. 问题文案修改从**下一个 `business_date`** 开始生效;不追溯重写当日已创建任务
### 6.5 `monitoring_question_config_snapshots`
该表属于 Monitoring PG 中的**配置快照表**,职责是为任务生成、租约接口和 API 执行器提供“只读且不可变”的问题版本视图。
```sql
CREATE TABLE monitoring_question_config_snapshots (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
keyword_id BIGINT,
question_hash BYTEA NOT NULL,
question_text_snapshot TEXT NOT NULL,
monitor_enabled BOOLEAN NOT NULL DEFAULT true,
superseded_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
projected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_monitoring_question_snapshot
UNIQUE (tenant_id, question_id, question_hash)
);
CREATE INDEX idx_monitoring_question_snapshot_active
ON monitoring_question_config_snapshots(tenant_id, brand_id, monitor_enabled, deleted_at, superseded_at);
```
约束:
1. 调度器生成任务只读取 `monitor_enabled = true``deleted_at IS NULL``superseded_at IS NULL` 的**当前版本**快照记录。
2. 租约接口和 API 执行器装配题目文案时,只读取这张快照表,不回源主业务 PG。
3. 问题文案变更时,projector 先 `INSERT``question_hash` 快照,再将旧当前版本标记 `superseded_at = now()`;不覆盖历史版本。
4. `brand_question.updated` 只负责“新版本追加 + 旧当前版本 supersede”,不会把历史快照物理删除。
5. `brand_question.disabled / brand.deleted` 负责把当前快照标记 `deleted_at`,而不是复用 `superseded_at` 语义。
### 6.5A `monitoring_platform_access_snapshots`
该表用于沉淀主监测插件最近一次探测到的“平台可访问性”快照,作为调度器与读侧共享的状态源,避免页面根据“有没有成功运行记录”反推 `未登录 / 不可用`
```sql
CREATE TABLE monitoring_platform_access_snapshots (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
installation_id BIGINT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL,
access_status VARCHAR(20) NOT NULL,
connected BOOLEAN NOT NULL DEFAULT false,
accessible BOOLEAN NOT NULL DEFAULT false,
detected_at TIMESTAMPTZ NOT NULL,
reason_code VARCHAR(50),
reason_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_platform_access_snapshot
UNIQUE (tenant_id, installation_id, ai_platform_id, business_date)
);
CREATE INDEX idx_platform_access_snapshot_lookup
ON monitoring_platform_access_snapshots(tenant_id, business_date, ai_platform_id, access_status);
```
约束:
1. `access_status` 推荐值:`accessible | not_logged_in | unavailable`
2. 数据语义与插件当前 `detect()` 返回的 `connected` 状态保持一致,并扩展出调度可用的 `accessible`
3. 调度器只为 `access_status = accessible` 的平台生成当天任务
4. 读侧平台矩阵和问题详情面板使用这张表 + `monitoring_collect_tasks` + `question_monitor_runs` 共同渲染 `sampled / pending / not_logged_in / unavailable`
5. 主监测插件每次心跳(默认 `15` 分钟)都应同时上报各 AI 平台 `detect()` 结果,服务端按 `(tenant_id, installation_id, ai_platform_id, business_date)` 执行 `UPSERT`
6.`business_date` 的第一条心跳会创建当日快照;在此之前,`00:10` 调度器允许回退读取最近 `24h` 内的上一份可访问快照做机会型计划
7.`00:10` 后同日首次心跳把平台状态更新为 `not_logged_in / unavailable`,服务端需立即对该平台当日仍为 `pending` 的任务执行 reconcile,转为 `skipped` 并写入 `skip_reason = platform_not_accessible`
8. 对已进入 `leased` 的任务不做强制回收;允许其继续执行、自然完成或按过期策略回收
### 6.6 `question_monitor_runs`
首期直接定义为独立结果表,不采用“先建空表再 ALTER”的增量方式。
```sql
CREATE TABLE question_monitor_runs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
question_text_snapshot TEXT NOT NULL,
question_hash BYTEA NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL,
trigger_source VARCHAR(20) NOT NULL DEFAULT 'automatic',
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard',
business_date DATE NOT NULL,
provider_model VARCHAR(100),
provider_request_id VARCHAR(100),
request_id VARCHAR(100),
raw_answer_text TEXT,
raw_answer_storage_key TEXT,
raw_response_json JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'succeeded',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_monitor_run_idempotent
UNIQUE (tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)
);
CREATE INDEX idx_monitor_runs_query
ON question_monitor_runs(tenant_id, business_date, ai_platform_id, brand_id);
```
建议:
1. `question_monitor_runs` 落在 Monitoring PostgreSQL。
2. 若原始回答文本不超过阈值,可直接存库;超大原文再转对象存储。
3. `trigger_source` 用于读侧展示“最近一次有效快照来自手动还是自动”,并支持 tooltip 解释层。
4. `collector_type``provider_request_id` 为插件/API 二选一模式打通统一入库协议。
5. 所有读侧查询必须显式按 `collector_type` 或等价 `collection_mode` 过滤,禁止混算。
6. 运行表沿用任务创建时锁定的 `question_hash`,用于保证回答口径与当日任务快照一致
7. 运行表唯一键故意与任务表幂等键保持一致,保证同日同题同平台只有一条运行记录,不因当日文案修改而分裂出第二条结果
### 6.6A `question_monitor_parse_results`
该表为 `question_monitor_runs` 的一对一解析结果表,用于承接 3.4 / 3.5 页面所需的回答层语义字段,并作为日聚合的事实来源之一。
```sql
CREATE TABLE question_monitor_parse_results (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
run_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
question_hash BYTEA NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL,
brand_mentioned BOOLEAN,
brand_mention_position VARCHAR(20),
first_recommended BOOLEAN,
sentiment_label VARCHAR(20),
matched_brand_terms JSONB,
parse_version VARCHAR(40) NOT NULL DEFAULT 'v1',
parse_status VARCHAR(20) NOT NULL DEFAULT 'succeeded',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_question_parse_run
UNIQUE (tenant_id, run_id)
);
CREATE INDEX idx_question_parse_lookup
ON question_monitor_parse_results(tenant_id, business_date, brand_id, question_id, ai_platform_id);
```
字段约定:
1. `brand_mention_position``top1 | mentioned | not_mentioned | null`
2. `sentiment_label``positive | neutral | negative | unknown`
3. `matched_brand_terms` 用于回答区品牌高亮;若为空,前端可退化为关键词字符串高亮
4. 日级 `mention_rate / top1_mention_rate / first_recommend_rate / positive_mention_rate` 均由本表聚合得出
### 6.7 文章 URL 归因表
为支撑“总引用数 / 文章引用率 / 引用文章数”,新增 URL 归因别名表:
```sql
CREATE TABLE monitoring_article_url_aliases (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
publish_record_id BIGINT,
platform_id VARCHAR(64),
publish_platform_name_snapshot VARCHAR(100),
external_article_id VARCHAR(128),
article_title_snapshot TEXT,
original_url TEXT NOT NULL,
normalized_url TEXT NOT NULL,
last_path_segment VARCHAR(255),
confidence VARCHAR(20) NOT NULL DEFAULT 'high',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uk_article_url_alias
ON monitoring_article_url_aliases(tenant_id, normalized_url);
```
补充说明:
1. `article_title_snapshot / publish_platform_name_snapshot` 允许冗余存储,优先服务读侧问题详情接口,避免逐条回源主业务 PG
2. 若历史数据尚未补齐上述快照字段,读侧允许退化为批量 `IN` 查询主业务 PG 并配合本地短 TTL 缓存
### 6.8 跨库一致性策略
主业务 PG 是配置真源,Monitoring PG 是结果投影库。
为避免跨库数据漂移,V5.9 采用:
1. **软删除优先**:品牌、问题、文章删除时先写主业务库 `deleted_at`,不立即物理删除 Monitoring PG 历史数据
2. **Outbox 事件传播**:主业务库通过 outbox 记录配置变更事件,由 projector 异步投影到 Monitoring PG
3. **最终一致**:监测任务生成只读取“已投影完成”的配置快照
首期实现方式明确为:
1. 使用**应用层 outbox**,不在首期引入 Debezium/CDC
2. 主业务事务提交配置变更时,同事务写入 `monitoring_config_outbox`
3. `projector` 进程每 5 秒轮询一次 outbox,按批次使用 `FOR UPDATE SKIP LOCKED` 投影到 Monitoring PG
4. 正常配置投影延迟目标为 `<= 30 秒`,超过 `2 分钟` 进入告警
5. 未完成投影的配置变更不得参与新任务生成
6. 首期维持 `PostgreSQL + 应用层 outbox + polling projector`,不切到 CDC/WAL 订阅;原因是当前同步对象为低频配置变更、下游单一,CDC 运维复杂度高于收益
主业务 PG 中的 outbox 表建议为:
```sql
CREATE TABLE monitoring_config_outbox (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
event_type VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
projected_at TIMESTAMPTZ,
last_error TEXT
);
CREATE INDEX idx_monitoring_config_outbox_pending
ON monitoring_config_outbox(status, available_at, id);
```
首期推荐事件类型:
- `brand.deleted`
- `brand_question.disabled`
- `brand_question.updated`(文案变更触发新快照投影)
- `tenant_monitoring_quota.updated`
- `article.deleted`
`[V6]` 预留事件类型(首期不实现):
- `tenant.collection_mode.switch_requested`
处理原则:
1. 主业务配置被软删除后,不再生成新任务
2. Monitoring PG 保留历史结果用于趋势回看
3. 读侧默认过滤已删除配置的“新任务入口”,但允许查询历史统计
Projector 运行要求:
1. `projector` 作为独立 deployable/worker role 运行,不内嵌在 Monitor Worker 的 goroutine 中
2. 生产环境推荐至少 `2` 个无状态副本,通过 `FOR UPDATE SKIP LOCKED` 竞争领取 outbox 批次,任一实例退出不影响整体投影
3. 调度器生成任务前,只读取 `monitoring_question_config_snapshots` 与本地缓存配置,不回源主业务 PG
4. 若租户存在超过阈值仍未投影完成的 outbox 事件,则当天任务生成跳过该租户并输出告警,避免读到过期快照
Replay / 补偿要求:
1. 支持按 `outbox_id` 范围、`tenant_id``aggregate_type + aggregate_id` 进行手动 replay
2. replay 必须保持幂等,重复投影只允许 `UPSERT` 当前快照/缓存表,不重复生成业务结果
3. 每日 `06:00` 增加一次轻量 reconcile,校验主业务有效配置与 Monitoring PG 快照数量是否一致,发现漂移时输出告警并允许重放
4. `06:00` reconcile 的目标是在 `06:30` 日聚合前先完成配置快照校验,降低聚合读取过期配置的风险
### 6.9 `[V6]` `collection_mode` 切换状态机
> **首期不实现。** 所有租户固定 `collection_mode = plugin`,无切换入口。以下为 API 模式上线时的预留设计。
插件模式和 API 模式是租户级二选一,因此模式切换必须显式建模。
该表属于**主业务 PostgreSQL**,因为它本质上是配置域状态,而不是监测结果。
推荐新增表(`[V6]` 时建表):
```sql
CREATE TABLE tenant_collection_mode_switches (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
from_mode VARCHAR(20) NOT NULL,
to_mode VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
effective_at TIMESTAMPTZ NOT NULL,
grace_until TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
cancelled_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
状态建议:
- `pending`
- `freezing_old_mode`
- `cutover_active`
- `completed`
- `cancelled`
切换规则:
1. `effective_at` 之前,不改变当前查询口径
2. 到达 `effective_at` 后,旧模式停止生成新任务
3. 旧模式已 `leased` / 已下发的任务允许执行到 `grace_until`
4. 超过 `grace_until` 未完成的旧模式任务直接过期
5. 新模式任务从 `effective_at` 起生成
6. 同一租户 `completed` 状态的模式切换,最短间隔为 30 天
7. 当存在 `pending / freezing_old_mode / cutover_active` 记录时,不允许再次发起新切换
前端展示规则:
1. 趋势图按 `collection_mode` 分段展示
2. 切换点展示模式切换标记
3. 禁止把切换前后的数据拼成单一连续口径
### 6.10 分区与数据生命周期
Monitoring PG 中以下表建议按 `business_date` 做月分区:
1. `monitoring_collect_tasks`
2. `question_monitor_runs`
3. `question_monitor_parse_results`
4. `monitoring_citation_facts`
5. `monitoring_*_daily`
推荐默认保留策略:
1. 原始任务与原始回答:60 天热数据
2. 引用事实:60 天热数据,必要时归档到对象存储或冷库
3. 日聚合结果:30 天
4. 超期数据按分区归档或删除,不做逐行清理
---
## 7. 解析标准化与 SoV 统计模型
### 7.1 解析分层
V5.9 的解析分为 3 层:
1. **回答层**:品牌提及、首位提及、首选推荐、情感倾向
2. **引用层**:从回答、citation JSON、search_results 中提取 URL 事实
3. **来源层**:基于域名清洗和站点聚合生成 SoV 统计结果
### 7.2 基于 `tldextract` 的顶级域名清洗
为避免 `www`、二级域名、国家后缀等问题导致同一站点重复计数,引用归因与来源排行统一采用 **Public Suffix List + `tldextract`** 口径。
Go 栈实现建议:
1. 首期优先使用 `golang.org/x/net/publicsuffix` + 自封装完成 `registrable_domain` 提取。
2. 若后续需要更接近 Python `tldextract` 的行为,再评估引入等价 Go 实现。
处理目标:
1. 从原始 URL 中提取 `subdomain / domain / suffix`
2. 生成 `registrable_domain`
3. 生成统一站点主键 `site_key`
4. 为页面级和站点级聚合提供稳定维度
示例:
| 原始 URL | `registrable_domain` | 说明 |
| ------------------------------------------- | -------------------- | --------------------- |
| `https://www.zhihu.com/question/123` | `zhihu.com` | `www` 折叠 |
| `https://zhuanlan.zhihu.com/p/123` | `zhihu.com` | 子域归并到站点 |
| `https://mp.weixin.qq.com/s/abc` | `weixin.qq.com` | 基于 PSL 提取可注册域 |
| `https://finance.sina.com.cn/roll/123.html` | `sina.com.cn` | 正确处理 `com.cn` |
| `https://sub.example.co.uk/a` | `example.co.uk` | 正确处理 `co.uk` |
推荐清洗流程:
1. URL 小写化主机名
2. 去掉默认端口
3. 去掉 fragment
4. path 做基础规范化
5. host 通过 `tldextract` 计算 `registrable_domain`
6. 保存:
- `normalized_url`
- `host`
- `registrable_domain`
- `subdomain`
- `suffix`
### 7.3 `monitoring_citation_facts` 推荐字段
为支撑域名清洗和来源排行,建议引用事实表至少包含:
```sql
CREATE TABLE monitoring_citation_facts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
run_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
business_date DATE NOT NULL,
cited_url TEXT NOT NULL,
cited_title TEXT,
normalized_url TEXT NOT NULL,
host VARCHAR(255) NOT NULL,
registrable_domain VARCHAR(255) NOT NULL,
subdomain VARCHAR(255),
suffix VARCHAR(50),
site_key VARCHAR(255) NOT NULL,
article_id BIGINT,
publish_record_id BIGINT,
resolution_status VARCHAR(20) NOT NULL DEFAULT 'resolved',
resolution_confidence VARCHAR(20) NOT NULL DEFAULT 'high',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_citation_facts_domain
ON monitoring_citation_facts(tenant_id, business_date, registrable_domain);
CREATE INDEX idx_citation_facts_run
ON monitoring_citation_facts(run_id);
```
约定:
1. `normalized_url` 用于页面级去重和文章归因
2. `registrable_domain` 用于站点级来源排行
3. `site_key` 默认等于 `registrable_domain`
4. 如业务需要区分站群,可对特定域名做覆写映射
5. `cited_title` 优先使用平台返回的 citation title;缺失时读侧允许退化为 URL 文本
### 7.4 `site_key` 映射规则
默认规则:
- `site_key = registrable_domain`
可选增强规则:
1.`mp.weixin.qq.com``weixin.qq.com` 统一到 `weixin.qq.com`
2. 将业务上需要区分的站群写入 `site_domain_mapping`
3. 对同集团但不同产品是否合并,必须通过配置显式决定,不靠临时规则猜测
推荐新增配置表:
```sql
CREATE TABLE site_domain_mappings (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
registrable_domain VARCHAR(255) NOT NULL,
site_key VARCHAR(255) NOT NULL,
site_name VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
映射优先级:
1. 租户级映射(`tenant_id = 当前租户`
2. 全局默认映射(`tenant_id IS NULL`
落库建议:
1. `site_domain_mappings` 的配置真源放在主业务域
2. Monitoring 读侧使用由 projector 投影到 Monitoring PG 的本地只读缓存,避免问题详情接口在高频查询时跨库查站点映射
3. 读侧若未命中映射,则 `site_name` 回退为 `registrable_domain`
### 7.5 SoV 统计模型
V5.9 中的 SoV 统一解释为:
> 在当前采样窗口内,某品牌、某站点或某文章在全部有效样本中的相对占比
必须明确分母,禁止不同口径混用。
### 7.6 品牌 SoV
品牌 SoV 适用于品牌曝光类页面。
定义:
```text
brand_sov = brand_mentioned_answers / actual_sample_count
```
其中:
- `brand_mentioned_answers`:成功采样回答中,提及目标品牌的回答数
- `actual_sample_count`:成功采样回答总数
这和页面上的“提及率”同源,可视为品牌在样本回答中的 `Share of Voice`
### 7.7 引用来源 SoV
引用来源 SoV 适用于“引用来源排行”“来源平台占比”。
定义:
```text
source_sov = source_citation_count / total_resolved_citation_count
```
其中:
- `source_citation_count`:某 `site_key` 在窗口内被引用的次数
- `total_resolved_citation_count`:全部已成功解析并完成站点归类的引用次数
补充口径:
- unresolved 引用不进入分子
- unresolved 是否进入分母,要固定为“不进入”
- unresolved 单独展示占比,作为质量指标
### 7.8 文章 SoV
文章 SoV 用于内部文章在可归因引用中的占比。
定义:
```text
article_sov = article_citation_count / total_article_resolved_citation_count
```
其中:
- `article_citation_count`:某篇内部文章被 AI 引用的次数
- `total_article_resolved_citation_count`:全部成功归因到内部文章的引用次数
注意:
1. 文章 SoV 不是品牌 SoV
2. 文章 SoV 的分母不能拿全部回答数
3. 若用户看的是“文章引用率”,仍然应使用 `cited_answer_count / actual_sample_count`
### 7.9 覆盖率与 SoV 的关系
覆盖率和 SoV 必须分开解释:
1. `coverage_rate` 反映“今天采样够不够”
2. `SoV` 反映“在已采样样本里占比多少”
因此页面解释顺序应为:
1. 先看覆盖率
2. 再看 SoV / 提及率 / 引用率
`coverage_rate < 40%` 时:
- 允许展示 SoV
- 但必须附带低置信度提示
### 7.10 聚合输出建议
建议在 `monitoring_citation_source_daily``monitoring_citation_page_daily` 中增加:
1. `citation_count`
2. `citation_sov`
3. `resolved_count`
4. `unresolved_count`
5. `confidence_level`
这样前端可以同时展示:
- 引用次数
- SoV
- 数据质量
---
## 8. 任务协议与插件侧流程
### 8.1 统一为租约式协议
V5 删除 `GET tasks -> POST claim` 两步式协议,统一改为:
1. 插件请求租约
2. 服务端原子分配任务并返回 `lease_token`
3. 插件执行采集
4. 插件带 `lease_token` 回传结果
5. 服务端校验归属、先写任务状态为 `received` 并投递 RabbitMQ
6. Monitor Worker 异步写入运行结果、解析结果并完成任务
### 8.2 插件 API
```text
POST /api/plugin/monitoring/heartbeat
POST /api/plugin/monitoring/tasks/lease
POST /api/plugin/monitoring/tasks/{id}/result
POST /api/plugin/monitoring/tasks/{id}/skip
```
请求头:
- `X-Geo-Installation-Token`
- `X-Geo-Installation-Id`
说明:
1. `POST /api/plugin/monitoring/heartbeat` 用于刷新 `plugin_installations.last_seen_at` 并批量上报平台 `detect()` 结果
2. 心跳接口建议在同一事务内完成:安装实例鉴权、`last_seen_at` 更新、`monitoring_platform_access_snapshots` 批量 `UPSERT`
3. 这组接口只服务 `collector_type = plugin`
4. `collection_mode = api` 的服务端调度入口为 `[V6]` 预留,首期不实现
### 8.3 租约接口行为
租约接口建议按 PG 原子领取:
```sql
SELECT id
FROM monitoring_collect_tasks
WHERE tenant_id = $1
AND status = 'pending'
AND business_date = $2
ORDER BY planned_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT $3;
```
服务端完成以下动作:
1. 选出任务
2. 生成短期 `lease_token`
3. 更新 `status = leased`
4. 写入 `lease_expires_at = now() + interval '15 minutes'`
5.`(tenant_id, question_id, question_hash)``monitoring_question_config_snapshots` 装配题目文案并返回给插件
### 8.3A 租户侧“立即采集”入口
数据详情页提供租户侧手动触发入口:
```text
POST /api/tenant/monitoring/brands/{brand_id}/collect-now
```
行为定义:
1. 仅当 `primary_monitoring_installation` 在线时可触发;离线则返回 `installation_offline`
2. 手动触发**不创建第二套同日任务**,而是把当日该品牌下尚未完成的 `pending / expired / failed` 任务刷新为可再次租约的状态,更新 `planned_at = now()`,并将 `trigger_source` 更新为 `manual`
3. 若当日已存在成功快照,则接口直接返回“今日已有有效快照”,不新增第二个日点位
4. 若存在已被插件领取的 `leased` 任务,接口**不等待当前租约批次完成**;这些任务继续执行,本次手动触发只刷新未被领取的任务
5. 接口返回值建议包含 `refreshed_task_count / leased_task_count / completed_task_count`,前端文案明确为“已刷新未领取任务,已领取任务继续执行”
6. 手动触发成功后,页面先进入“手动采集中”状态;待新结果成功入库并完成局部聚合后,再刷新“今日快照”和对应日桶的 `snapshot_updated_at / trigger_source`
7. 首期仍遵守“在线就采集,不在线就放弃”的原则,不做离线补数
8. 若用户当天修改问题文案后立即手动触发,仍执行当日已生成任务锁定的题目版本;新文案从下一 `business_date` 起生效
### 8.4 插件执行策略
V5.9 复用的是现有扩展框架,而不是现有发布 adapter 体系:
- [background.ts](/Users/liangxu/Documents/test/geo-rankly/apps/browser-extension/entrypoints/background.ts)
- [runtime.ts](/Users/liangxu/Documents/test/geo-rankly/apps/browser-extension/src/runtime.ts)
- [storage.ts](/Users/liangxu/Documents/test/geo-rankly/apps/browser-extension/src/storage.ts)
代码库对齐说明:
1. 可复用的是 WXT 框架、background service worker、alarm/message passing、平台状态存储等基础设施
2. 现有 `src/adapters/` 是媒体发布 adapter,接口为 `detect() + publish()`,继续服务文章发布链路
3. 监测侧需要新建独立 AI 监测 adapter 模块,例如 `src/monitoring-adapters/`,接口建议为 `detect() + query() + parseResponse()`
4. 发布 adapter 与监测 adapter 可以共享平台状态探测能力,但不共享同一接口定义、平台枚举和执行流水线
但采样策略改为“小批量、固定预算”:
| 项目 | 建议值 |
| -------------- | -------------- |
| 心跳频率 | 15 分钟 |
| 单次租约任务数 | 2-3 |
| 单任务超时 | 60 秒 |
| 租约时长 | 15 分钟 |
| 每日硬上限 | 按套餐 36 / 72 |
### 8.4A 平台状态上报闭环
为让 `monitoring_platform_access_snapshots` 真正成为调度器与读侧共享状态源,首期明确采用以下闭环:
1. 主监测插件每次心跳都执行监测平台 `detect()`,并把 `connected / accessible / reason_code / reason_message` 随心跳一并回传服务端
2. 服务端以心跳接收时间所属的 `business_date` 为准,`UPSERT monitoring_platform_access_snapshots`
3. `00:10` 调度器生成当天任务时,优先读取当日快照;若当日快照尚未出现,则回退读取最近 `24h` 内最新快照做机会型计划
4. 若调度后首次心跳把平台状态修正为 `not_logged_in / unavailable`,服务端立即对该平台当日仍为 `pending` 的任务执行 reconcile,改为 `skipped`
5. `leased` 中的任务不因状态回传被强制中断;仍按正常回调或过期回收处理
6. 读侧平台矩阵与问题详情面板统一读取该表,不直接根据前端本地状态猜测 `未登录 / 不可用`
### 8.5 任务过期与回收
定时任务每 30 分钟处理一次:
1. `lease_expires_at < now()` 且状态仍为 `leased` 的任务改为 `expired`
2. `received` 状态超过阈值未被 worker 完成时,进入异常告警
3. 同一 `business_date``expired` 任务最多重试 1 次
4. 超过 `business_date + 2 days` 的未完成任务不再重试
### 8.6 `[V6]` API 采集模式接口预留
> **首期不实现。** 本节仅保留数据模型和协议契约,供后续 API 模式接入时复用。
V5.9 中插件采集和 API 采集是二选一模式,因此必须提前统一“结果入库协议”。
建议内部抽象:
```text
CollectTask
-> collector_type = plugin | api
CollectorResultEnvelope
-> collector_type
-> task_id
-> tenant_id
-> brand_id
-> question_id
-> question_hash
-> ai_platform_id
-> run_mode
-> provider_model
-> provider_request_id
-> answer
-> citations
-> search_results
-> raw_response_json
-> status
```
统一原则:
1. 插件采集和 API 采集最终都投递到同一个 `monitor.result.ingest`
2. parse worker 只认标准化后的 `CollectorResultEnvelope`
3. 采集通道差异在 adapter 层消化,不传播到聚合层和查询层
4. 同一租户、同一统计窗口只能启用一种 `collector_type`
5. 传输层中的 `question_hash` 统一使用 `v1:<lowercase-hex>` 字符串表示,ingest 后统一解码落为 Monitoring PG 的二进制指纹
### 8.7 `[V6]` API 模式执行器并发模型
> **首期不实现。** 本节为 API 模式上线时的执行器并发和限流预留设计。
API 模式不复用插件租约接口,但仍复用同一任务表和同一领取模型。
建议:
1. API worker 从 `monitoring_collect_tasks` 中仅领取 `collector_type = api` 的任务
2. API worker 同样使用 `FOR UPDATE SKIP LOCKED` 控制并发
3. 并发粒度按 `ai_platform_id` 和租户配额限流
4. 同一问题同一平台同一天仍由幂等键保证不重复落数
推荐并发层级:
1. 租户级并发上限
2. 平台级并发上限
3. 全局 provider QPS / TPM 限流
首期实现建议:
1. 全局 provider QPS / TPM 限流先在 API worker 进程内以 token bucket 实现
2. worker 集群总吞吐通过部署副本数和进程内并发参数共同控制
3. 当后续进入多实例横向扩容或 provider 配额需要跨实例共享时,再升级为 Redis 分布式限流
4. 限流接口在代码层保持抽象,避免首期实现和后续分布式版本耦合
---
## 9. 采集、解析、聚合架构
### 9.1 首期简化版链路
```text
插件每 15 分钟心跳并上报 detect() 结果
服务端 upsert monitoring_platform_access_snapshots
每日 00:10 调度器生成当天 plugin 任务
用户可在数据详情页手动催发当日未完成任务
插件心跳租约并执行
插件回传结果 -> 写入 RabbitMQ ingest
Monitor Worker:消费结果 -> 写 Monitoring PG -> 解析结果 -> 完成任务
每日 06:30 聚合 Job 汇总到 monitoring_*_daily
读侧 API + Redis 缓存
```
`[V6]` 预留分支:
1. API 模式上线后,再在调度器后增加服务端 provider adapter 执行链路
2. 该扩展不改变下游 ingest / parse / aggregate 主链路
首期调度器实现选择:
1. 不复用当前 `schedule_tasks`
2. 原因是当前代码库中 `schedule_tasks` 只有 CRUD 和 schema,尚无 executor / dispatcher
3. 首期监测调度器直接作为 Monitor Worker 进程内独立 cron goroutine 实现,同时负责每日任务生成和过期回收
4. 待通用调度执行器成熟后,再评估是否统一收敛到平台级调度框架
### 9.2 RabbitMQ 的职责边界
V5.9 中 RabbitMQ 只承担“结果异步化”,不承担“任务分发”。
使用 RabbitMQ 的原因:
1. 插件回调接口可以快速返回,降低超时和重试风险
2. 解析、引用归因、写库失败不会直接影响插件侧体验
3. 方便把高频写入隔离到独立 worker
4. 后续若企业版放大量级,不需要重做写链路
但 RabbitMQ 不参与以下事项:
1. 不做任务租约
2. 不做读侧查询
3. 不强制引入准实时前端刷新
推荐队列:
- `monitor.result.ingest`
- `monitor.result.dlq`
- `monitor.aggregate.trigger`
说明:
1. 首期 `monitor.result.ingest` 只接收 `plugin` 采集结果
2. `[V6]` 引入 API 模式后,继续复用同一 ingest 队列和同一标准化 envelope
3. 队列消费者不关心采集来源,只关心统一 envelope
MQ 部署建议:
1. 开发 / docker-compose 环境先落单节点 RabbitMQ,验证 publisher、consumer、DLQ 全链路
2. 生产环境使用 3 节点 RabbitMQ 集群
3. 核心队列在生产环境使用 durable + quorum queue
4. 发布端启用 publisher confirm
5. 消息使用 persistent delivery mode
消费与幂等建议:
1. worker 在 Monitoring PG 事务提交成功后再 ack
2.`task_id + collector_type + business_date` 或运行表唯一键做最终幂等
3. 同一消息重复投递时,允许消费端幂等落空后直接 ack
DLQ 处理流程:
1. 首次失败按重试策略回投主队列,重试 3 次,退避间隔为 `5s / 30s / 120s`
2. 重试次数通过消息 header 传递,超过阈值进入 `monitor.result.dlq`
3. 运维或研发基于任务 ID、错误类型、原始 payload 做人工排查
4. 排查完成后支持“重放到 ingest”或“标记任务 failed”
5. 所有重放动作必须写审计日志,记录操作人、时间、原因、task_id 和消息摘要
### 9.3 Monitoring PostgreSQL 的职责
独立 Monitoring PG 的目的不是“多一套库”,而是隔离监测链路的写放大。
承载内容:
1. 任务表
2. 原始回答
3. 解析结果
4. 引用事实
5. 日聚合结果
收益:
1. 主业务 PG 不承受高频插入和大文本更新
2. Monitoring PG 可独立做分区、归档、VACUUM 策略
3. 监测表膨胀不会影响文章、品牌、发布主链路
### 9.4 原始回答存储
V5.9 中抓取结果优先进入 Monitoring PG
1. 原始回答文本
2. 平台响应元数据
3. 引用与搜索结果原始 JSON
4. 解析后的结构化字段
可选归档策略:
1. 小于 64 KB 的原文直接存 Monitoring PG
2. 超大原文或完整调试负载转存对象存储
3. Monitoring PG 仅保留摘要、关键字段和存储路径
---
## 10. 文章引用归因策略
### 10.1 为什么不能只看 URL 最后一段
仅按最后一段匹配存在以下问题:
1. 不同平台可能出现相同 path segment
2. 同一平台 PC / H5 / 分享页 URL 可能不同
3. AI 引用结果可能带参数、重定向或 canonical URL
4. 同一篇文章可能存在多个可访问别名
因此 V5 的归因策略是 **分层匹配 + 置信度标记**
### 10.2 归因顺序
1. `normalized_url` 精确匹配
2. `(platform_id, external_article_id)` 精确匹配
3. `(domain, last_path_segment)` 唯一匹配
4. 无法唯一命中时,记为 `unresolved`
### 10.3 低置信度处理
当仅通过 `last_path_segment` 命中时:
1. 给归因结果打 `low_confidence`
2. 可以进入趋势参考视图
3. 不建议进入需要精确计费或精确归属的报表
这与 V5 的“趋势参考”定位是一致的。
---
## 11. 平台接入范围
### 11.1 V5 交付范围
数据模型支持 6 平台扩展,但首期交付只承诺首批 3 平台:
1. DeepSeek
2. 通义千问
3. 豆包
### 11.2 页面与套餐表达
平台访问条件不能一刀切,V5.9 统一按“平台访问模式”处理。
推荐在平台配置中增加:
- `auth_requirement = login_required | anonymous_allowed`
当前已知口径:
| 平台 | 访问模式 | 说明 |
| ---------------- | ------------------- | ------------------------ |
| DeepSeek | `login_required` | 必须登录后才能提问 |
| Kimi | `login_required` | 必须登录后才能提问 |
| 其他当前接入平台 | `anonymous_allowed` | 可在未登录状态下直接提问 |
任务生成规则:
1. `login_required` 平台:只有检测到有效登录态才进入当天采样计划
2. `anonymous_allowed` 平台:默认可进入当天采样计划
3. 若免登录平台后续改版为必须登录,应通过平台配置热更新切换,不改调度代码
实现建议:
1. 平台访问模式放入 `ai_platforms` 或等价配置表
2. 插件 `detect()` 返回 `accessible``connected` 两个概念
3. 调度器判断的是 `accessible`,不是简单判断 `connected`
页面和套餐都必须按“当前已接入且满足访问条件的平台”解释:
- 已接入且满足访问条件:进入采样计划
- 已接入但不满足访问条件:展示 `未登录``不可访问`
- 未接入:展示 `未接入`
不得再用“全 6 平台稳定可用”作为首期对外承诺。
---
## 12. 读侧与汇总表
V5 读侧继续沿用 V3 的聚合表和缓存思路,但口径增加采样字段:
1. `desired_sample_count`
2. `planned_sample_count`
3. `actual_sample_count`
4. `coverage_rate`
5. `confidence_level`
推荐所有 `monitoring_*_daily` 汇总表统一增加以上字段。
### 12.0 `monitoring_brand_daily` 推荐 DDL
为避免实现期对“品牌日聚合表”字段理解不一致,至少明确一张完整日聚合表定义:
```sql
CREATE TABLE monitoring_brand_daily (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin',
business_date DATE NOT NULL,
sample_status VARCHAR(20) NOT NULL,
desired_sample_count INT NOT NULL DEFAULT 0,
planned_sample_count INT NOT NULL DEFAULT 0,
actual_sample_count INT NOT NULL DEFAULT 0,
coverage_rate NUMERIC(8,4),
confidence_level VARCHAR(20) NOT NULL,
trigger_source VARCHAR(20),
snapshot_updated_at TIMESTAMPTZ,
mentioned_count INT NOT NULL DEFAULT 0,
top1_mentioned_count INT NOT NULL DEFAULT 0,
first_recommended_count INT NOT NULL DEFAULT 0,
positive_mentioned_count INT NOT NULL DEFAULT 0,
cited_answer_count INT NOT NULL DEFAULT 0,
mention_rate NUMERIC(8,4),
top1_mention_rate NUMERIC(8,4),
first_recommend_rate NUMERIC(8,4),
positive_mention_rate NUMERIC(8,4),
citation_rate NUMERIC(8,4),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_monitoring_brand_daily
UNIQUE (tenant_id, brand_id, collector_type, business_date)
);
CREATE INDEX idx_monitoring_brand_daily_lookup
ON monitoring_brand_daily(tenant_id, brand_id, business_date DESC, collector_type);
```
说明:
1. `overview``brand_time_buckets` 可直接读取本表
2. `desired_sample_count` 用于支撑 3.2 节“目标 60,计划 30”的副文案;不建议每次读接口动态回算
3. 平台矩阵建议使用同风格的 `monitoring_brand_platform_daily`,仅额外加入 `ai_platform_id` 并纳入唯一键
4. `sample_status / trigger_source / snapshot_updated_at` 为页面 tooltip 和手动刷新反馈保留,不建议只在缓存层临时拼装
推荐最小结构:
```sql
CREATE TABLE monitoring_brand_platform_daily (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
brand_id BIGINT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
collector_type VARCHAR(20) NOT NULL DEFAULT 'plugin',
business_date DATE NOT NULL,
platform_sample_status VARCHAR(20) NOT NULL,
planned_sample_count INT NOT NULL DEFAULT 0,
actual_sample_count INT NOT NULL DEFAULT 0,
mention_rate NUMERIC(8,4),
top1_mention_rate NUMERIC(8,4),
last_sampled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_monitoring_brand_platform_daily
UNIQUE (tenant_id, brand_id, ai_platform_id, collector_type, business_date)
);
```
### 12.1 Dashboard composite
`/api/tenant/monitoring/dashboard/composite` 继续保留,但返回结构需扩展以支撑新的四层页面布局:
```json
{
"overview": {
"collection_mode": "plugin",
"mention_rate": 0.42,
"top1_mention_rate": 0.18,
"first_recommend_rate": 0.25,
"positive_mention_rate": 0.67,
"citation_rate": 0.18,
"desired_sample_count": 60,
"planned_sample_count": 30,
"actual_sample_count": 21,
"coverage_rate": 0.7,
"confidence_level": "high",
"last_sampled_at": "2026-04-08T09:20:00Z",
"last_trigger_source": "automatic",
"prev_snapshot_mention_rate": 0.37,
"prev_snapshot_top1_mention_rate": 0.2,
"prev_snapshot_first_recommend_rate": 0.22,
"prev_snapshot_positive_mention_rate": 0.67
},
"platform_breakdown": [
{
"ai_platform_id": "doubao",
"platform_name": "豆包",
"mention_rate": 0.45,
"top1_mention_rate": 0.2,
"platform_sample_status": "sampled",
"actual_sample_count": 8,
"last_sampled_at": "2026-04-08T09:20:00Z"
},
{
"ai_platform_id": "deepseek",
"platform_name": "DeepSeek",
"mention_rate": 0.38,
"top1_mention_rate": 0.15,
"platform_sample_status": "sampled",
"actual_sample_count": 6,
"last_sampled_at": "2026-04-08T08:45:00Z"
},
{
"ai_platform_id": "qwen",
"platform_name": "通义千问",
"mention_rate": null,
"top1_mention_rate": null,
"platform_sample_status": "not_logged_in",
"actual_sample_count": 0,
"last_sampled_at": null
}
],
"brand_time_buckets": [
{
"date": "2026-04-03",
"sample_status": "sampled",
"metric_value": 0.42,
"planned_sample_count": 12,
"actual_sample_count": 8,
"coverage_rate": 0.67,
"trigger_source": "automatic",
"snapshot_updated_at": "2026-04-03T09:12:00Z"
},
{
"date": "2026-04-04",
"sample_status": "unsampled",
"metric_value": null,
"planned_sample_count": 0,
"actual_sample_count": 0,
"coverage_rate": null,
"trigger_source": null,
"snapshot_updated_at": null
}
],
"hot_questions": [
{
"question_id": 42,
"question_hash": "v1:0f3be2f94a4e7b51d9d1f9a6c2208d15",
"question_text": "北京口腔医院推荐?",
"sample_count": 3,
"mention_rate": 0.67,
"last_sampled_at": "2026-04-08T09:15:00Z"
}
],
"citation_ranking": [
{
"ai_platform_id": "deepseek",
"platform_name": "DeepSeek",
"sample_status": "sampled",
"cited_answer_count": 2,
"cited_article_count": 1,
"citation_rate": 0.33
}
],
"cited_articles": [
{
"article_id": 88,
"article_title": "北京口腔医院全面测评",
"publish_platform": "微信公众号",
"citation_count": 3,
"citation_rate": 0.14
}
]
}
```
补充规则:
1. 查询接口必须返回当前 `collection_mode`
2. 首期所有查询固定按 `collector_type = plugin` 过滤
3. `[V6]` 若后续引入模式切换,历史趋势需按模式分段或按模式筛选,不做跨模式拼接
4. `overview` 新增 `prev_snapshot_*` 字段,供前端计算指标卡趋势箭头
5. `platform_breakdown` 始终返回全部已接入平台,不受前端模型筛选器影响
6. `brand_time_buckets` 继续保留,供 mini sparkline 和日指示点渲染
7. `sample_status` 是读侧解释字段,前端只在日指示点 tooltip 中使用,不作为逐日主文案
8. 同一自然日若发生手动催发,接口返回的仍然是一个日桶;只更新 `trigger_source / snapshot_updated_at`
9. `hot_questions``(question_id, question_hash)` 聚合,避免同一 `question_id` 在窗口内因文案变更而混算
10. 前端打开问题详情面板时,应从 `hot_questions` 透传 `question_id + question_hash`
11. `hot_questions``sample_count / mention_rate / last_sampled_at``question_monitor_runs + question_monitor_parse_results` 聚合得出
12. `citation_ranking``cited_articles` 缺数据时返回空数组,不以 `0` 强补
13. `platform_sample_status``question_monitor_runs + monitoring_collect_tasks + monitoring_platform_access_snapshots` 共同判定,而不是只看成功运行记录
### 12.1A 高频问题详情接口
问题详情面板通过独立接口按需加载,不合并到 composite 接口,避免拖慢首屏渲染。
```text
GET /api/tenant/monitoring/brands/{brand_id}/questions/{question_id}/detail
?date_from=2026-04-08
&date_to=2026-04-08
&question_hash=v1:0f3be2f94a4e7b51d9d1f9a6c2208d15
&ai_platform_id=deepseek // 可选,默认返回全部平台数据
```
返回结构示例:
```json
{
"question_id": 42,
"question_hash": "v1:0f3be2f94a4e7b51d9d1f9a6c2208d15",
"question_text": "北京口腔医院推荐?",
"time_window": {
"date_from": "2026-04-08",
"date_to": "2026-04-08"
},
"sample_count": 3,
"platforms": [
{
"ai_platform_id": "deepseek",
"platform_name": "DeepSeek",
"sample_status": "sampled",
"run_id": 1001,
"sampled_at": "2026-04-08T09:15:00Z",
"provider_model": "deepseek-chat",
"answer_text": "根据用户口碑和专业评价,推荐以下几家...",
"brand_mentioned": true,
"brand_mention_position": "top1",
"citations": [
{
"cited_url": "https://www.zhihu.com/question/123",
"cited_title": "北京口腔医院哪家比较好?",
"site_name": "知乎",
"site_key": "zhihu.com",
"favicon_url": "https://zhihu.com/favicon.ico",
"article_id": null,
"resolution_status": "resolved",
"resolution_confidence": "high"
},
{
"cited_url": "https://mp.weixin.qq.com/s/abc123",
"cited_title": "北京口腔医院全面测评",
"site_name": "微信公众号",
"site_key": "weixin.qq.com",
"favicon_url": "https://weixin.qq.com/favicon.ico",
"article_id": 88,
"article_title": "北京口腔医院全面测评",
"resolution_status": "resolved",
"resolution_confidence": "high"
}
]
},
{
"ai_platform_id": "qwen",
"platform_name": "通义千问",
"sample_status": "not_logged_in",
"run_id": null,
"sampled_at": null,
"provider_model": null,
"answer_text": null,
"brand_mentioned": null,
"citations": []
}
],
"citation_analysis": [
{
"ai_platform_id": "deepseek",
"platform_name": "DeepSeek",
"citation_count": 4,
"distinct_site_count": 3,
"brand_article_citation_count": 1,
"has_brand_article_citation": true
}
],
"content_citations": [
{
"article_id": 88,
"article_title": "北京口腔医院全面测评",
"publish_platform": "微信公众号",
"ai_platform_id": "deepseek",
"platform_name": "DeepSeek",
"citation_count": 1,
"citation_rate": 0.33
}
]
}
```
接口说明:
1. `platforms` 数组始终返回全部已接入平台,未采样平台的 `answer_text / citations` 为空
2. 若请求时携带 `ai_platform_id` 参数,`platforms` 数组只返回指定平台的数据
3. `citation_analysis` 为跨平台引用汇总,不受 `ai_platform_id` 参数过滤,始终展示全平台聚合
4. `content_citations``ai_platform_id` 参数过滤;未传时返回全部平台的内容引用记录
5. 该接口主读 `question_monitor_runs``question_monitor_parse_results``monitoring_citation_facts``monitoring_collect_tasks``monitoring_platform_access_snapshots``site_domain_mappings``monitoring_article_url_aliases`
6. `brand_mention_position``top1 | mentioned | not_mentioned | null`
7. `question_hash` 由外层 `hot_questions` 透传,用于锁定同一问题文案版本;若省略,后端只允许解析为窗口内最新版本,不保证历史版本一致性
8. `favicon_url``site_key``registrable_domain` 在读侧运行时派生,不要求持久化到 Monitoring PG
9. `content_citations.citation_rate` 的分母固定为同一 `question_id + question_hash` 在当前时间窗口内的成功采样次数
10. `article_title / publish_platform` 优先读取 `monitoring_article_url_aliases` 中的快照字段;若缺失,只允许按 `article_id / publish_record_id` 执行单次批量 `IN` 查询并配合 `TTL = 5 分钟` 本地缓存,禁止按引用逐条回源主业务 PG
### 12.2 缓存策略
读侧缓存策略可继续沿用 V3
- composite 聚合缓存
- singleflight
- TTL 抖动
- stale-while-revalidate
但缓存失效触发点改为:
1. 每日聚合完成
2. 用户切换品牌 / 时间窗口
3. 手动采集触发的局部增量聚合完成
不需要为单条采样结果做准实时逐条失效。
### 12.3 聚合补算策略
为避免 06:30 后仍有 `received` / `completed` 结果陆续入库导致统计偏差,V5.9 采用“双阶段聚合”:
1. `06:30` 产出首版日聚合
2. `08:00` 对当日和前一日做一次增量修正
`08:00` 后仍有迟到结果:
1. 更新原始表与事实表
2. 在下一次修正窗口内补算
3. 不要求逐条触发读侧实时失效
手动触发例外:
1.`collect-now` 触发的任务产生新的成功结果后,worker 额外投递一条 `monitor.aggregate.trigger`
2. 聚合消费者按 `(tenant_id, brand_id, business_date)` 做一次局部增量重算,覆盖 `monitoring_brand_daily`、高频问题列表所需汇总以及问题详情相关引用汇总
3. 局部聚合成功后,主动失效该品牌当日的 `dashboard/composite``questions/{question_id}/detail` 缓存
4. 该路径仅服务“手动采集后的用户可见刷新”,目标 SLA 为 `<= 1 分钟`,不把全链路改造成逐条准实时聚合
---
## 13. 降级、告警与风险
### 13.1 降级策略
| 场景 | V5 行为 |
| ------------------------- | ---------------------------------- |
| 插件模式下插件 24h 未活跃 | 暂停生成新任务,页面展示上次数据 |
| 登录必需平台未登录 | 该平台不进入当天计划样本 |
| 当日覆盖率低 | 页面提示“样本不足,仅供参考” |
| 某平台 adapter 失效 | 该平台展示“暂不可用”,其余平台继续 |
| URL 无法归因 | 计入 unresolved,不强行挂到文章 |
### 13.2 关键告警
| 指标 | 阈值 |
| ------------------- | -------------- |
| 主插件离线时长 | > 24h |
| 日覆盖率 | < 40% |
| 单平台连续失败 | > 20 次 / 天 |
| unresolved 引用占比 | > 30% |
| 每日聚合延迟 | > 08:00 未完成 |
| projector 无心跳 | > 1 分钟 |
| outbox 投影延迟 | > 2 分钟 |
| outbox 堆积量 | > 100 持续 10 分钟 |
### 13.3 Token 生命周期与回调安全
`X-Geo-Installation-Token` 需要完整生命周期管理。
建议:
1. 安装注册时签发长期 token,仅存 hash
2. 支持手动吊销与重新签发
3. 插件结果回调除安装 token 外,仍要求携带短期 `lease_token`
4. `task_id + lease_token + collector_type` 共同校验,防止 replay attack
5. 任务进入 `received / completed` 后,重复结果直接按幂等处理,不重复计数
### 13.4 可观测性建设
V5.9 需要最小可运维基线,而不只是阈值表。
建议输出:
1. Prometheus metrics
- task lease latency
- queue depth
- worker consume lag
- projector heartbeat
- outbox pending count
- outbox projection lag
- outbox replay count
- projector reconcile drift
- parse success rate
- unresolved citation ratio
- per-mode coverage rate
2. 结构化日志:
- task_id
- tenant_id
- collector_type
- ai_platform_id
- provider_request_id
3. 链路追踪:
- lease -> execute -> ingest -> parse -> aggregate
### 13.5 风险接受说明
V5.9 明确接受以下风险:
1. 单日样本偏少导致波动大
2. 插件离线导致当天无新数据
3. 部分 URL 只能低置信度归因
4. 调度窗口前 `24h` 内曾在线但次日实际未再上线,导致当日任务自然过期;首期接受该浪费,不做离线补数
但不接受以下风险:
1. 任务重复执行导致统计翻倍
2. 输入口径变更污染历史数据
3. 未授权回调写入虚假监测结果
---
## 14. 实施计划
### 14.1 Phase 分期
| Phase | 内容 | 估时 |
| ------------ | --------------------------------------------------------------------------------------------------------- | ------------ |
| A | 双库基建:Monitoring PG 实例、双连接池、`monitoring_database` 配置、双 sqlc codegen、主业务/监测库 schema | 4-5 天 |
| B | 后台基建:RabbitMQ、publisher/consumer 骨架、DLQ、outbox polling projector | 4 天 |
| C | 插件:在现有扩展框架内新建 AI 监测 adapter 模块、3 平台、租约轮询、结果回传 | 10-12 天 |
| D | 后端:租约 API、监测调度 cron、结果回调、MQ 消费、解析入库、日聚合 | 5-6 天 |
| E | 前端:数据追踪页补覆盖率、更新时间、置信度提示、每日单快照脉冲视图 | 3 天 |
| F | 可观测性:metrics、日志、告警、DLQ 运维脚本 | 3 天 |
| G | 联调、坏样本验证、口径验收 | 4 天 |
| **基线合计** | | **33-37 天** |
建议:
1. Phase A 需拆成 `A1 Monitoring PG + docker-compose``A2 双连接池 + 双 sqlc``A3 schema / outbox / snapshot 表`
2. Outbox DDL 归 Phase Aoutbox polling projector 归 Phase B`collection_mode` 切换状态机延后到 `[V6]`
3. Phase C 建议拆成 `C1 框架 + 1 平台 + 结果回传``C2 +2 平台 + 登录态/风控收尾` 两个子阶段,以降低 DOM 适配风险
4. `33-37 天` 是按“首期只做 plugin 模式”校准后的**基线工期**
5. 若按 20% buffer 对外承诺,建议按 **40-44 天** 评估
### 14.2 验收标准
1. 页面可展示 `planned_sample_count / actual_sample_count / coverage_rate`
2. 页面可正常展示离散采样时序视图、高频问题、引用排行
3. 当覆盖率不足时,页面有明确提示
4. 同一任务不会被重复计入统计
5. 问题文案修改后,历史趋势不被污染
6. 引用文章数可通过 URL alias 规则稳定归因,无法归因时进入 unresolved
7. 首期所有读侧查询固定按 `collector_type = plugin` 过滤,为 `[V6]` 扩展保留模式边界
8. MQ、DLQ、worker lag、覆盖率等关键指标可观测
9. 未采样日期不会被渲染为 0 值折线点
10. 数据详情页支持“立即采集”,但同一自然日不会新增第二个展示点位
11. 前端不会把 `sampled / partial / unsampled` 直接逐日渲染为主文案
---
## 15. V4 → V5 变更摘要
| 编号 | V4 | V5 |
| ---- | ------------------------ | ------------------------------------------------------------------- |
| C1 | 全量监测叙事 | 采样趋势叙事 |
| C2 | Free 1 品牌 / 40 问题 | Free 1 品牌,尽量覆盖全部启用问题,受执行预算限制 |
| C3 | Pro 3 品牌 / 40 问题 | Pro 3 品牌,尽量覆盖全部启用问题,受执行预算限制 |
| C4 | GET + claim 两步任务协议 | 一步租约式任务协议 |
| C5 | `question_version_id` | `question_hash` 负责任务快照锁定,`question_text_snapshot` 负责运行快照 |
| C6 | RabbitMQ + 增量聚合 | RabbitMQ 仅做结果异步化,不参与任务分发或前端准实时 |
| C7 | 准实时可见 | T+1 / 延迟可接受 |
| C8 | 仅展示百分比 | 百分比 + 采样覆盖信息 |
| C9 | 首期 6 平台叙事 | 首期只承诺 3 平台 |
| C10 | 单一业务库承载全部数据 | 主业务 PG + Monitoring PG 双库隔离 |
| C11 | 模式切换未定义 | `collection_mode` 状态机 + grace period |
| C12 | MQ 仅概念化引入 | durable + quorum + publisher confirm + DLQ 运维流程 |
| C13 | 未定义分区与保留 | Monitoring PG 按 `business_date` 分区 + 生命周期策略 |
| C14 | 告警阈值表 | 告警 + metrics + tracing + 结构化日志 |
## 16. 最终建议
V5 适合当前阶段的原因不是“它最强”,而是“它和当前约束一致”:
1. 和单实例插件现实相符
2. 和 PRD 的 V1 边界相符
3. 和用户对“趋势参考”的接受度相符
如果未来要升级到 V6,再考虑:
1. 专用在线采集节点
2. 企业版更高采样量
3. 更细粒度的增量聚合和近实时刷新
4. 更强的 URL 归因与多次重复采样校准