Files
geo/docs/ai-brand-monitoring-tech-design-v4.md
T
root 5ff2e2e74c refactor(tenant): drop legacy plugin_installations, migrate monitoring to desktop_clients
Hard cutover from the browser-extension plugin flow to desktop clients:
remove plugin_installations/plugin_sessions tables and related service,
handler, router, and generated model code; migrate monitoring quotas
and collector types to desktop_clients (UUID primary_client_id);
recreate platform_access_snapshots keyed by client_id; update dev-seed
and callback types accordingly; mark legacy design docs as historical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:52:35 +08:00

1411 lines
57 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 品牌曝光监测系统技术方案 V4(旧版设计)
> 旧版设计说明:本文描述的是基于浏览器插件采集、`installation_token`、`/api/plugin/monitoring/*` 的旧监控方案。自 2026-04-20 起,当前实现已切换到 `desktop_clients` / desktop client 架构。本文保留用于工作记录与方案追溯,不再作为当前实现依据。
## 1. 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档名称 | AI 品牌曝光监测系统技术方案 V4(插件采集 + 配额约束) |
| 文档版本 | V4.0 |
| 文档状态 | 旧版设计(保留工作记录) |
| 创建日期 | 2026-04-08 |
| 基线文档 | `docs/ai-brand-monitoring-tech-design-v3.md` |
| 适用范围 | 数据追踪模块全部页面 |
| 关联文档 | `docs/geo-platform-prd-v1.md` (PRD 8.4 数据追踪) |
| 关联文档 | `docs/ai-brand-monitoring-collection-feasibility-v1.md` (采集可行性) |
| 容量目标 | 5 万用户(B2B 共享租户,~3,000 租户) |
## 2. V4 修订范围
V4 在 V3 基础上做两项根本性变更:
1. **采集方式**:从服务端 AI API 调用改为 **浏览器插件采集**,消除 AI API 成本。
2. **配额约束**:引入租户级品牌/关键词/问题配额,控制采集规模。
### 2.1 与 V3 的关系
| V3 章节 | V4 处理 | 原因 |
| --- | --- | --- |
| 2.1 数据口径分层 | 保留,`run_mode` 值调整为 `plugin_*` | 口径逻辑不变,采集来源变了 |
| 2.2 Schema Delta | 保留 + 扩展 | 新增配额表、任务分配字段 |
| 3. 读流量与缓存 | **完全保留** | 读侧与采集方式无关 |
| 4. 采集流水线 | **全部替换** | 插件采集替代服务端 Collector |
| 5. 部署拓扑 | 精简 | 去掉 Collector Pod、Queue Redis |
| 6. 降级与容灾 | 保留读侧 + 新增插件降级 | 新增采集不足的降级策略 |
| 7. 压测验收 | 保留读侧 + 新增插件压测 | 新增插件并发场景 |
| 8. 实施计划 | 重写 | 工期和顺序均有变化 |
V3 其他章节(页面需求、前端组件、API 响应结构、指标定义)保持不变。
---
## 3. 用户配额体系
### 3.1 配额规则
| 配额项 | 普通租户(Free) | 高级租户(Pro | 企业租户(Enterprise |
| --- | --- | --- | --- |
| 最大品牌数 | 1 | 3 | 自定义 |
| 每品牌关键词数 | 8 | 8 | 自定义 |
| 每关键词问题数 | 5 | 5 | 自定义 |
| **每品牌最大问题数** | **40** | **40** | **自定义** |
| 采集频率 | 每 3 天 | 每天 | 每天 + 按需触发 |
| AI 平台数 | 3(首批) | 6(全部) | 6 + 自定义 |
| 搜索增强采集 | 不支持 | 支持 | 支持 |
### 3.2 配额表 Schema
```sql
CREATE TABLE tenant_monitoring_quotas (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
max_brands INT NOT NULL DEFAULT 1,
max_keywords_per_brand INT NOT NULL DEFAULT 8,
max_questions_per_keyword INT NOT NULL DEFAULT 5,
collect_frequency VARCHAR(20) NOT NULL DEFAULT 'every_3_days',
enabled_platforms JSONB NOT NULL DEFAULT '["deepseek","qwen","doubao"]',
search_enhanced BOOLEAN NOT NULL DEFAULT false,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON COLUMN tenant_monitoring_quotas.collect_frequency IS 'daily / every_3_days / weekly';
COMMENT ON COLUMN tenant_monitoring_quotas.plan_tier IS 'free / pro / enterprise';
```
### 3.3 采集规模估算
| 参数 | 估值 |
| --- | --- |
| 总用户 | 50,000 |
| 租户数 | ~3,000(平均 15-20 人/租户) |
| 高级租户(10% | 300,品牌数 900 |
| 普通租户(90% | 2,700,品牌数 2,700 |
| 品牌实例总数(最大) | 3,600 |
| 监测采用率(30%) | 活跃监测品牌 ~1,080 |
| 实际问题数/品牌(70% 使用率) | ~28 |
每日采集任务量(中等场景):
```
标准模式:
1,080 品牌 × 28 问题 × 6 平台 = 181,440 任务/天
考虑采集频率分层(Free 每 3 天):
高级 (30%): 324 品牌 × 28q × 6 平台 = 54,432/天
普通 (70%): 756 品牌 × 28q × 6 平台 ÷ 3 = 42,336/天
合计 ≈ 96,768 任务/天(均摊)
```
---
## 4. 插件采集架构
### 4.1 现有插件能力复用
当前 GEO Publisher 插件(`apps/browser-extension/`)已具备完整基础设施:
| 现有能力 | 文件位置 | 监测复用方式 |
| --- | --- | --- |
| 平台适配器模式 | `src/adapters/` (13 个) | 新增 AI 平台 adapter |
| 登录状态检测 | 各 adapter 的 `detect()` | 检测 AI 平台登录态 |
| 后台 Service Worker | `entrypoints/background.ts` | 定时触发采集任务 |
| 页面 ↔ 插件通信 | `entrypoints/content.ts` (postMessage) | 前端可触发手动采集(辅助通道) |
| 后端回调 | `src/runtime.ts` (HTTP POST) | 采集结果回传后端 |
| 后端直连 | `installation_token` + `api_base_url` | **插件后台直接拉取任务 + 回传结果** |
| Chrome Storage | `src/storage.ts` | 存储采集队列和进度 |
| DNR 跨域规则 | `public/rules.json` (18 条) | 新增 AI 平台规则 |
| 安装认证 | `installation_token` + `installation_id` | 复用,校验采集权限 |
### 4.2 整体采集流程
```
┌──────────────────────────────────────────────────────────────────┐
│ 后端调度器 │
│ 1. 根据 tenant_monitoring_quotas 和 collect_frequency │
│ 生成当日采集任务(品牌 × 去重问题 × 平台) │
│ 2. 写入 monitoring_collect_tasks 表 │
│ 3. 状态机: pending → assigned → completed / failed / expired │
└───────────────────────────────┬──────────────────────────────────┘
插件 Background SW 定时心跳(每 15 分钟)
┌──────────────────────────────────────────────────────────────────┐
│ 浏览器插件 Background Service Worker │
│ │
│ 主通道:插件后台直接拉取(不依赖前端页面) │
│ ────────────────────────────────────────── │
│ 4. 心跳触发 → GET /api/plugin/monitoring/tasks │
│ (Header: X-Geo-Installation-Token) │
│ → 后端根据 installation → tenant 映射,返回待采集任务 │
│ 5. POST /api/plugin/monitoring/tasks/{id}/claim → 领取任务 │
│ 6. 按 AI 平台分发到对应 adapter │
│ 7. 检测用户是否登录该 AI 平台(Cookie 检测) │
│ 8. 已登录 → 调用平台 Web API 创建对话 → 输入问题 → 等待回答 │
│ 未登录 → 标记 skip │
│ 9. 提取回答文本 + 引用信息(如有) │
│ 10. POST /api/callback/plugin/monitor → 结果回传后端 │
│ │
│ 辅助通道:前端 postMessage(用户主动触发时) │
│ ────────────────────────────────────────── │
│ Admin Web 点击"立即采集" → postMessage → 插件立即执行 │
│ (仅作为加速手段,不是必须路径) │
└───────────────────────────────┬──────────────────────────────────┘
POST /api/callback/plugin/monitor
┌──────────────────────────────────────────────────────────────────┐
│ 后端采集服务 │
│ 11. 校验 installation_token + 任务归属 │
│ 12. 解析回答:品牌提及、位置、情感、引用 │
│ 13. 写入 question_monitor_runs + question_monitor_parse_results │
│ 14. 原始回答存 MinIO(对象存储) │
│ 15. 标记任务 completed │
└──────────────────────────────────────────────────────────────────┘
↓ 每日 6:30 聚合
┌──────────────────────────────────────────────────────────────────┐
│ Aggregator Job(不变) │
│ 16. 按 business_date 聚合已完成的采集数据 │
│ 17. 写入 10 张 monitoring_*_daily 汇总表 │
│ 18. 预热热门品牌缓存 │
└──────────────────────────────────────────────────────────────────┘
```
**关键设计**:插件通过 Background SW 的定时心跳(每 15 分钟)**直接调用后端 API** 拉取和领取任务,不依赖 Admin Web 前端页面是否打开。前端 postMessage 仅作为用户主动触发"立即采集"的辅助通道。
后端需要新增 **插件专用 API**(不走 JWT 认证,走 installation_token 认证):
```
GET /api/plugin/monitoring/tasks → 返回当前插件所属租户的 pending 任务
POST /api/plugin/monitoring/tasks/{id}/claim → 领取任务(分布式锁)
POST /api/callback/plugin/monitor → 回传采集结果(已有回调模式)
POST /api/callback/plugin/monitor/batch-skip → 批量跳过未登录平台的任务
```
### 4.3 AI 平台适配器
#### 4.3.1 适配器接口
```typescript
// apps/browser-extension/src/adapters/ai/types.ts
export interface AIMonitorAdapter {
/** 平台标识 */
platformId: AIPlatformId;
/** 检测用户是否已登录该 AI 平台 */
detect(): Promise<AIMonitorPlatformState>;
/** 向 AI 平台提问并获取回答 */
ask(question: string, options?: AskOptions): Promise<AIMonitorResult>;
}
export type AIPlatformId = 'deepseek' | 'qwen' | 'doubao' | 'kimi' | 'ernie' | 'hunyuan';
export interface AIMonitorPlatformState {
platformId: AIPlatformId;
connected: boolean;
userId?: string;
nickname?: string;
message?: string;
}
export interface AskOptions {
/** 是否启用联网搜索 */
searchEnabled?: boolean;
/** 任务超时(毫秒) */
timeout?: number;
}
export interface AIMonitorResult {
success: boolean;
question: string;
answer: string;
citations: Citation[];
searchResults: SearchResult[];
responseTime: number;
model: string;
timestamp: string;
rawHtml?: string;
}
export interface Citation {
url: string;
title: string;
domain: string;
snippet?: string;
}
export interface SearchResult {
url: string;
title: string;
snippet: string;
}
```
#### 4.3.2 各平台采集方式
| AI 平台 | Web 端 URL | 采集方式 | 登录检测 | 引用支持 |
| --- | --- | --- | --- | --- |
| DeepSeek | chat.deepseek.com | 调用 Web 端 chat APISSE 流) → 拼接完整回答 | Cookie 检测 | 无原生引用 |
| 千问 | tongyi.aliyun.com | 调用 Web 端对话 API → 等待回答完成 | Cookie 检测 | 联网搜索模式支持 |
| 豆包 | doubao.com | 调用 Web 端对话 API → 等待回答 | Cookie 检测 | 联网搜索支持 |
| Kimi | kimi.moonshot.cn | 调用 Web 端对话 API → 等待回答 | Cookie 检测 | 联网搜索支持 |
| 文心一言 | yiyan.baidu.com | 调用 Web 端对话 API → 等待回答 | 百度账号 Cookie | 支持 |
| 混元 | hunyuan.tencent.com | 调用 Web 端对话 API → 等待回答 | Cookie 检测 | 搜索增强支持 |
**核心实现模式**(以 DeepSeek 为例):
```typescript
// apps/browser-extension/src/adapters/ai/deepseek.ts
import type { AIMonitorAdapter, AIMonitorPlatformState, AIMonitorResult, AskOptions } from './types';
const DEEPSEEK_CHAT_URL = 'https://chat.deepseek.com';
const DEEPSEEK_API_BASE = 'https://chat.deepseek.com/api/v0';
export const deepseekAdapter: AIMonitorAdapter = {
platformId: 'deepseek',
async detect(): Promise<AIMonitorPlatformState> {
try {
// 通过 Cookie 判断登录状态,类似现有 zhihu/bilibili adapter
const cookies = await browser.cookies.getAll({ domain: '.deepseek.com' });
const hasSession = cookies.some(c => c.name === 'ds_session' || c.name === 'token');
if (!hasSession) {
return { platformId: 'deepseek', connected: false, message: '未登录' };
}
// 验证 token 有效性
const resp = await fetch(`${DEEPSEEK_API_BASE}/user/current`, {
credentials: 'include',
});
if (!resp.ok) {
return { platformId: 'deepseek', connected: false, message: '登录已过期' };
}
const data = await resp.json();
return {
platformId: 'deepseek',
connected: true,
userId: data.data?.id,
nickname: data.data?.nickname,
};
} catch {
return { platformId: 'deepseek', connected: false, message: '检测失败' };
}
},
async ask(question: string, options?: AskOptions): Promise<AIMonitorResult> {
const startTime = Date.now();
const timeout = options?.timeout ?? 60_000;
try {
// 1. 创建新对话
const createResp = await fetch(`${DEEPSEEK_API_BASE}/chat/create`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { data: chat } = await createResp.json();
// 2. 发送问题并监听 SSE 流
const answer = await streamChat(chat.id, question, timeout);
return {
success: true,
question,
answer: answer.text,
citations: [], // DeepSeek 标准模式无引用
searchResults: [],
responseTime: Date.now() - startTime,
model: answer.model ?? 'deepseek-chat',
timestamp: new Date().toISOString(),
};
} catch (err: any) {
return {
success: false,
question,
answer: '',
citations: [],
searchResults: [],
responseTime: Date.now() - startTime,
model: '',
timestamp: new Date().toISOString(),
};
}
},
};
async function streamChat(chatId: string, question: string, timeout: number) {
// SSE 流式读取,拼接完整回答
// 具体实现需根据 DeepSeek Web 端实际 API 格式调整
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const resp = await fetch(`${DEEPSEEK_API_BASE}/chat/completion`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
prompt: question,
stream: true,
}),
signal: controller.signal,
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let fullText = '';
let model = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE data 行
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.slice(6));
if (json.choices?.[0]?.delta?.content) {
fullText += json.choices[0].delta.content;
}
if (json.model) model = json.model;
} catch { /* 跳过非 JSON 行 */ }
}
}
}
return { text: fullText, model };
} finally {
clearTimeout(timer);
}
}
```
#### 4.3.3 适配器注册表
```typescript
// apps/browser-extension/src/adapters/ai/index.ts
import { deepseekAdapter } from './deepseek';
import { qwenAdapter } from './qwen';
import { doubaoAdapter } from './doubao';
import { kimiAdapter } from './kimi';
import { ernieAdapter } from './ernie';
import { hunyuanAdapter } from './hunyuan';
import type { AIMonitorAdapter, AIPlatformId } from './types';
export const aiAdapters: Record<AIPlatformId, AIMonitorAdapter> = {
deepseek: deepseekAdapter,
qwen: qwenAdapter,
doubao: doubaoAdapter,
kimi: kimiAdapter,
ernie: ernieAdapter,
hunyuan: hunyuanAdapter,
};
export function getAIAdapter(platformId: AIPlatformId): AIMonitorAdapter | undefined {
return aiAdapters[platformId];
}
```
### 4.4 采集任务调度器(插件侧)
```typescript
// apps/browser-extension/src/monitor/scheduler.ts
import { getAIAdapter } from '../adapters/ai';
import type { AIMonitorResult, AIPlatformId } from '../adapters/ai/types';
import { getState } from '../storage';
/** 反检测配置 */
const ANTI_DETECT = {
maxQueriesPerPlatformPerHour: 3,
minIntervalSec: 30,
maxIntervalSec: 120,
maxDailyQueries: 50,
};
interface CollectTask {
id: number;
question_text: string;
platform_id: AIPlatformId;
brand_id: number;
question_id: number;
search_enabled: boolean;
}
/** 执行一批采集任务 */
export async function executeCollectTasks(tasks: CollectTask[]): Promise<void> {
const state = await getState();
if (!state.installation_token || !state.api_base_url) return;
// 按平台分组,串行执行(避免同时操作多个平台)
const byPlatform = groupBy(tasks, t => t.platform_id);
for (const [platformId, platformTasks] of Object.entries(byPlatform)) {
const adapter = getAIAdapter(platformId as AIPlatformId);
if (!adapter) continue;
// 检测登录状态
const detection = await adapter.detect();
if (!detection.connected) {
// 标记该平台全部任务为 skip
await reportSkipped(state, platformTasks, '未登录');
continue;
}
// 逐条执行,加入随机延迟
for (const task of platformTasks) {
// 检查日限额
if (await getDailyCount() >= ANTI_DETECT.maxDailyQueries) break;
// 检查平台小时限额
if (await getPlatformHourlyCount(platformId) >= ANTI_DETECT.maxQueriesPerPlatformPerHour) break;
const result = await adapter.ask(task.question_text, {
searchEnabled: task.search_enabled,
timeout: 60_000,
});
// 回传后端
await reportResult(state, task, result);
await incrementCounters(platformId);
// 随机延迟
const delay = randomBetween(ANTI_DETECT.minIntervalSec, ANTI_DETECT.maxIntervalSec);
await sleep(delay * 1000);
}
}
}
async function reportResult(
state: ExtensionStorageState,
task: CollectTask,
result: AIMonitorResult,
) {
await fetch(`${state.api_base_url}/api/callback/plugin/monitor`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Geo-Installation-Token': state.installation_token!,
'X-Geo-Installation-Id': String(state.plugin_installation_id),
},
body: JSON.stringify({
task_id: task.id,
platform_id: task.platform_id,
brand_id: task.brand_id,
question_id: task.question_id,
success: result.success,
answer: result.answer,
citations: result.citations,
search_results: result.searchResults,
response_time: result.responseTime,
model: result.model,
timestamp: result.timestamp,
raw_html: result.rawHtml,
client_version: browser.runtime.getManifest().version,
}),
});
}
async function reportSkipped(state: ExtensionStorageState, tasks: CollectTask[], reason: string) {
await fetch(`${state.api_base_url}/api/callback/plugin/monitor/batch-skip`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Geo-Installation-Token': state.installation_token!,
'X-Geo-Installation-Id': String(state.plugin_installation_id),
},
body: JSON.stringify({
task_ids: tasks.map(t => t.id),
reason,
}),
});
}
function randomBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function groupBy<T>(arr: T[], key: (item: T) => string): Record<string, T[]> {
return arr.reduce((acc, item) => {
const k = key(item);
(acc[k] ??= []).push(item);
return acc;
}, {} as Record<string, T[]>);
}
```
### 4.5 采集执行策略
采用**插件自主 + 前端辅助**模式:
| 模式 | 触发方式 | 单次任务数 | 频率 | 依赖前端 |
| --- | --- | --- | --- | --- |
| **心跳自主采集(主通道)** | Background SW 定时心跳 | 3-5 个任务 | 每 15 分钟 | 否 |
| **用户主动采集(辅助)** | Dashboard 点击"立即采集" → postMessage | 批量 pending 任务 | 用户触发 | 是 |
心跳自主采集保证:**只要用户安装了插件且浏览器在运行,采集就在持续进行**,无需用户打开 Admin Web。
每日限额:单用户最多 50 个采集任务。
### 4.6 Background SW 扩展
#### 4.6.1 新增采集心跳(15 分钟间隔)
```typescript
// entrypoints/background.ts
// 原有 60 分钟心跳保持不变(平台检测 + 状态同步)
// 新增 15 分钟采集心跳
browser.alarms.create('monitor-heartbeat', { periodInMinutes: 15 });
browser.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'heartbeat') {
// 原有逻辑:刷新内容平台登录状态
await refreshPlatformState();
}
if (alarm.name === 'monitor-heartbeat') {
// 新增:插件后台直接拉取并执行采集任务
await pullAndExecuteMonitorTasks();
}
});
async function pullAndExecuteMonitorTasks() {
const state = await getState();
if (!state.installation_token || !state.api_base_url) return;
try {
// 1. 直接调后端 API 拉取待采集任务(不经过前端)
const resp = await fetch(`${state.api_base_url}/api/plugin/monitoring/tasks`, {
headers: {
'X-Geo-Installation-Token': state.installation_token,
'X-Geo-Installation-Id': String(state.plugin_installation_id),
},
});
if (!resp.ok) return;
const { data: tasks } = await resp.json();
if (!tasks?.length) return;
// 2. 执行采集(串行,带反检测延迟)
await executeCollectTasks(tasks);
} catch {
// 静默失败,下次心跳再试
}
}
```
#### 4.6.2 前端辅助通道(用户主动触发)
```typescript
// 新增 action 处理(前端 postMessage 触发)
case 'collectMonitorData':
// 用户点击"立即采集"时,前端传入任务列表
return await handleCollectMonitorData(payload);
case 'detectAIPlatforms':
// 检测 AI 平台登录状态(供前端展示)
return await handleDetectAIPlatforms();
```
### 4.7 分布式任务调度(后端侧)
#### 4.7.1 任务生成
```go
// 每日凌晨由 Aggregator 或独立 CronJob 生成
func (s *MonitoringScheduler) GenerateDailyTasks(ctx context.Context, businessDate time.Time) error {
// 1. 获取所有启用监测的租户
tenants, _ := s.repo.ListMonitoringTenants(ctx)
for _, tenant := range tenants {
quota, _ := s.repo.GetMonitoringQuota(ctx, tenant.ID)
// 检查采集频率
if !shouldCollectToday(quota.CollectFrequency, businessDate) {
continue
}
// 2. 获取租户的品牌和问题
brands, _ := s.repo.ListBrandsWithQuestions(ctx, tenant.ID, quota.MaxBrands)
for _, brand := range brands {
for _, question := range brand.Questions {
for _, platformID := range quota.EnabledPlatforms {
// 3. 生成任务(幂等,按唯一键去重)
s.repo.UpsertCollectTask(ctx, MonitorCollectTask{
TenantID: tenant.ID,
BrandID: brand.ID,
QuestionID: question.ID,
PlatformID: platformID,
BusinessDate: businessDate,
RunMode: "plugin_standard",
Status: "pending",
SearchEnabled: quota.SearchEnhanced && platformSupportsSearch(platformID),
})
}
}
}
}
return nil
}
```
#### 4.7.2 任务领取(分布式锁)
```go
// GET /api/tenant/monitoring/collect-tasks
func (s *MonitoringService) GetPendingTasks(ctx context.Context, tenantID uuid.UUID, limit int) ([]CollectTask, error) {
// 只返回 status=pending 的任务
return s.repo.ListPendingCollectTasks(ctx, tenantID, limit)
}
// POST /api/tenant/monitoring/collect-tasks/{id}/claim
func (s *MonitoringService) ClaimTask(ctx context.Context, taskID int64, installationID string) error {
// Redis 分布式锁,TTL 10 分钟
lockKey := fmt.Sprintf("mon:task_lock:%d", taskID)
acquired, _ := s.cache.SetNX(ctx, lockKey, installationID, 10*time.Minute)
if !acquired {
return ErrTaskAlreadyClaimed
}
// 更新任务状态
return s.repo.UpdateCollectTaskStatus(ctx, taskID, "assigned", installationID)
}
```
#### 4.7.3 结果接收(HTTP → RabbitMQ 异步处理)
插件回传的采集结果通过 RabbitMQ 异步处理,API 层只做校验和投递,**不阻塞插件等待解析完成**:
```go
// POST /api/callback/plugin/monitor
func (h *MonitorCallbackHandler) HandleMonitorResult(c *gin.Context) {
var req MonitorResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
// 校验 installation_token
installationToken := c.GetHeader("X-Geo-Installation-Token")
if !h.service.ValidateInstallation(c, installationToken) {
c.JSON(401, gin.H{"error": "unauthorized"})
return
}
// 仅做两件事:标记任务 assigned → received,投递到 MQ
// 解析、入库、MinIO 存储全部由 Consumer 异步完成
if err := h.service.MarkTaskReceived(c, req.TaskID); err != nil {
c.JSON(409, gin.H{"error": "task not found or already completed"})
return
}
// 投递到 RabbitMQ
if err := h.mq.Publish(c, "monitor.result", req); err != nil {
// MQ 投递失败 → 回退任务状态,插件下次心跳会重试
h.service.MarkTaskPending(c, req.TaskID)
c.JSON(500, gin.H{"error": "queue unavailable"})
return
}
c.JSON(200, gin.H{"ok": true})
}
```
### 4.8 RabbitMQ 异步处理架构
#### 4.8.1 队列拓扑
```
插件 HTTP 回传
┌──────────────────────────────────────────────────┐
│ tenant-api (HTTP 层) │
│ 校验 token → 标记 received → publish to MQ │
└───────────────────────┬──────────────────────────┘
┌──────────────────────────────────────────────────┐
│ RabbitMQ Broker │
│ │
│ Exchange: monitor.direct (direct) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Queue: monitor.result.parse │ │
│ │ routing_key: monitor.result │ │
│ │ consumer: MonitorParseWorker × 3 │ │
│ │ 作用: 解析回答 + 入库 + MinIO 存储 │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Queue: monitor.task.generate │ │
│ │ routing_key: monitor.task.generate │ │
│ │ consumer: TaskGenerateWorker × 1 │ │
│ │ 作用: 凌晨批量生成采集任务 │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Queue: monitor.aggregate.trigger │ │
│ │ routing_key: monitor.aggregate │ │
│ │ consumer: AggregateWorker × 1 │ │
│ │ 作用: 按品牌触发增量聚合 │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Queue: monitor.result.dlq (死信队列) │ │
│ │ routing_key: monitor.result.dlq │ │
│ │ 作用: 处理失败的消息暂存,人工排查 │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
#### 4.8.2 Parse Worker(核心消费者)
```go
// server/internal/monitoring/worker/parse_worker.go
type MonitorParseWorker struct {
repo *repository.MonitoringRepository
parser *MonitorAnswerParser
storage objectstorage.Client
mq *rabbitmq.Client
}
func (w *MonitorParseWorker) Handle(ctx context.Context, msg MonitorResultMessage) error {
// 1. 存原始回答到 MinIO
objectKey := fmt.Sprintf("monitor/%s/%d/%d.json",
msg.Timestamp[:10], msg.TaskID, time.Now().UnixMilli())
if err := w.storage.PutJSON(ctx, objectKey, msg); err != nil {
return fmt.Errorf("minio put failed: %w", err)
}
// 2. 写 question_monitor_runs
run, err := w.repo.CreateMonitorRun(ctx, CreateMonitorRunInput{
TaskID: msg.TaskID,
PlatformID: msg.PlatformID,
BrandID: msg.BrandID,
QuestionID: msg.QuestionID,
RunMode: "plugin_standard",
RawAnswer: msg.Answer,
ResponseTime: msg.ResponseTime,
Model: msg.Model,
ObjectKey: objectKey,
})
if err != nil {
return fmt.Errorf("create run failed: %w", err)
}
// 3. 解析回答(品牌提及、位置、情感、引用)
parseResults := w.parser.Parse(ctx, run, msg.Answer, msg.Citations)
if err := w.repo.SaveParseResults(ctx, run.ID, parseResults); err != nil {
return fmt.Errorf("save parse results failed: %w", err)
}
// 4. 标记任务 completed
if err := w.repo.CompleteTask(ctx, msg.TaskID); err != nil {
return fmt.Errorf("complete task failed: %w", err)
}
// 5. 检查该品牌当日任务是否全部完成 → 触发增量聚合
allDone, _ := w.repo.IsBrandDailyTasksComplete(ctx, msg.BrandID, msg.BusinessDate)
if allDone {
w.mq.Publish(ctx, "monitor.aggregate", AggregateMessage{
BrandID: msg.BrandID,
TenantID: msg.TenantID,
BusinessDate: msg.BusinessDate,
})
}
return nil
}
```
#### 4.8.3 Aggregate Worker(增量聚合)
```go
// server/internal/monitoring/worker/aggregate_worker.go
type AggregateWorker struct {
repo *repository.MonitoringRepository
cache cache.Cache
}
func (w *AggregateWorker) Handle(ctx context.Context, msg AggregateMessage) error {
// 按品牌 + business_date 增量聚合(替代 V3 的全量定时聚合)
// 好处:品牌任务完成即聚合,不必等到每日 6:30
if err := w.repo.AggregateBrandDaily(ctx, msg.TenantID, msg.BrandID, msg.BusinessDate); err != nil {
return err
}
// 预热该品牌缓存
w.cache.WarmBrandComposite(ctx, msg.TenantID, msg.BrandID)
return nil
}
```
#### 4.8.4 RabbitMQ 配置
```yaml
# server/configs/config.yaml 新增
rabbitmq:
url: "amqp://guest:guest@localhost:5672/"
exchange: "monitor.direct"
queues:
result_parse:
name: "monitor.result.parse"
routing_key: "monitor.result"
concurrency: 3 # 3 个并发消费者
prefetch: 5 # 每个消费者预取 5 条
retry_max: 3 # 最大重试 3 次
retry_delay: "5s" # 重试间隔
dlq: "monitor.result.dlq"
task_generate:
name: "monitor.task.generate"
routing_key: "monitor.task.generate"
concurrency: 1
aggregate:
name: "monitor.aggregate.trigger"
routing_key: "monitor.aggregate"
concurrency: 1
prefetch: 1
```
#### 4.8.5 Go 客户端初始化
```go
// server/internal/shared/mq/rabbitmq.go
import amqp "github.com/rabbitmq/amqp091-go"
type Client struct {
conn *amqp.Connection
channel *amqp.Channel
exchange string
}
func NewClient(url, exchange string) (*Client, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
return nil, err
}
// 声明 exchange
err = ch.ExchangeDeclare(exchange, "direct", true, false, false, false, nil)
if err != nil {
return nil, err
}
return &Client{conn: conn, channel: ch, exchange: exchange}, nil
}
func (c *Client) Publish(ctx context.Context, routingKey string, body any) error {
data, _ := json.Marshal(body)
return c.channel.PublishWithContext(ctx, c.exchange, routingKey, false, false, amqp.Publishing{
DeliveryMode: amqp.Persistent,
ContentType: "application/json",
Body: data,
MessageId: uuid.NewString(),
Timestamp: time.Now(),
})
}
func (c *Client) Consume(queueName string, prefetch int, handler func(amqp.Delivery)) error {
c.channel.Qos(prefetch, 0, false)
msgs, err := c.channel.Consume(queueName, "", false, false, false, false, nil)
if err != nil {
return err
}
for msg := range msgs {
handler(msg)
}
return nil
}
```
#### 4.8.6 异步化带来的架构优势
| 对比项 | 同步处理(V4 原方案) | RabbitMQ 异步 |
| --- | --- | --- |
| 插件回调延迟 | ~500ms(含解析+入库+MinIO | ~10ms(仅校验+投递) |
| API Pod 负载 | 高(CPU 密集的解析在 API 进程) | 低(解析转移到 Worker) |
| 解析失败影响 | 插件收到 500,需重试整个采集 | 消息重试,不影响插件 |
| 聚合时机 | 每日 6:30 全量聚合 | **品牌级增量聚合**(任务完成即触发) |
| 数据可见延迟 | T+1(次日早上) | **准实时**(品牌任务完成后分钟级可见) |
| 峰值吸收 | API 直接承压 | MQ 削峰填谷 |
| 故障隔离 | 解析崩溃 → API 不可用 | Worker 崩溃 → 消息堆积 → 恢复后自动消化 |
### 4.8 DNR 规则扩展
`apps/browser-extension/public/rules.json` 中新增 AI 平台的跨域规则:
```json
[
{
"id": 101,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://chat.deepseek.com"},
{"header": "origin", "operation": "set", "value": "https://chat.deepseek.com"}
]
},
"condition": {
"urlFilter": "https://chat.deepseek.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 102,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://tongyi.aliyun.com"},
{"header": "origin", "operation": "set", "value": "https://tongyi.aliyun.com"}
]
},
"condition": {
"urlFilter": "https://tongyi.aliyun.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 103,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://www.doubao.com"},
{"header": "origin", "operation": "set", "value": "https://www.doubao.com"}
]
},
"condition": {
"urlFilter": "https://www.doubao.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 104,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://kimi.moonshot.cn"},
{"header": "origin", "operation": "set", "value": "https://kimi.moonshot.cn"}
]
},
"condition": {
"urlFilter": "https://kimi.moonshot.cn/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 105,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://yiyan.baidu.com"},
{"header": "origin", "operation": "set", "value": "https://yiyan.baidu.com"}
]
},
"condition": {
"urlFilter": "https://yiyan.baidu.com/*",
"resourceTypes": ["xmlhttprequest"]
}
},
{
"id": 106,
"action": {
"type": "modifyHeaders",
"requestHeaders": [
{"header": "Referer", "operation": "set", "value": "https://hunyuan.tencent.com"},
{"header": "origin", "operation": "set", "value": "https://hunyuan.tencent.com"}
]
},
"condition": {
"urlFilter": "https://hunyuan.tencent.com/*",
"resourceTypes": ["xmlhttprequest"]
}
}
]
```
---
## 5. 数据口径修订(覆盖 V3 2.1)
### 5.1 `run_mode` 调整
| `run_mode` | 含义 | 进入主统计 | 默认消费页面 |
| --- | --- | --- | --- |
| `plugin_standard` | 插件在 AI Web 端标准对话采集 | 是 | Dashboard、平台占比、竞争对手、业务主题、AI 对话问题、品牌印象 |
| `plugin_search` | 插件在 AI Web 端开启联网搜索后采集 | 是,单独统计 | AI 引用排名 |
| `api_standard` | 后端 API 直接调用(降级/验证用) | 是 | 同 plugin_standard |
插件采集的回答更接近真实用户体验。采集可行性方案已确认 DeepSeek API 与 Web 版本不同,插件方案反而是优势。
### 5.2 Schema Delta 补充
在 V3 Schema Delta 基础上,新增/修改:
#### 5.2.1 `monitoring_collect_tasks` 表扩展
```sql
CREATE TABLE monitoring_collect_tasks (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
brand_id INT NOT NULL,
question_id INT NOT NULL,
question_version_id INT NOT NULL,
ai_platform_id VARCHAR(30) NOT NULL,
run_mode VARCHAR(30) NOT NULL DEFAULT 'plugin_standard',
business_date DATE NOT NULL,
search_enabled BOOLEAN NOT NULL DEFAULT false,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
assigned_to VARCHAR(100), -- 插件 installation_id
assigned_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
priority INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_collect_task_idempotent
UNIQUE (tenant_id, brand_id, question_id, ai_platform_id, run_mode, business_date)
);
CREATE INDEX idx_collect_tasks_pending
ON monitoring_collect_tasks(tenant_id, status, business_date)
WHERE status = 'pending';
COMMENT ON COLUMN monitoring_collect_tasks.status IS 'pending / assigned / completed / failed / expired / skipped';
COMMENT ON COLUMN monitoring_collect_tasks.assigned_to IS '领取该任务的插件 installation_id';
```
#### 5.2.2 `ai_platforms` 表
```sql
CREATE TABLE ai_platforms (
id VARCHAR(30) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
web_url VARCHAR(200) NOT NULL,
supports_standard BOOLEAN NOT NULL DEFAULT true,
supports_search BOOLEAN NOT NULL DEFAULT false,
supports_citation BOOLEAN NOT NULL DEFAULT false,
plugin_adapter_available BOOLEAN NOT NULL DEFAULT false,
display_order INT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 初始数据
INSERT INTO ai_platforms (id, name, web_url, supports_search, supports_citation, plugin_adapter_available, display_order) VALUES
('deepseek', 'DeepSeek', 'https://chat.deepseek.com', false, false, true, 1),
('qwen', '通义千问', 'https://tongyi.aliyun.com', true, true, true, 2),
('doubao', '豆包', 'https://www.doubao.com', true, true, true, 3),
('kimi', 'Kimi', 'https://kimi.moonshot.cn', true, true, false, 4),
('ernie', '文心一言', 'https://yiyan.baidu.com', true, true, false, 5),
('hunyuan', '混元', 'https://hunyuan.tencent.com', true, true, false, 6);
```
---
## 6. 读流量模型与缓存架构(保留 V3 不变)
**完全保留 V3 第 3 章的设计**,包括:
- 3.1 页面 Fan-out 矩阵
- 3.2 QPS 模型(13,500 设计容量 vs 实际 ~900 QPS,绰绰有余)
- 3.3 BFF 聚合层(Dashboard composite 接口)
- 3.4 缓存防雪崩(singleflight + TTL 抖动 + stale-while-revalidate + L1
- 3.5 缓存 Key 修订(`scope` 改为对应 `plugin_standard`/`plugin_search`
- 3.6 缓存失效策略(渐进式失效 + 预热)
- 3.7 TTL 矩阵
---
## 7. 部署拓扑与资源(修订 V3 第 5 章)
### 7.1 部署架构
```
┌─────────────────────────────┐
│ CDN (静态资源) │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ Nginx / SLB │
│ 限流: 每 IP 200 req/s │
└──────────┬───────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ tenant-api │ │ tenant-api │ │ (HPA 弹性) │
│ Pod #1 │ │ Pod #2 │ │ Pod #3 │
│ L1 cache │ │ L1 cache │ │ │
└──┬────┬─────┘ └──┬────┬─────┘ └──┬────┬─────┘
│ │ │ │ │ │
查询读取 │ │ 投递消息 │ │ │ │
│ │ │ │ │ │
┌──▼────┴─────────────────▼────┴─────────────────▼────┴──┐
│ Cache Redis (主从) │
│ 用途: API 缓存 + JWT 会话 + 任务分布式锁 │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────┼────────────────────────┬───────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ PG Primary │────────▶│ PG Replica │ │ MinIO │ │ RabbitMQ │
│ (读写) │ 流复制 │ (只读) │ │ (原始回答) │ │ (异步队列) │
└─────────────┘ └─────────────┘ └─────────────┘ └──────┬───────┘
▲ ▲ │
│ │ │
│ │ 消费消息 │
│ │ ▼
┌──────┴────────────────────────┴────────────────────────────────────────────┐
│ Worker 进程(与 API 独立部署) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Monitor Worker (单进程,多 goroutine) │ │
│ │ • ParseWorker × 3 ← consume: monitor.result.parse │ │
│ │ • AggregateWorker × 1 ← consume: monitor.aggregate │ │
│ │ • TaskGenWorker × 1 ← consume: monitor.task.generate │ │
│ │ • 写 PG Primary / 读 PG Replica / 写 MinIO │ │
│ └─────────────────────────────────────────────────────────┘ │
╚═══════════════════════════════════════════════════════════════════════════╝
┌─────────────────────────────────────┐
│ 浏览器插件 × 30,000(用户端) │
│ 替代 V3 的 Collector Pod × 3 │
│ 采集结果通过 HTTP 回传 API │
│ API 投递到 RabbitMQ 异步处理 │
└─────────────────────────────────────┘
```
**对比 V3 的变更**
- 去掉 Collector Pod × 3(插件替代)
- 去掉 Queue Redis Sentinel → 替换为 **RabbitMQ**(功能更强:死信队列、重试、多队列路由)
- 新增 **Monitor Worker** 进程(消费 MQ 消息,执行解析/入库/聚合)
- V3 的 Aggregator 独立进程合并到 Monitor Worker 中(AggregateWorker 消费者)
### 7.2 资源规格
| 组件 | 实例数 | 规格 | 月成本(阿里云) |
| --- | --- | --- | --- |
| **tenant-api** | 2HPA 2-4 | 4C8G | ¥2,400 |
| **Cache Redis** | 1 主 1 从 | 4G 内存 | ¥1,500 |
| **RabbitMQ** | 1(镜像队列可选主从) | 2C4G | ¥800 |
| **PG Primary** | 1 | 4C16G RDS | ¥2,500 |
| **PG Replica** | 1 | 2C8G 只读 | ¥1,000 |
| **MinIO / OSS** | 1 | 50G | ¥200 |
| **Monitor Worker** | 1 | 2C4G | ¥600 |
| **SLB + CDN** | - | - | ¥500 |
| **合计** | | | **¥9,500/月** |
### 7.3 连接池修订
```yaml
# 每个 API 实例
database:
max_open_conns: 20
# 2 API 实例 → PG 总连接: 40
# 1 Monitor Worker → PG 连接: 15
# 合计: 55 连接
# PostgreSQL: max_connections = 100 (留余量)
```
---
## 8. 降级与容灾(扩展 V3 第 6 章)
### 8.1 采集侧降级
| 故障场景 | 降级措施 | 用户感知 |
| --- | --- | --- |
| 某 AI 平台改版导致 adapter 失效 | 该平台任务自动跳过,其他平台正常 | 该平台数据缺失,标记"暂不可用" |
| 插件安装率不足 / 用户不在线 | 未完成任务次日继续;Dashboard 展示已采集数据 | 部分平台数据延迟 1-2 天 |
| 采集完成率 < 70% | 告警通知;可选开启少量 API 采集补充 | 无感知(API 降级透明) |
| AI 平台风控封禁用户 | 降低该平台采集频率;通知用户重新登录 | 该平台数据暂停 |
| 全部 AI 平台不可用 | Dashboard 展示最后一次成功聚合数据 | 显示"数据更新时间" |
### 8.2 API 采集降级通道(可选)
保留 V3 的 API 采集能力作为降级通道。当插件采集完成率连续 3 天 < 70% 时,自动为该租户开启 API 采集:
```go
// 降级判断(每日聚合后执行)
func (s *MonitoringService) CheckCollectionCoverage(ctx context.Context, tenantID uuid.UUID) {
rate := s.repo.GetCollectionCompletionRate(ctx, tenantID, 3) // 近 3 天
if rate < 0.70 {
s.enableAPIFallback(ctx, tenantID)
}
}
```
### 8.3 读侧降级(保留 V3 不变)
V3 第 6 章的 Cache Redis 故障、PG 故障、Aggregator 异常等降级策略完全保留。
### 8.4 监控告警
| 指标 | 告警阈值 | 说明 |
| --- | --- | --- |
| API P99 延迟 | > 2s 持续 5 分钟 | 同 V3 |
| Cache Redis 命中率 | < 80% | 同 V3 |
| PG 连接池使用率 | > 80% | 同 V3 |
| 日采集完成率 | < 70% | **新增**,按平台分别告警 |
| 单平台 adapter 连续失败 | > 50 次/小时 | **新增**,可能平台改版 |
| 聚合 Job 未在 8:00 前完成 | 超时 | 同 V3 |
| 待采集任务堆积 | > 50,000 pending | **新增**,可能插件采集能力不足 |
---
## 9. 插件方案的风险与反检测
### 9.1 风险矩阵
| 风险 | 严重性 | 概率 | 对策 |
| --- | --- | --- | --- |
| AI 平台 Web 端改版 | 高 | 中 | adapter 独立维护,改版快速热更新;多平台冗余 |
| AI 平台检测自动化行为 | 高 | 中 | 控制频率 ≤ 3 次/平台/小时;模拟真实行为;随机延迟 |
| 用户未登录目标 AI 平台 | 中 | 高 | 跳过未登录平台;引导登录;多用户互补覆盖 |
| 采集数据质量不一致 | 中 | 中 | 同一问题多次采集取中位;异常过滤 |
| 采集延迟(依赖用户在线) | 中 | 中 | T+1 模型兼容延迟;次日继续 |
### 9.2 反检测策略
```typescript
const ANTI_DETECT_CONFIG = {
// 频率控制
maxQueriesPerPlatformPerHour: 3,
maxDailyQueries: 50,
// 时间随机化
minIntervalSec: 30,
maxIntervalSec: 120,
// 行为模拟
typeDelay: { min: 50, max: 150 }, // 逐字输入延迟 ms
postAnswerDelay: { min: 2000, max: 5000 }, // 回答后停留 ms
// 请求特征
randomizeUserAgent: false, // 使用浏览器真实 UA
addRandomHeaders: false, // 不添加额外头,避免特征
};
```
---
## 10. 压测验收标准(扩展 V3 第 7 章)
### 10.1 读侧压测(保留 V3 不变)
V3 第 7 章的 S1-S5 场景、SLA 指标完全保留。
### 10.2 新增:插件采集压测
| 场景 | 方法 | 目标 |
| --- | --- | --- |
| S7: 1000 并发插件实例同时回传结果 | 模拟 1000 个 POST /api/callback/plugin/monitor 并发 | P99 < 500ms0 丢失 |
| S8: 任务领取竞争 | 500 个并发 claim 同一任务 | 只有 1 个成功,其余 409 |
| S9: 高频任务生成 | 生成 100K 采集任务 | 生成耗时 < 30s |
### 10.3 数据质量验证
| 验证项 | 方法 | 目标 |
| --- | --- | --- |
| 回答完整性 | 同一问题 3 次采集,对比回答长度 | 长度标准差 < 30% |
| 品牌提及准确率 | 人工标注 100 条回答,对比解析结果 | 准确率 > 90% |
| 引用提取率 | 有引用的平台,对比插件提取 vs 手动检查 | 召回率 > 85% |
---
## 11. 实施计划
### 11.1 Phase 分期
| Phase | 内容 | 估时 | 依赖 |
| --- | --- | --- | --- |
| A | 插件 AI 适配器框架 + 首批 3 平台(DeepSeek/千问/豆包) | 8 天 | 无 |
| B | 后端:Migration + 配额 + RabbitMQ 基础设施 + 采集回调 API + 插件认证 API | 8 天 | A(接口定义) |
| C | 后端:Monitor WorkerParseWorker + AggregateWorker+ 缓存层 + 查询 API | 8 天 | B |
| D | 前端:6 个数据追踪页面 + 采集控制面板 | 7 天 | C(API 就绪) |
| E | 第二批 3 个 AI 适配器(Kimi/文心/混元)+ 联调 | 5 天 | A + D |
| F | 压测 + 数据质量验证 + 验收 | 5 天 | E |
| **合计** | | **41 天** | |
Phase B 细分:
- RabbitMQ 客户端封装 + Exchange/Queue 声明(1 天)
- Migration(配额表 + 任务表 + AI 平台表 + Schema Delta)(2 天)
- 插件认证 API`/api/plugin/monitoring/tasks``/claim`)(2 天)
- 采集回调 API(接收 → 投递 MQ)(1 天)
- TaskGenerateWorker(凌晨批量生成任务)(2 天)
Phase C 细分:
- ParseWorkerMQ 消费 → 解析 → 入库 → MinIO)(3 天)
- AggregateWorker(品牌级增量聚合)(2 天)
- 缓存层(singleflight + stale-while-revalidate + L1)(1 天)
- 查询 API6 个页面 + composite)(2 天)
双人并行方案(前后端各 1 人):
```
Week 1-2: 后端 Phase B (MQ + Migration + API) | 插件 Phase A (适配器)
Week 3-4: 后端 Phase C (Worker + 缓存 + 查询) | 前端 Phase D (页面)
Week 5: 联调 Phase E | 第二批适配器
Week 6: 压测 Phase F
总计: 28 天
```
### 11.2 关键里程碑
| 时间点 | 里程碑 | 验收标准 |
| --- | --- | --- |
| Day 8 | 首个 AI 适配器可用 | DeepSeek 登录检测 + 提问 + 获取回答 E2E 通过 |
| Day 10 | RabbitMQ 基础设施就绪 | Exchange/Queue 声明完成,Publish/Consume 冒烟测试通过 |
| Day 16 | 后端采集链路通 | 插件回传 → MQ → ParseWorker → PG 入库全链路通过 |
| Day 24 | 查询 API + 增量聚合可用 | 品牌任务完成后分钟级数据可见 |
| Day 31 | 前端页面完成 | 6 个页面 + 采集面板可交互 |
| Day 36 | 全部 6 平台适配器可用 | 全平台检测 + 采集 E2E |
| Day 41 | 验收通过 | 读侧压测 SLA + MQ 吞吐压测 + 数据质量验证 |
---
## 12. V3 → V4 变更汇总
| 变更编号 | V3 方案 | V4 方案 | 原因 |
| --- | --- | --- | --- |
| **C1** | 服务端 AI API 采集(30 worker × 3 Pod | 浏览器插件采集(30,000 用户端节点) | 消除 AI API 成本 |
| **C2** | Queue Redis Stream 任务队列 | RabbitMQ 异步处理(解析/入库/聚合) + PG 任务表 + Redis 分布式锁 | 插件 HTTP 回传 → MQ 异步解耦,API 不阻塞 |
| **C3** | 100 品牌 × 100 问题 | 配额限制(Free 1 品牌 / Pro 3 品牌,每品牌 40 问题) | 控制采集规模 |
| **C4** | 全品牌每日采集 | Free 每 3 天 / Pro 每天 | 降低采集压力 |
| **C5** | `run_mode`: api_standard / api_search_grounded | `run_mode`: plugin_standard / plugin_search | 采集来源变更 |
| **C6** | 月成本 ~¥42K(含 API ¥22K | 月成本 ~¥9.5K(无 API 费用) | 成本优化 77% |
| **C7** | 无配额机制 | `tenant_monitoring_quotas` 租户级配额 | 支撑多租户规模化 |
| **C8** | 3 个 Collector Pod + Queue Redis | Collector → 插件替代;Queue Redis → RabbitMQ + Monitor Worker | 采集去中心化,处理异步化 |
| **C9** | 数据一致性高(固定 prompt/温度) | 数据更接近真实用户(Web 端采集) | 采集可行性方案确认 API ≠ Web |
| **新增** | 无 | 反检测策略 | 插件采集必须的安全措施 |
| **新增** | 无 | 采集控制面板(前端) | 用户管理采集进度和平台登录 |
| **新增** | 无 | API 降级通道 | 插件采集不足时的兜底方案 |
| **新增** | 无 | RabbitMQ 异步处理架构 | 解析/入库/聚合与 API 层解耦,插件回调 ~10ms 返回 |
| **新增** | 每日 6:30 全量定时聚合 | 品牌级增量聚合(MQ 触发) | 数据可见延迟从 T+1 降至准实时(分钟级) |
| **新增** | 无 | Monitor Worker 独立进程 | ParseWorker × 3 + AggregateWorker + TaskGenWorker |
| **新增** | 无 | 死信队列 (DLQ) | 处理失败的消息暂存,不丢数据 |