feat: Introduce AI Brand Monitoring System V5 technical design document

- Added comprehensive technical design document for AI Brand Monitoring System V5, outlining system architecture, data models, sampling strategies, and monitoring protocols.
- Key changes include a shift to a sampling-based trend monitoring approach, updated data collection and storage strategies, and new metrics for performance evaluation.
- Implemented migration scripts to support the flattening of brand questions and versioning of question texts, ensuring historical data integrity and version control.
This commit is contained in:
2026-04-09 14:43:20 +08:00
parent 41f8e0621e
commit 36451a613d
18 changed files with 2709 additions and 234 deletions
File diff suppressed because it is too large Load Diff
+960
View File
@@ -0,0 +1,960 @@
# AI 品牌曝光监测系统技术方案 V5
## 1. 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V5(采样型趋势监测 + 异步解耦) |
| 文档版本 | V5.1 |
| 文档状态 | 待评审 |
| 创建日期 | 2026-04-08 |
| 基线文档 | `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. **单实例插件 + 非全天在线设备**前提下,只承诺趋势参考,不承诺全量覆盖。
3. **页面百分比全部按实际采样成功样本计算**,不再暗示“全量真实占比”。
4. **页面必须展示采样覆盖信息**,包括计划采样数、实际采样数、覆盖率、最近更新时间。
5. **引入 RabbitMQ 和独立 Monitoring PostgreSQL 隔离写链路**,但聚合仍以日批为主,不承诺准实时展示。
### 2.1 相比 V4 的根本变化
| 维度 | V4 | V5 |
| --- | --- | --- |
| 产品定位 | 全量品牌曝光监测 | 采样型趋势参考 |
| 采集目标 | 尽量覆盖全部问题 × 全平台 | 每日仅覆盖活跃监测题池 |
| 插件节点假设 | 大量用户端节点可互补 | 单租户单主插件实例 |
| 数据时效 | 准实时 + T+1 | T+1 为主,允许延迟 |
| 数据精度承诺 | 接近真实全量 | 趋势可参考,精度受覆盖率影响 |
| 任务处理 | PG + Redis 锁 + RabbitMQ | Monitoring PG 任务表 + PG 租约状态机 + RabbitMQ 结果异步化 |
| 存储边界 | 单业务库承载配置与监测结果 | 主业务 PG 存配置,Monitoring PG 存监测数据 |
| 页面表达 | 指标卡直接展示百分比 | 指标卡 + 采样覆盖信息 + 置信度提示 |
### 2.2 非目标
V5 明确不做以下承诺:
1. 不承诺所有问题每天都有结果。
2. 不承诺所有 AI 平台每天都有样本。
3. 不承诺分钟级实时更新。
4. 不承诺百分比可用于财务、法务、投放结算或精确 KPI 审计。
---
## 3. 产品口径与页面表达
### 3.1 页面定位
数据追踪页展示的是:
- 在“当前监测题池”上的 AI 曝光趋势
- 在“实际采样成功样本”上的品牌表现
- 在“可归因引用样本”上的文章引用趋势
不是:
- 品牌在全网 AI 场景中的绝对真实份额
- 所有问题、所有平台、所有时间点的完整监控
### 3.2 页面必须新增的展示项
为避免误导,数据追踪页和 Dashboard composite 接口必须返回并展示:
1. `planned_sample_count`:计划采样数
2. `actual_sample_count`:实际采样成功数
3. `coverage_rate`:覆盖率 = `actual / planned`
4. `last_sampled_at`:最近成功采样时间
5. `confidence_level``high / medium / low`
推荐前端呈现方式:
- 顶部右侧信息条:`采样模式`
- 趋势图上方副文案:`已采样 18/30,覆盖率 60%,更新于 04-08 09:20`
- 覆盖率过低时展示 `样本不足,仅供参考`
### 3.3 低覆盖场景的展示规则
| 覆盖率 | 置信度 | 页面表现 |
| --- | --- | --- |
| >= 70% | high | 正常展示百分比和趋势 |
| 40% - 69% | medium | 展示百分比,并提示“样本偏少” |
| < 40% | low | 保留原始计数,百分比弱化展示或标记“仅供参考” |
`actual_sample_count < 5` 时:
1. 不展示“精确到整数”的百分比变化解释。
2. 趋势线可以显示,但需打低置信度标记。
3. 引用排行允许为空,不用补零。
---
## 4. 采样配额与容量模型
### 4.1 套餐定义
V5 中配额控制的不是“题库总量”,而是“每日活跃监测问题数”。
| 配额项 | Free | Pro | Enterprise |
| --- | --- | --- | --- |
| 最大品牌数 | 1 | 3 | 自定义 |
| 题库总量 | 不限于监测能力,只做配置资产 | 不限于监测能力,只做配置资产 | 自定义 |
| **每日活跃监测问题数** | **3-5** | **5-10** | 自定义 |
| 采集频率 | 每天 | 每天 | 每天 / 自定义 |
| 平台范围 | 当前已接入且已登录的平台 | 当前已接入且已登录的平台 | 同左 |
| 数据承诺 | 趋势参考 | 趋势参考 | 趋势参考 / 专属增强 |
### 4.2 活跃监测题池
系统将问题分成两层:
1. **问题题库**:品牌下维护的全部问题,仅做配置和运营资产。
2. **活跃监测题池**:每日实际参与采样的问题集合,数量受套餐限制。
推荐实现:
- `brand_questions` 保留全部题库
- 新增 `monitor_priority``monitor_enabled`
- 调度器每日只选 Top N 问题进入任务生成
### 4.3 单实例前提下的目标任务量
按首批 6 个平台测算:
| 套餐 | 活跃问题数 | 平台数 | 目标计划任务/天 |
| --- | --- | --- | --- |
| Free | 3-5 | 6 | 18-30 |
| Pro | 5-10 | 6 | 30-60 |
说明:
1. 这是 **计划采样量**,不是保证完成量。
2. 实际完成量取决于:
- 主监测插件是否在线
- 插件机器当日在线时长
- 对应 AI 平台是否已登录
- 平台风控和采集成功率
### 4.4 核心调度原则
V5 的调度器不追求“补齐所有漏采任务”,而追求“稳定输出可比较样本”。
因此:
1. 每天只生成当天计划任务,不补历史大积压。
2. 超过 48 小时未完成的任务自动过期,不回补到新一天的分母中。
3. 读侧按 `planned_sample_count``actual_sample_count` 共同解释结果。
### 4.5 主插件在线约束
单租户只指定 1 个 `primary_monitoring_installation` 参与自动监测。
调度前先判断该插件是否活跃:
- `last_seen_at <= 24h`:生成当天任务
- `last_seen_at > 24h`:不生成新任务,页面沿用最近一次成功数据并提示“近期无新采样”
这条规则的目的:
1. 防止设备长期离线时任务无限堆积
2. 防止“未执行任务”污染覆盖率统计
3. 让系统行为和单实例现实约束一致
---
## 5. 数据口径定义
### 5.1 顶部指标口径
所有百分比的分母统一为 **实际采样成功回答数**,不是计划任务数,也不是题库总数。
| 指标 | 定义 |
| --- | --- |
| 提及率 | `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 趋势图口径
趋势图展示的是按天聚合后的样本表现,不是平台真实总体份额。
每个点必须同时具备:
- `metric_value`
- `actual_sample_count`
- `coverage_rate`
可选增强:
- 默认展示 7 天滚动均值
- 用户切换后可看原始日值
### 5.3 高频问题口径
高频问题区不再解释为“全站高频触发问题”,而是:
> 在当前时间窗口内,被实际采样成功次数最多的问题
推荐展示字段:
- 问题文案
- 成功采样次数
- 提及率
- 最近采样时间
### 5.4 引用排行口径
引用排行按模型平台展示:
- 有过可归因引用的回答数
- 引用文章数
- 引用率
若平台当日无样本或平台未登录:
- 展示为 `未采样`
- 不强行按 `0` 并入分母
---
## 6. 数据模型修订
### 6.1 双 PostgreSQL 存储边界
V5.1 将数据按“业务配置”和“监测结果”拆到两个 PostgreSQL
**主业务 PostgreSQL**
- `tenants`
- `brands`
- `brand_keywords`
- `brand_questions`
- `articles`
- `publish_records`
- `plugin_installations`
- `tenant_monitoring_quotas`
**Monitoring PostgreSQL**
- `monitoring_collect_tasks`
- `question_monitor_runs`
- `question_monitor_parse_results`
- `monitoring_citation_facts`
- `monitoring_article_url_aliases`
- `monitoring_*_daily`
设计原则:
1. 不做跨库物理外键,只保留逻辑 ID 引用。
2. 监测链路的高频写入、重试、归档不影响主业务 PG。
3. 读侧监测查询优先从 Monitoring PG 获取。
### 6.2 `question_version_id` 移除后的替代方案
V5 接受原系统不再维护 `question_version_id`,但仍然保留“输入快照不可变”原则。
替代方案:
1. 在任务表和运行表写入 `question_text_snapshot`
2. 同时写入 `question_hash`
3. 历史汇总按 `question_hash` 区分口径
这样即使 `question_id` 不变、文案发生修改,历史数据也不会被新文案污染。
### 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,
daily_active_question_limit INT NOT NULL DEFAULT 5,
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`daily_active_question_limit = 5``task_daily_hard_cap = 36`
- Pro`daily_active_question_limit = 10``task_daily_hard_cap = 72`
说明:
1. `tenant_monitoring_quotas` 放在主业务 PostgreSQL。
2. `primary_installation_id` 继续引用主业务库中的 `plugin_installations`
### 6.4 `monitoring_collect_tasks`
V5.1 使用 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_text_snapshot TEXT NOT NULL,
question_hash VARCHAR(64) NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
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_installation BIGINT,
leased_at TIMESTAMPTZ,
lease_expires_at TIMESTAMPTZ,
callback_received_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
skip_reason VARCHAR(50),
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, question_hash, ai_platform_id, 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. 任务租约状态在 Monitoring PG 中维护。
2. `received` 表示 API 已接收插件结果并成功投递 MQ,但 worker 尚未完成最终入库。
### 6.5 `question_monitor_runs`
在 V5 中至少补充:
```sql
ALTER TABLE question_monitor_runs
ADD COLUMN IF NOT EXISTS tenant_id BIGINT,
ADD COLUMN IF NOT EXISTS business_date DATE,
ADD COLUMN IF NOT EXISTS run_mode VARCHAR(30),
ADD COLUMN IF NOT EXISTS question_text_snapshot TEXT,
ADD COLUMN IF NOT EXISTS question_hash VARCHAR(64),
ADD COLUMN IF NOT EXISTS provider_model VARCHAR(100),
ADD COLUMN IF NOT EXISTS request_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS raw_answer_storage_key TEXT;
```
建议幂等键:
```sql
CREATE UNIQUE INDEX uk_monitor_run_idempotent
ON question_monitor_runs(tenant_id, brand_id, question_id, question_hash, ai_platform_id, run_mode, business_date);
```
建议:
1. `question_monitor_runs` 落在 Monitoring PostgreSQL。
2. 若原始回答文本不超过阈值,可直接存库;超大原文再转对象存储。
### 6.6 文章 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),
external_article_id VARCHAR(128),
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()
);
CREATE UNIQUE INDEX uk_article_url_alias
ON monitoring_article_url_aliases(tenant_id, normalized_url);
```
---
## 7. 解析标准化与 SoV 统计模型
### 7.1 解析分层
V5.1 的解析分为 3 层:
1. **回答层**:品牌提及、首位提及、首选推荐、情感倾向
2. **引用层**:从回答、citation JSON、search_results 中提取 URL 事实
3. **来源层**:基于域名清洗和站点聚合生成 SoV 统计结果
### 7.2 基于 `tldextract` 的顶级域名清洗
为避免 `www`、二级域名、国家后缀等问题导致同一站点重复计数,引用归因与来源排行统一采用 **Public Suffix List + `tldextract`** 口径。
处理目标:
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,
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);
```
约定:
1. `normalized_url` 用于页面级去重和文章归因
2. `registrable_domain` 用于站点级来源排行
3. `site_key` 默认等于 `registrable_domain`
4. 如业务需要区分站群,可对特定域名做覆写映射
### 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,
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()
);
```
### 7.5 SoV 统计模型
V5.1 中的 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/tasks/lease
POST /api/plugin/monitoring/tasks/{id}/result
POST /api/plugin/monitoring/tasks/{id}/skip
```
请求头:
- `X-Geo-Installation-Token`
- `X-Geo-Installation-Id`
### 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'`
### 8.4 插件执行策略
V5 继续复用现有插件基础设施:
- [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)
但采样策略改为“小批量、固定预算”:
| 项目 | 建议值 |
| --- | --- |
| 心跳频率 | 15 分钟 |
| 单次租约任务数 | 2-3 |
| 单任务超时 | 60 秒 |
| 租约时长 | 15 分钟 |
| 每日硬上限 | 按套餐 36 / 72 |
### 8.5 任务过期与回收
定时任务每 30 分钟处理一次:
1. `lease_expires_at < now()` 且状态仍为 `leased` 的任务改为 `expired`
2. `received` 状态超过阈值未被 worker 完成时,进入异常告警
3. 同一 `business_date``expired` 任务最多重试 1 次
4. 超过 `business_date + 2 days` 的未完成任务不再重试
---
## 9. 采集、解析、聚合架构
### 9.1 首期简化版链路
```text
每日 00:10 调度器生成当天任务
插件后台 15 分钟心跳租约任务
插件执行提问并回传结果
tenant-api:校验租约 -> 标记 received -> 投递 RabbitMQ
Monitor Worker:消费结果 -> 写 Monitoring PG -> 解析结果 -> 完成任务
每日 06:30 聚合 Job 汇总到 monitoring_*_daily
读侧 API + Redis 缓存
```
### 9.2 RabbitMQ 的职责边界
V5.1 中 RabbitMQ 只承担“结果异步化”,不承担“任务分发”。
使用 RabbitMQ 的原因:
1. 插件回调接口可以快速返回,降低超时和重试风险
2. 解析、引用归因、写库失败不会直接影响插件侧体验
3. 方便把高频写入隔离到独立 worker
4. 后续若企业版放大量级,不需要重做写链路
但 RabbitMQ 不参与以下事项:
1. 不做任务租约
2. 不做读侧查询
3. 不强制引入准实时前端刷新
推荐队列:
- `monitor.result.ingest`
- `monitor.result.dlq`
- `monitor.aggregate.trigger`
### 9.3 Monitoring PostgreSQL 的职责
独立 Monitoring PG 的目的不是“多一套库”,而是隔离监测链路的写放大。
承载内容:
1. 任务表
2. 原始回答
3. 解析结果
4. 引用事实
5. 日聚合结果
收益:
1. 主业务 PG 不承受高频插入和大文本更新
2. Monitoring PG 可独立做分区、归档、VACUUM 策略
3. 监测表膨胀不会影响文章、品牌、发布主链路
### 9.4 原始回答存储
V5.1 中抓取结果优先进入 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 页面与套餐表达
页面和套餐都必须按“当前已接入且已登录的平台”解释:
- 已接入且已登录:进入采样计划
- 已接入未登录:展示 `未登录`
- 未接入:展示 `未接入`
不得再用“全 6 平台稳定可用”作为首期对外承诺。
---
## 12. 读侧与汇总表
V5 读侧继续沿用 V3 的聚合表和缓存思路,但口径增加采样字段:
1. `planned_sample_count`
2. `actual_sample_count`
3. `coverage_rate`
4. `confidence_level`
推荐所有 `monitoring_*_daily` 汇总表统一增加以上字段。
### 12.1 Dashboard composite
`/api/tenant/monitoring/dashboard/composite` 继续保留,但返回结构需扩展:
```json
{
"overview": {
"mention_rate": 0.42,
"citation_rate": 0.18,
"planned_sample_count": 30,
"actual_sample_count": 21,
"coverage_rate": 0.70,
"confidence_level": "high",
"last_sampled_at": "2026-04-08T09:20:00Z"
}
}
```
### 12.2 缓存策略
读侧缓存策略可继续沿用 V3
- composite 聚合缓存
- singleflight
- TTL 抖动
- stale-while-revalidate
但缓存失效触发点改为:
1. 每日聚合完成
2. 用户切换品牌 / 时间窗口
不需要为单条采样结果做准实时逐条失效。
---
## 13. 降级、告警与风险
### 13.1 降级策略
| 场景 | V5 行为 |
| --- | --- |
| 插件 24h 未活跃 | 暂停生成新任务,页面展示上次数据 |
| 平台未登录 | 该平台不进入当天计划样本 |
| 当日覆盖率低 | 页面提示“样本不足,仅供参考” |
| 某平台 adapter 失效 | 该平台展示“暂不可用”,其余平台继续 |
| URL 无法归因 | 计入 unresolved,不强行挂到文章 |
### 13.2 关键告警
| 指标 | 阈值 |
| --- | --- |
| 主插件离线时长 | > 24h |
| 日覆盖率 | < 40% |
| 单平台连续失败 | > 20 次 / 天 |
| unresolved 引用占比 | > 30% |
| 每日聚合延迟 | > 08:00 未完成 |
### 13.3 风险接受说明
V5 明确接受以下风险:
1. 单日样本偏少导致波动大
2. 插件离线导致当天无新数据
3. 部分 URL 只能低置信度归因
但不接受以下风险:
1. 任务重复执行导致统计翻倍
2. 输入口径变更污染历史数据
3. 未授权回调写入虚假监测结果
---
## 14. 实施计划
### 14.1 Phase 分期
| Phase | 内容 | 估时 |
| --- | --- | --- |
| A | 双库方案落地:主业务 PG / Monitoring PG schema 与连接配置 | 4 天 |
| B | RabbitMQ 基础设施 + Monitor Worker 骨架 + DLQ | 4 天 |
| C | 插件:AI adapter 框架 + 3 平台 + 租约式任务执行 | 6 天 |
| D | 后端:租约 API、结果回调、MQ 消费、解析入库、日聚合 | 7 天 |
| E | 前端:数据追踪页补覆盖率、更新时间、置信度提示 | 4 天 |
| F | 联调、坏样本验证、口径验收 | 4 天 |
| **合计** | | **29 天** |
### 14.2 验收标准
1. 页面可展示 `planned_sample_count / actual_sample_count / coverage_rate`
2. 页面可正常展示趋势图、高频问题、引用排行
3. 当覆盖率不足时,页面有明确提示
4. 同一任务不会被重复计入统计
5. 问题文案修改后,历史趋势不被污染
6. 引用文章数可通过 URL alias 规则稳定归因,无法归因时进入 unresolved
---
## 15. V4 → V5 变更摘要
| 编号 | V4 | V5 |
| --- | --- | --- |
| C1 | 全量监测叙事 | 采样趋势叙事 |
| C2 | Free 1 品牌 / 40 问题 | Free 3-5 个每日活跃问题 |
| C3 | Pro 3 品牌 / 40 问题 | Pro 5-10 个每日活跃问题 |
| 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 双库隔离 |
## 16. 最终建议
V5 适合当前阶段的原因不是“它最强”,而是“它和当前约束一致”:
1. 和单实例插件现实相符
2. 和 PRD 的 V1 边界相符
3. 和用户对“趋势参考”的接受度相符
如果未来要升级到 V6,再考虑:
1. 专用在线采集节点
2. 企业版更高采样量
3. 更细粒度的增量聚合和近实时刷新
4. 更强的 URL 归因与多次重复采样校准
+1 -3
View File
@@ -516,9 +516,7 @@ export interface Question {
id: number;
brand_id: number;
keyword_id: number;
current_version_id: number | null;
question_text: string | null;
version_no: number | null;
question_text: string;
status: string;
created_at: string;
}
+33 -74
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
@@ -276,7 +275,7 @@ func (s *BrandService) DeleteKeyword(ctx context.Context, brandID, keywordID int
return nil
}
// --- Question CRUD with versioning ---
// --- Question CRUD ---
type QuestionRequest struct {
KeywordID int64 `json:"keyword_id" binding:"required"`
@@ -284,23 +283,19 @@ type QuestionRequest struct {
}
type QuestionResponse struct {
ID int64 `json:"id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
CurrentVersionID *int64 `json:"current_version_id"`
QuestionText *string `json:"question_text"`
VersionNo *int `json:"version_no"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
QuestionText string `json:"question_text"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
actor := auth.MustActor(ctx)
query := `
SELECT q.id, q.brand_id, q.keyword_id, q.current_version_id, q.status, q.created_at,
v.question_text, v.version_no
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q
LEFT JOIN brand_question_versions v ON v.id = q.current_version_id
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
args := []interface{}{brandID, actor.TenantID}
@@ -320,7 +315,7 @@ func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keyword
for rows.Next() {
var q QuestionResponse
var ca interface{}
if err := rows.Scan(&q.ID, &q.BrandID, &q.KeywordID, &q.CurrentVersionID, &q.Status, &ca, &q.QuestionText, &q.VersionNo); err != nil {
if err := rows.Scan(&q.ID, &q.BrandID, &q.KeywordID, &q.QuestionText, &q.Status, &ca); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
q.CreatedAt = fmt.Sprintf("%v", ca)
@@ -334,42 +329,29 @@ func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keyword
func (s *BrandService) CreateQuestion(ctx context.Context, brandID int64, req QuestionRequest) (*QuestionResponse, error) {
actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
req.QuestionText = strings.TrimSpace(req.QuestionText)
if req.QuestionText == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "question_text is required")
}
defer func() {
_ = tx.Rollback(ctx)
}()
var questionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, status) VALUES ($1, $2, $3, 'active') RETURNING id
`, actor.TenantID, brandID, req.KeywordID).Scan(&questionID)
var ca interface{}
err := s.pool.QueryRow(ctx, `
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id, created_at
`, actor.TenantID, brandID, req.KeywordID, req.QuestionText).Scan(&questionID, &ca)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to create question")
}
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.QuestionText)))
var versionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, 1, true) RETURNING id
`, questionID, req.QuestionText, hash).Scan(&versionID)
if err != nil {
return nil, response.ErrInternal(50010, "create_version_failed", "failed to create question version")
}
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET current_version_id = $1 WHERE id = $2`, versionID, questionID)
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
vno := 1
return &QuestionResponse{
ID: questionID, BrandID: brandID, KeywordID: req.KeywordID,
CurrentVersionID: &versionID, QuestionText: &req.QuestionText, VersionNo: &vno, Status: "active",
ID: questionID,
BrandID: brandID,
KeywordID: req.KeywordID,
QuestionText: req.QuestionText,
Status: "active",
CreatedAt: fmt.Sprintf("%v", ca),
}, nil
}
@@ -379,42 +361,19 @@ type UpdateQuestionRequest struct {
func (s *BrandService) UpdateQuestion(ctx context.Context, brandID, questionID int64, req UpdateQuestionRequest) error {
actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
req.QuestionText = strings.TrimSpace(req.QuestionText)
if req.QuestionText == "" {
return response.ErrBadRequest(40001, "invalid_params", "question_text is required")
}
defer func() {
_ = tx.Rollback(ctx)
}()
// Verify ownership
var exists bool
_ = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM brand_questions WHERE id = $1 AND brand_id = $2 AND tenant_id = $3 AND deleted_at IS NULL)`,
questionID, brandID, actor.TenantID).Scan(&exists)
if !exists {
tag, err := s.pool.Exec(ctx, `
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`, req.QuestionText, questionID, brandID, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40422, "question_not_found", "question not found")
}
// Deactivate current version
_, _ = tx.Exec(ctx, `UPDATE brand_question_versions SET is_active = false WHERE question_id = $1 AND is_active = true`, questionID)
// Get next version number
var maxVersion int
_ = tx.QueryRow(ctx, `SELECT COALESCE(MAX(version_no), 0) FROM brand_question_versions WHERE question_id = $1`, questionID).Scan(&maxVersion)
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.QuestionText)))
var versionID int64
err = tx.QueryRow(ctx, `
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id
`, questionID, req.QuestionText, hash, maxVersion+1).Scan(&versionID)
if err != nil {
return fmt.Errorf("create version: %w", err)
}
_, _ = tx.Exec(ctx, `UPDATE brand_questions SET current_version_id = $1, updated_at = NOW() WHERE id = $2`, versionID, questionID)
return tx.Commit(ctx)
return nil
}
func (s *BrandService) DeleteQuestion(ctx context.Context, brandID, questionID int64) error {
@@ -3,21 +3,11 @@ package domain
import "time"
type BrandQuestion struct {
ID int64
TenantID int64
BrandID int64
KeywordID int64
CurrentVersionID *int64
Status string
CreatedAt time.Time
}
type BrandQuestionVersion struct {
ID int64
QuestionID int64
TenantID int64
BrandID int64
KeywordID int64
QuestionText string
QuestionHash string
VersionNo int
IsActive bool
Status string
CreatedAt time.Time
}
@@ -20,6 +20,22 @@ func nullableText(value pgtype.Text) *string {
return &text
}
func nullableAnyText(value interface{}) *string {
switch typed := value.(type) {
case nil:
return nil
case string:
return &typed
case []byte:
text := string(typed)
return &text
case pgtype.Text:
return nullableText(typed)
default:
return nil
}
}
func nullableInt64(value pgtype.Int8) *int64 {
if !value.Valid {
return nil
@@ -94,59 +94,30 @@ func (q *Queries) CreateKeyword(ctx context.Context, arg CreateKeywordParams) (C
}
const createQuestion = `-- name: CreateQuestion :one
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, status)
VALUES ($1, $2, $3, 'active')
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id
`
type CreateQuestionParams struct {
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
QuestionText string `json:"question_text"`
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (int64, error) {
row := q.db.QueryRow(ctx, createQuestion, arg.TenantID, arg.BrandID, arg.KeywordID)
var id int64
err := row.Scan(&id)
return id, err
}
const createQuestionVersion = `-- name: CreateQuestionVersion :one
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES ($1, $2, $3, $4, true)
RETURNING id
`
type CreateQuestionVersionParams struct {
QuestionID int64 `json:"question_id"`
QuestionText string `json:"question_text"`
QuestionHash string `json:"question_hash"`
VersionNo int32 `json:"version_no"`
}
func (q *Queries) CreateQuestionVersion(ctx context.Context, arg CreateQuestionVersionParams) (int64, error) {
row := q.db.QueryRow(ctx, createQuestionVersion,
arg.QuestionID,
row := q.db.QueryRow(ctx, createQuestion,
arg.TenantID,
arg.BrandID,
arg.KeywordID,
arg.QuestionText,
arg.QuestionHash,
arg.VersionNo,
)
var id int64
err := row.Scan(&id)
return id, err
}
const deactivateQuestionVersion = `-- name: DeactivateQuestionVersion :exec
UPDATE brand_question_versions SET is_active = false
WHERE question_id = $1 AND is_active = true
`
func (q *Queries) DeactivateQuestionVersion(ctx context.Context, questionID int64) error {
_, err := q.db.Exec(ctx, deactivateQuestionVersion, questionID)
return err
}
const getBrandByID = `-- name: GetBrandByID :one
SELECT id, tenant_id, name, description, status, created_at, updated_at
FROM brands
@@ -183,19 +154,6 @@ func (q *Queries) GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (Get
return i, err
}
const getLatestQuestionVersionNo = `-- name: GetLatestQuestionVersionNo :one
SELECT COALESCE(MAX(version_no), 0)::INT AS max_version
FROM brand_question_versions
WHERE question_id = $1
`
func (q *Queries) GetLatestQuestionVersionNo(ctx context.Context, questionID int64) (int32, error) {
row := q.db.QueryRow(ctx, getLatestQuestionVersionNo, questionID)
var max_version int32
err := row.Scan(&max_version)
return max_version, err
}
const listBrands = `-- name: ListBrands :many
SELECT id, tenant_id, name, description, status, created_at, updated_at
FROM brands
@@ -346,10 +304,8 @@ func (q *Queries) ListKeywords(ctx context.Context, arg ListKeywordsParams) ([]L
}
const listQuestions = `-- name: ListQuestions :many
SELECT q.id, q.tenant_id, q.brand_id, q.keyword_id, q.current_version_id, q.status, q.created_at,
v.question_text, v.version_no
SELECT q.id, q.tenant_id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q
LEFT JOIN brand_question_versions v ON v.id = q.current_version_id
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL
AND ($3::bigint IS NULL OR q.keyword_id = $3)
ORDER BY q.created_at DESC
@@ -362,15 +318,13 @@ type ListQuestionsParams struct {
}
type ListQuestionsRow struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
CurrentVersionID pgtype.Int8 `json:"current_version_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
QuestionText pgtype.Text `json:"question_text"`
VersionNo pgtype.Int4 `json:"version_no"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
QuestionText string `json:"question_text"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) {
@@ -387,11 +341,9 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
&i.TenantID,
&i.BrandID,
&i.KeywordID,
&i.CurrentVersionID,
&i.QuestionText,
&i.Status,
&i.CreatedAt,
&i.QuestionText,
&i.VersionNo,
); err != nil {
return nil, err
}
@@ -584,18 +536,24 @@ func (q *Queries) UpdateKeyword(ctx context.Context, arg UpdateKeywordParams) er
return err
}
const updateQuestionCurrentVersion = `-- name: UpdateQuestionCurrentVersion :exec
UPDATE brand_questions SET current_version_id = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
const updateQuestion = `-- name: UpdateQuestion :exec
UPDATE brand_questions SET question_text = $1, updated_at = NOW()
WHERE id = $2 AND brand_id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`
type UpdateQuestionCurrentVersionParams struct {
VersionID pgtype.Int8 `json:"version_id"`
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
type UpdateQuestionParams struct {
QuestionText string `json:"question_text"`
ID int64 `json:"id"`
BrandID int64 `json:"brand_id"`
TenantID int64 `json:"tenant_id"`
}
func (q *Queries) UpdateQuestionCurrentVersion(ctx context.Context, arg UpdateQuestionCurrentVersionParams) error {
_, err := q.db.Exec(ctx, updateQuestionCurrentVersion, arg.VersionID, arg.ID, arg.TenantID)
func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams) error {
_, err := q.db.Exec(ctx, updateQuestion,
arg.QuestionText,
arg.ID,
arg.BrandID,
arg.TenantID,
)
return err
}
@@ -94,25 +94,15 @@ type BrandKeyword struct {
}
type BrandQuestion struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
CurrentVersionID pgtype.Int8 `json:"current_version_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type BrandQuestionVersion struct {
ID int64 `json:"id"`
QuestionID int64 `json:"question_id"`
TenantID int64 `json:"tenant_id"`
BrandID int64 `json:"brand_id"`
KeywordID int64 `json:"keyword_id"`
QuestionText string `json:"question_text"`
QuestionHash string `json:"question_hash"`
VersionNo int32 `json:"version_no"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type Competitor struct {
@@ -146,6 +136,77 @@ type GenerationTask struct {
OperatorID pgtype.Int8 `json:"operator_id"`
}
type KnowledgeChunksMetum struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
KnowledgeItemID int64 `json:"knowledge_item_id"`
ChunkIndex int32 `json:"chunk_index"`
TokenCount int32 `json:"token_count"`
QdrantPointID string `json:"qdrant_point_id"`
ItemVersion int32 `json:"item_version"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type KnowledgeGroup struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Name string `json:"name"`
ParentID pgtype.Int8 `json:"parent_id"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type KnowledgeItem struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
GroupID int64 `json:"group_id"`
SourceType string `json:"source_type"`
Name string `json:"name"`
SourceUri pgtype.Text `json:"source_uri"`
StorageKey string `json:"storage_key"`
ContentText pgtype.Text `json:"content_text"`
Status string `json:"status"`
SizeBytes int64 `json:"size_bytes"`
ItemVersion int32 `json:"item_version"`
ErrorMessage pgtype.Text `json:"error_message"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
MarkdownContent pgtype.Text `json:"markdown_content"`
}
type KnowledgeParseTask struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
KnowledgeItemID int64 `json:"knowledge_item_id"`
SourceType string `json:"source_type"`
Status string `json:"status"`
ErrorMessage pgtype.Text `json:"error_message"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type MediaPlatform struct {
ID int64 `json:"id"`
PlatformID string `json:"platform_id"`
Name string `json:"name"`
Category string `json:"category"`
ShortName string `json:"short_name"`
AccentColor string `json:"accent_color"`
LoginUrl pgtype.Text `json:"login_url"`
LogoUrl pgtype.Text `json:"logo_url"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Plan struct {
ID int64 `json:"id"`
PlanCode string `json:"plan_code"`
@@ -157,6 +218,22 @@ type Plan struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PlatformAccount struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
PlatformID string `json:"platform_id"`
PlatformUid string `json:"platform_uid"`
Nickname string `json:"nickname"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Status string `json:"status"`
MetadataJson []byte `json:"metadata_json"`
LastCheckAt pgtype.Timestamptz `json:"last_check_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PlatformUserRole struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
@@ -166,6 +243,40 @@ type PlatformUserRole struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PluginInstallation struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
InstallationKey string `json:"installation_key"`
InstallationName string `json:"installation_name"`
BrowserName pgtype.Text `json:"browser_name"`
ClientVersion pgtype.Text `json:"client_version"`
InstallationTokenHash string `json:"installation_token_hash"`
Status string `json:"status"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PluginSession struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
PluginInstallationID pgtype.Int8 `json:"plugin_installation_id"`
ActionType string `json:"action_type"`
ResourceType pgtype.Text `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
PlatformAccountID pgtype.Int8 `json:"platform_account_id"`
PlatformID string `json:"platform_id"`
SessionToken string `json:"session_token"`
ClientVersion pgtype.Text `json:"client_version"`
Status string `json:"status"`
ExpireAt pgtype.Timestamptz `json:"expire_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptRule struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
@@ -191,6 +302,44 @@ type PromptRuleGroup struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type PromptRuleKnowledgeGroup struct {
PromptRuleID int64 `json:"prompt_rule_id"`
KnowledgeGroupID int64 `json:"knowledge_group_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type PublishBatch struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ArticleID int64 `json:"article_id"`
InitiatorUserID int64 `json:"initiator_user_id"`
Status string `json:"status"`
PublishType string `json:"publish_type"`
CoverAssetUrl pgtype.Text `json:"cover_asset_url"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PublishRecord struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
PublishBatchID int64 `json:"publish_batch_id"`
ArticleID int64 `json:"article_id"`
PlatformAccountID int64 `json:"platform_account_id"`
PlatformID string `json:"platform_id"`
Status string `json:"status"`
ExternalArticleID pgtype.Text `json:"external_article_id"`
ExternalArticleUrl pgtype.Text `json:"external_article_url"`
ExternalManageUrl pgtype.Text `json:"external_manage_url"`
PublishedAt pgtype.Timestamptz `json:"published_at"`
RequestPayloadJson []byte `json:"request_payload_json"`
ResponsePayloadJson []byte `json:"response_payload_json"`
ErrorMessage pgtype.Text `json:"error_message"`
RetryCount int32 `json:"retry_count"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type QuotaReservation struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
@@ -27,16 +27,13 @@ type Querier interface {
CreatePromptRule(ctx context.Context, arg CreatePromptRuleParams) (CreatePromptRuleRow, error)
CreatePromptRuleGroup(ctx context.Context, arg CreatePromptRuleGroupParams) (CreatePromptRuleGroupRow, error)
CreateQuestion(ctx context.Context, arg CreateQuestionParams) (int64, error)
CreateQuestionVersion(ctx context.Context, arg CreateQuestionVersionParams) (int64, error)
CreateQuotaReservation(ctx context.Context, arg CreateQuotaReservationParams) (int64, error)
CreateScheduleTask(ctx context.Context, arg CreateScheduleTaskParams) (CreateScheduleTaskRow, error)
DeactivateQuestionVersion(ctx context.Context, questionID int64) error
GetActivePlanForTenant(ctx context.Context, tenantID int64) (GetActivePlanForTenantRow, error)
GetArticleByID(ctx context.Context, arg GetArticleByIDParams) (GetArticleByIDRow, error)
GetArticleVersions(ctx context.Context, articleID int64) ([]GetArticleVersionsRow, error)
GetBrandByID(ctx context.Context, arg GetBrandByIDParams) (GetBrandByIDRow, error)
GetCurrentBalance(ctx context.Context, arg GetCurrentBalanceParams) (int32, error)
GetLatestQuestionVersionNo(ctx context.Context, questionID int64) (int32, error)
GetPromptRuleByID(ctx context.Context, arg GetPromptRuleByIDParams) (GetPromptRuleByIDRow, error)
GetQuotaSummary(ctx context.Context, tenantID int64) (int32, error)
GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error)
@@ -85,7 +82,7 @@ type Querier interface {
UpdatePromptRule(ctx context.Context, arg UpdatePromptRuleParams) error
UpdatePromptRuleGroup(ctx context.Context, arg UpdatePromptRuleGroupParams) error
UpdatePromptRuleStatus(ctx context.Context, arg UpdatePromptRuleStatusParams) error
UpdateQuestionCurrentVersion(ctx context.Context, arg UpdateQuestionCurrentVersionParams) error
UpdateQuestion(ctx context.Context, arg UpdateQuestionParams) error
UpdateQuotaReservationResource(ctx context.Context, arg UpdateQuotaReservationResourceParams) error
UpdateScheduleTask(ctx context.Context, arg UpdateScheduleTaskParams) error
UpdateScheduleTaskStatus(ctx context.Context, arg UpdateScheduleTaskStatusParams) error
@@ -98,7 +98,7 @@ SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
t.template_name,
COALESCE(
NULLIF(gt.input_params_json ->> 'generation_mode', ''),
CASE WHEN a.source_type = 'custom_generation' THEN 'instant' ELSE NULL END
CASE WHEN a.source_type = 'custom_generation' THEN 'instant'::TEXT ELSE NULL::TEXT END
) AS generation_mode
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
@@ -126,7 +126,7 @@ type GetRecentArticlesRow struct {
WordCount pgtype.Int4 `json:"word_count"`
SourceLabel pgtype.Text `json:"source_label"`
TemplateName pgtype.Text `json:"template_name"`
GenerationMode pgtype.Text `json:"generation_mode"`
GenerationMode interface{} `json:"generation_mode"`
}
func (q *Queries) GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error) {
@@ -46,36 +46,20 @@ UPDATE brand_keywords SET deleted_at = NOW(), updated_at = NOW()
WHERE brand_id = sqlc.arg(brand_id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
-- name: ListQuestions :many
SELECT q.id, q.tenant_id, q.brand_id, q.keyword_id, q.current_version_id, q.status, q.created_at,
v.question_text, v.version_no
SELECT q.id, q.tenant_id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q
LEFT JOIN brand_question_versions v ON v.id = q.current_version_id
WHERE q.brand_id = sqlc.arg(brand_id) AND q.tenant_id = sqlc.arg(tenant_id) AND q.deleted_at IS NULL
AND (sqlc.narg(keyword_id)::bigint IS NULL OR q.keyword_id = sqlc.narg(keyword_id))
ORDER BY q.created_at DESC;
-- name: CreateQuestion :one
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, status)
VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.arg(keyword_id), 'active')
INSERT INTO brand_questions (tenant_id, brand_id, keyword_id, question_text, status)
VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.arg(keyword_id), sqlc.arg(question_text), 'active')
RETURNING id;
-- name: CreateQuestionVersion :one
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
VALUES (sqlc.arg(question_id), sqlc.arg(question_text), sqlc.arg(question_hash), sqlc.arg(version_no), true)
RETURNING id;
-- name: UpdateQuestionCurrentVersion :exec
UPDATE brand_questions SET current_version_id = sqlc.arg(version_id), updated_at = NOW()
WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id);
-- name: DeactivateQuestionVersion :exec
UPDATE brand_question_versions SET is_active = false
WHERE question_id = sqlc.arg(question_id) AND is_active = true;
-- name: GetLatestQuestionVersionNo :one
SELECT COALESCE(MAX(version_no), 0)::INT AS max_version
FROM brand_question_versions
WHERE question_id = sqlc.arg(question_id);
-- name: UpdateQuestion :exec
UPDATE brand_questions SET question_text = sqlc.arg(question_text), updated_at = NOW()
WHERE id = sqlc.arg(id) AND brand_id = sqlc.arg(brand_id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL;
-- name: SoftDeleteQuestion :exec
UPDATE brand_questions SET deleted_at = NOW(), updated_at = NOW()
@@ -16,7 +16,7 @@ SELECT a.id, a.generate_status, a.publish_status, a.source_type, a.created_at,
t.template_name,
COALESCE(
NULLIF(gt.input_params_json ->> 'generation_mode', ''),
CASE WHEN a.source_type = 'custom_generation' THEN 'instant' ELSE NULL END
CASE WHEN a.source_type = 'custom_generation' THEN 'instant'::TEXT ELSE NULL::TEXT END
) AS generation_mode
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
@@ -70,7 +70,7 @@ func (r *workspaceRepository) GetRecentArticles(ctx context.Context, tenantID in
GenerateStatus: row.GenerateStatus,
PublishStatus: row.PublishStatus,
SourceType: row.SourceType,
GenerationMode: nullableText(row.GenerationMode),
GenerationMode: nullableAnyText(row.GenerationMode),
CreatedAt: timeFromTimestamp(row.CreatedAt),
Title: nullableText(row.Title),
WordCount: intFromInt4(row.WordCount),
@@ -1,12 +1,12 @@
CREATE TABLE brand_questions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
keyword_id BIGINT NOT NULL REFERENCES brand_keywords(id),
current_version_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
brand_id BIGINT NOT NULL REFERENCES brands(id),
keyword_id BIGINT NOT NULL REFERENCES brand_keywords(id),
question_text TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_brand_questions_keyword ON brand_questions(keyword_id);
@@ -1 +1 @@
DROP TABLE IF EXISTS brand_question_versions;
-- No-op: question versioning was removed during development.
@@ -1,11 +1 @@
CREATE TABLE brand_question_versions (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES brand_questions(id),
question_text TEXT NOT NULL,
question_hash VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_question_version_no UNIQUE (question_id, version_no)
);
CREATE INDEX idx_question_hash ON brand_question_versions(question_hash);
-- No-op: question versioning was removed during development.
@@ -0,0 +1,37 @@
ALTER TABLE brand_questions
ADD COLUMN IF NOT EXISTS current_version_id BIGINT;
CREATE TABLE IF NOT EXISTS brand_question_versions (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES brand_questions(id),
question_text TEXT NOT NULL,
question_hash VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_question_version_no UNIQUE (question_id, version_no)
);
CREATE INDEX IF NOT EXISTS idx_question_hash ON brand_question_versions(question_hash);
INSERT INTO brand_question_versions (question_id, question_text, question_hash, version_no, is_active)
SELECT q.id,
q.question_text,
md5(q.question_text),
1,
true
FROM brand_questions q
WHERE NOT EXISTS (
SELECT 1
FROM brand_question_versions v
WHERE v.question_id = q.id
);
UPDATE brand_questions q
SET current_version_id = v.id
FROM brand_question_versions v
WHERE v.question_id = q.id
AND v.version_no = 1;
ALTER TABLE brand_questions
DROP COLUMN IF EXISTS question_text;
@@ -0,0 +1,29 @@
ALTER TABLE brand_questions
ADD COLUMN IF NOT EXISTS question_text TEXT;
UPDATE brand_questions q
SET question_text = COALESCE(
(
SELECT v.question_text
FROM brand_question_versions v
WHERE v.id = q.current_version_id
LIMIT 1
),
(
SELECT v.question_text
FROM brand_question_versions v
WHERE v.question_id = q.id
ORDER BY v.version_no DESC, v.id DESC
LIMIT 1
),
''
)
WHERE q.question_text IS NULL;
ALTER TABLE brand_questions
ALTER COLUMN question_text SET NOT NULL;
ALTER TABLE brand_questions
DROP COLUMN IF EXISTS current_version_id;
DROP TABLE IF EXISTS brand_question_versions;