diff --git a/apps/desktop-client/electron-builder.yml b/apps/desktop-client/electron-builder.yml index 208f4ea..66d3e70 100644 --- a/apps/desktop-client/electron-builder.yml +++ b/apps/desktop-client/electron-builder.yml @@ -1,18 +1,72 @@ appId: com.geo.rankly.desktop productName: GEO Rankly Desktop +copyright: Copyright © 2026 GEO Rankly directories: output: release -files: - - out/** + buildResources: build +compression: maximum +removePackageScripts: true +removePackageKeywords: true asar: true +asarUnpack: + - "**/node_modules/better-sqlite3/build/Release/**" + - "**/node_modules/better-sqlite3/lib/**" + - "**/node_modules/bindings/**" + - "**/node_modules/file-uri-to-path/**" + - "**/node_modules/playwright-core/lib/**" + - "**/node_modules/playwright-core/bin/**" +files: + - out/**/* + - package.json + - "!**/*.map" + - "!**/*.ts" + - "!**/*.tsx" + - "!**/*.md" + - "!**/*.markdown" + - "!**/{test,tests,__tests__,__mocks__,example,examples,docs,doc,demo,samples,man}/**" + - "!**/{LICENSE,LICENSE.*,license,CHANGELOG,CHANGELOG.*,README,README.*,HISTORY,HISTORY.*,AUTHORS,CONTRIBUTING}*" + - "!**/.*" + - "!**/node_modules/.bin/**" + - "!**/node_modules/*/{Makefile,Gulpfile.js,Gruntfile.js,CMakeLists.txt,*.coffee,*.flow,*.c,*.cc,*.cpp,*.h,*.hpp,*.gyp,*.gypi,*.tgz,binding.gyp}" + - "!**/node_modules/**/{*.d.ts,tsconfig*.json,jest.config*,rollup.config*,webpack.config*,vite.config*}" + - "!**/node_modules/better-sqlite3/{deps,src,docs,benchmark}/**" + - "!**/node_modules/better-sqlite3/{test_extension.c,download.sh,*.gyp,*.gypi}" + - "!**/node_modules/playwright-core/{types,ThirdPartyNotices.txt}/**" + - "!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}" mac: category: public.app-category.productivity + identity: null + notarize: false + hardenedRuntime: false + gatekeeperAssess: false + electronLanguages: + - en + - zh_CN target: - - dmg - - zip + - target: dmg + arch: [arm64] + - target: zip + arch: [arm64] +dmg: + format: ULFO + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications win: target: - - nsis + - target: nsis + arch: [x64] +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true linux: + category: Office target: - - AppImage + - target: AppImage + arch: [x64] +publish: null diff --git a/apps/desktop-client/electron.vite.config.ts b/apps/desktop-client/electron.vite.config.ts index 7e932f5..420dd3b 100644 --- a/apps/desktop-client/electron.vite.config.ts +++ b/apps/desktop-client/electron.vite.config.ts @@ -8,9 +8,25 @@ import { AntDesignVueResolver } from "unplugin-vue-components/resolvers"; const rootDir = fileURLToPath(new URL(".", import.meta.url)); +const workspacePackages = [ + "@geo/shared-types", + "@geo/http-client", + "@geo/publisher-platforms", + "@geo/ui-shared", +]; + +const workspaceAliases = { + "@geo/shared-types": resolve(rootDir, "../../packages/shared-types/src/index.ts"), + "@geo/http-client": resolve(rootDir, "../../packages/http-client/src/index.ts"), + "@geo/publisher-platforms": resolve(rootDir, "../../packages/publisher-platforms/src/index.ts"), + "@geo/ui-shared": resolve(rootDir, "../../packages/ui-shared/src/index.ts"), + "@geo/ui-shared/tokens": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.ts"), + "@geo/ui-shared/tokens.css": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.css"), +}; + export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + plugins: [externalizeDepsPlugin({ exclude: workspacePackages })], build: { rollupOptions: { input: { @@ -26,11 +42,12 @@ export default defineConfig({ resolve: { alias: { "@main": resolve(rootDir, "src/main"), + ...workspaceAliases, }, }, }, preload: { - plugins: [externalizeDepsPlugin()], + plugins: [externalizeDepsPlugin({ exclude: workspacePackages })], build: { rollupOptions: { input: { @@ -46,6 +63,7 @@ export default defineConfig({ resolve: { alias: { "@preload": resolve(rootDir, "src/preload"), + ...workspaceAliases, }, }, }, @@ -64,11 +82,7 @@ export default defineConfig({ resolve: { alias: { "@renderer": resolve(rootDir, "src/renderer"), - "@geo/shared-types": resolve(rootDir, "../../packages/shared-types/src/index.ts"), - "@geo/http-client": resolve(rootDir, "../../packages/http-client/src/index.ts"), - "@geo/ui-shared": resolve(rootDir, "../../packages/ui-shared/src/index.ts"), - "@geo/ui-shared/tokens": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.ts"), - "@geo/ui-shared/tokens.css": resolve(rootDir, "../../packages/ui-shared/src/tokens/index.css"), + ...workspaceAliases, }, }, }, diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index 808955a..20d8f93 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -2,36 +2,39 @@ "name": "@geo/desktop-client", "version": "0.1.0", "private": true, + "description": "GEO Rankly Desktop — account health, monitor scheduler, and publish dispatch client.", + "author": { + "name": "GEO Rankly", + "email": "support@geo-rankly.local" + }, "type": "module", "main": "out/main/bootstrap.cjs", "scripts": { "dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev --inspect=9229", "build": "electron-vite build", - "package:mac": "electron-builder --mac --arm64 ", + "package:mac": "electron-builder --mac --arm64", "test": "vitest run", "typecheck": "vue-tsc --noEmit" }, "dependencies": { - "@ant-design/icons-vue": "^7.0.1", - "@geo/http-client": "workspace:*", - "@geo/publisher-platforms": "workspace:*", - "@geo/shared-types": "workspace:*", - "@geo/ui-shared": "workspace:*", - "ant-design-vue": "^4.2.6", "better-sqlite3": "^12.8.0", "electron-updater": "^6.0.0", "marked": "^17.0.5", "pino": "^9.0.0", "playwright-core": "^1.55.0", - "vue": "^3.5.31", - "vue-router": "^4.5.1", "ws": "^8.20.0" }, "devDependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@geo/http-client": "workspace:*", + "@geo/publisher-platforms": "workspace:*", + "@geo/shared-types": "workspace:*", + "@geo/ui-shared": "workspace:*", "@playwright/test": "^1.0.0", "@types/node": "^24.0.0", "@types/ws": "^8.18.1", "@vitejs/plugin-vue": "^5.2.4", + "ant-design-vue": "^4.2.6", "electron": "41.2.0", "electron-builder": "^25.0.0", "electron-vite": "^2.0.0", @@ -39,6 +42,84 @@ "unplugin-vue-components": "^32.0.0", "vite": "^5.4.19", "vitest": "^2.0.0", + "vue": "^3.5.31", + "vue-router": "^4.5.1", "vue-tsc": "^3.2.6" + }, + "build": { + "appId": "com.geo.rankly.desktop", + "productName": "GEO Rankly Desktop", + "copyright": "Copyright © 2026 GEO Rankly", + "directories": { + "output": "release", + "buildResources": "build" + }, + "compression": "maximum", + "removePackageScripts": true, + "removePackageKeywords": true, + "asar": true, + "asarUnpack": [ + "**/node_modules/better-sqlite3/build/Release/**", + "**/node_modules/better-sqlite3/lib/**", + "**/node_modules/bindings/**", + "**/node_modules/file-uri-to-path/**", + "**/node_modules/playwright-core/lib/**", + "**/node_modules/playwright-core/bin/**" + ], + "files": [ + "out/**/*", + "package.json", + "!**/*.map", + "!**/*.ts", + "!**/*.tsx", + "!**/*.md", + "!**/*.markdown", + "!**/{test,tests,__tests__,__mocks__,example,examples,docs,doc,demo,samples,man}/**", + "!**/{LICENSE,LICENSE.*,license,CHANGELOG,CHANGELOG.*,README,README.*,HISTORY,HISTORY.*,AUTHORS,CONTRIBUTING}*", + "!**/.*", + "!**/node_modules/.bin/**", + "!**/node_modules/*/{Makefile,Gulpfile.js,Gruntfile.js,CMakeLists.txt,*.coffee,*.flow,*.c,*.cc,*.cpp,*.h,*.hpp,*.gyp,*.gypi,*.tgz,binding.gyp}", + "!**/node_modules/**/{*.d.ts,tsconfig*.json,jest.config*,rollup.config*,webpack.config*,vite.config*}", + "!**/node_modules/better-sqlite3/{deps,src,docs,benchmark}/**", + "!**/node_modules/better-sqlite3/{test_extension.c,download.sh,*.gyp,*.gypi}", + "!**/node_modules/playwright-core/{types,ThirdPartyNotices.txt}/**", + "!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}" + ], + "mac": { + "category": "public.app-category.productivity", + "identity": null, + "electronLanguages": ["en", "zh_CN"], + "target": [ + { "target": "dmg", "arch": ["arm64"] }, + { "target": "zip", "arch": ["arm64"] } + ], + "notarize": false, + "hardenedRuntime": false, + "gatekeeperAssess": false + }, + "dmg": { + "format": "ULFO", + "contents": [ + { "x": 130, "y": 220 }, + { "x": 410, "y": 220, "type": "link", "path": "/Applications" } + ] + }, + "win": { + "target": [ + { "target": "nsis", "arch": ["x64"] } + ] + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true + }, + "linux": { + "target": [ + { "target": "AppImage", "arch": ["x64"] } + ], + "category": "Office" + }, + "publish": null } } diff --git a/docs/ai-brand-monitoring-priority-dispatch-design-v1.1.md b/docs/ai-brand-monitoring-priority-dispatch-design-v1.1.md new file mode 100644 index 0000000..ba474ce --- /dev/null +++ b/docs/ai-brand-monitoring-priority-dispatch-design-v1.1.md @@ -0,0 +1,1403 @@ +# AI 品牌监测优先级调度与立即采集抢占方案 V1.1 + +## 1. 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档名称 | AI 品牌监测优先级调度与立即采集抢占方案 V1.1 | +| 文档版本 | V1.1 | +| 文档状态 | 修订完成,待二次评审 | +| 创建日期 | 2026-04-20 | +| 修订日期 | 2026-04-20 | +| 上一版本 | `docs/ai-brand-monitoring-priority-dispatch-design-v1.md`(V1.0) | +| 适用范围 | `monitoring` 采集链路、`scheduler`、`tenant-api`、`desktop client` | +| 关联文档 | `docs/ai-brand-monitoring-tech-design-v5.md` | +| 关联文档 | `docs/geo-platform-ops-admin-tech-architecture-v1.md` | +| 当前基线 | `desktop client (pull-lease) + monitoring_collect_tasks + RabbitMQ result ingest + projection rebuild` | + +## 2. 修订记录(V1.0 → V1.1) + +本版本针对 V1.0 架构评审中的 5 项 Critical 与 7 项 Warning 做全面修订: + +| 编号 | 原问题 | 修订章节 | 修订摘要 | +| --- | --- | --- | --- | +| C1 | `status` 与 `execution_status` 二选一决策留白 | §7.1 | 明确不新增 `execution_status`;Phase 1 继续复用现有 `status`,Phase 2 执行权威迁至 `desktop_tasks` | +| C2 | `interrupt_generation` 语义未定义,存在抢占竞态 | §10.2 | 新增代际规范:归属、递增规则、事件携带、回写比对 | +| C3 | Phase 1→2 迁移期双写一致性空白 | §16.2 | 引入 `execution_owner` 路由字段 + 灰度 + 回滚 + reconcile | +| C4 | 抢占 15s/60s 硬编码无经验基线 | §10.5 | 改为 per-platform 可配置参数,附测量落地流程 | +| C5 | dispatch 预算四维耦合关系未定义 | §9.2 | 新增决策树、拒绝处置矩阵、high lane 绕过规则 | +| W1 | `target_client_id` 设备漂移场景未覆盖 | §10.6 | 新增绑定时机、选择规则、漂移 4 场景处置、TTL | +| W2 | aging 简单加分会集体失效 | §9.4 | 改为百分位触发 + 对数衰减 | +| W3 | publish/monitor 共表 priority 值域未规范 | §7.3 | 新增跨 kind priority 值域表 + lane_weight 排序 | +| W4 | 伪代码 16.1 单大事务 + 外部副作用 | §17.1 | 改为 outbox 模式,短事务 + 独立 pump | +| W5 | Phase 1 抢占语义与双层模型矛盾 | §16.1 | 收敛 Phase 1 范围:只抢占 queued/leased,不抢 running | +| W6 | desktop pull→push 过渡期通道空窗 | §14.3 §16.1 | **Phase 1 即启用 WebSocket 通道,SSE `/api/desktop/events` Phase 1 完成后立即下线**(修订 2:用户明确要求早期拿掉 SSE) | +| W7 | 幂等键与现有表约束未对齐 | §12.3 | 对齐 `question_monitor_runs`、`projection_rebuild_queue` 现有键 | +| I1 | §6.1 当前态描述偏旧 | §6.1 | 更新 `monitor.projection.rebuild` 已存在 | +| I2 | `ready` 与 `pending` 混用 | 全文 | 统一为 `pending` | +| I3 | `collection_mode` 字段语义在插件下线后歧义 | §13.1 | 保留用于日志审计,文档明确说明 | +| I4 | 观测指标不足 | §15.1 | 补充 `preempt_induced_retry`、`safe_point_interval`、`dispatch_reject_by_reason` 等 | + +## 3. 背景与问题 + +当前监测采集已经具备以下基础能力: + +1. `collect-now` 已存在入口,能够把当天任务重置回可执行状态。 +2. desktop client 已具备任务租约、续租、回写结果、使用 WS 推送等基础设施(monitor 仍以 pull-lease 为主,其实是无法正常使用的,不用太在意;publish 已使用 WS push(可用,体验非常好);旧 SSE `/api/desktop/events` **Phase 1 即下线**,所有事件统一走 WebSocket)。 +3. 结果上报已走 `API -> RabbitMQ -> ingest worker -> Monitoring PG` 的异步写链路,`monitor.projection.rebuild` worker 与 `MonitoringProjectionRebuildQueue` 已存在并完成聚合投影合并。 + +但当前实现仍存在几个关键缺口: + +1. `collect-now` 只能重置 `pending / expired / failed / completed / skipped`,不会抢占已经 `leased / running` 的定时任务。 +2. monitoring 任务当前仍以"按 `planned_at` 顺序从 DB 拉取"作为主调度信号,缺少显式优先级和抢占语义。 +3. monitoring 尚未像 publish 一样迁移到 `RabbitMQ -> tenant-api -> desktop dispatch websocket` 的低延迟推送链路。 +4. 定时任务如果按分钟大批量扫到期后直接下发,容易把 desktop client、结果回写链路和数据库同时打满。 +5. 结果入库虽然已异步化,但"原始事实写入"的分片串行尚未落地,高峰期仍有打挂库风险。 + +本方案要解决的核心问题是: + +1. 每日定时采集如何稳态运行,不形成瞬时巨量计算洪峰。 +2. `collect-now` 如何保证明显高于定时任务的体验优先级。 +3. 当定时任务正在执行时,如何安全让路给 `collect-now`。 +4. 结果如何在不牺牲吞吐的前提下做到局部有序、幂等落库。 + +## 4. 目标与非目标 + +### 4.1 目标 + +1. `collect-now` 从点击到 desktop client 开始执行的链路尽量走低延迟通道。 +2. `collect-now` 对同一 client 上的定时监测任务具备抢占权。 +3. 定时调度采用"分批 claim + 按预算 dispatch",而不是"扫到即全量下发"。 +4. 数据库只承担真相存储与有界写入,不承担大规模实时排队压力。 +5. 结果入库按业务分片串行,局部有序,不要求全局单线程。 +6. 全链路具备幂等、回放、超时回收、死信与可观测性。 + +### 4.2 非目标 + +1. 不要求一次 `collect-now` 抢占整个租户所有后台任务;默认只抢占目标 client 上的定时监测任务。 +2. 不要求依赖 RabbitMQ 单队列优先级特性解决全部排序问题(没顺序要求)。 +3. 不要求 desktop client 直接连接 RabbitMQ(不可以直接连接 RabbitMQ)。 +4. 不在本方案内重做全部监测业务口径,仅聚焦任务控制面与执行链路。 + +## 5. 核心结论 + +### 5.1 任务真相源 + +推荐采用"双层任务模型"(Phase 2 全量引入): + +1. `monitoring_collect_tasks` 继续作为"业务任务真相源",表示某品牌、某问题、某平台、某业务日是否需要采集。 +2. `desktop_tasks(kind=monitor)` 作为"执行任务控制面",承接优先级、抢占、租约、取消、推送与恢复。 + +换句话说: + +- `monitoring_collect_tasks` 负责"要不要做" +- `desktop_tasks(kind=monitor)` 负责"怎么派发给哪台 desktop 做" + +这样可以复用现有 publish 的成熟能力,而不是让 monitoring 永久维持一套独立调度机制。 + +**过渡期说明**:Phase 1 不引入 `desktop_tasks(kind=monitor)`,执行态从 `monitoring_collect_tasks` 的 `lease_expires_at / leased_to_executor` 推导;Phase 2 按 tenant 灰度迁移,详见 §16.2。 + +### 5.2 RabbitMQ 的角色 + +RabbitMQ 只负责运输、削峰、解耦和异步化,不作为唯一真相源。 + +推荐边界: + +1. 下行:`DB claim -> RabbitMQ dispatch event -> tenant-api dispatch hub -> desktop websocket` +2. 上行:`desktop callback -> tenant-api receive -> RabbitMQ result ingest -> worker -> Monitoring PG` + +不推荐 desktop client 直连 RabbitMQ,原因: + +1. 证书与凭证分发复杂。 +2. 网络拓扑和私有化部署适配更难。 +3. 权限收敛、审计、灰度、风控和回放都不如经由 API 网关统一处理稳定。 + +### 5.3 优先级策略 + +推荐采用"多队列 + 服务端调度预算": + +1. `high`:`collect-now` +2. `normal`:每日定时 +3. `retry`:失败重试、过期回收 + +不要依赖 RabbitMQ 单队列 `x-max-priority` 解决一切问题。大厂常见做法是多队列分层,再由业务侧决定是否暂停 `normal` lane。 + +### 5.4 抢占策略 + +抢占采用"协作式抢占",不做粗暴 kill: + +1. `queued` 的执行任务:直接取消或降级回 `normal` 队列。 +2. `leased` 但尚未开始浏览器交互:不在服务端盲目清租约,而是下发 `task_control`;desktop 只在本地单线程调度循环中、且 `executionPhase in (PREPARING, NAVIGATING)` 时才主动 cancel lease 并回队(见 §14.2 / §14.4)。 +3. `running` 的执行任务(**Phase 2 起支持**):下发 `interrupt_requested`,desktop client 在安全点主动中断并回写 `aborted(preempted)`。 +4. 如果 `interrupt_soft_timeout` 内无法安全中断,则禁止新的 `normal` 任务继续进入该 client,待当前任务结束后立即执行 `collect-now`。 + +本文中“尚未真正开始执行”严格等价于 desktop 本地 `executionPhase in (PREPARING, NAVIGATING)`;自 `AUTHENTICATING` 起一律视为“已开始”,Phase 1 不再直接 cancel,Phase 2 改为在安全点处理中断。 + +### 5.5 数据库保护策略 + +数据库保护依赖四层手段: + +1. 调度限流:到期任务先 claim,不立刻全量 dispatch。 +2. 写链路拆分:原始事实写入与聚合重算分开(已落地)。 +3. 分片串行:按 `(tenant_id, brand_id, business_date)` 做结果分片。 +4. 局部重算:只重算受影响的品牌/日期快照,不做全局全表聚合(已落地)。 + +## 6. 当前态与目标态 + +### 6.1 当前态(V1.1 更正) + +```text +schedule/collect-now API (status reset only) + -> monitoring_collect_tasks (status=pending) + -> desktop client 定时 pull lease + -> desktop 执行 + -> API callback + -> RabbitMQ monitor.result.ingest (已存在) + -> ingest worker (已存在) + -> Monitoring PG raw facts + -> RabbitMQ monitor.projection.rebuild (已存在) + -> projection rebuild worker (已存在,带 coalesce) + -> monitoring_brand_daily / monitoring_brand_platform_daily +``` + +特点(实际代码核实): + +1. 上行结果链路合理,projection rebuild 已带 coalesce。 +2. 下行仍偏 DB 轮询,无 push 推送。 +3. `collect-now` 目前只做"重置可执行状态",不做优先级提升也不做抢占(§`server/internal/tenant/app/monitoring_service.go` 中 `CollectNow` 方法)。 +4. SSE `/api/desktop/events` 存在且仍在使用,**Phase 1 即下线**,所有事件统一走 WebSocket `desktop.task.dispatch`。 +5. `desktop_tasks` 表已存在并支持 `kind=publish \| monitor`,但 monitor kind 目前**未被任何调度逻辑使用**。 + +### 6.2 推荐目标态 + +```text +schedule scanner / collect-now API + -> monitoring_collect_tasks (业务真相,带 lane/priority/target_client_id) + -> monitor dispatch coordinator (按预算 claim execution slots) + -> desktop_tasks(kind=monitor) (执行控制面) + -> RabbitMQ desktop.task.dispatch (复用 publish 通道) + -> tenant-api dispatch hub + -> desktop websocket + -> desktop lease / execute / extend / cancel / complete + -> monitor result receive API + -> RabbitMQ monitor.result.ingest (分片串行消费) + -> ingest worker + -> Monitoring PG raw facts + -> RabbitMQ monitor.projection.rebuild (已有 coalesce) + -> projection worker + -> monitoring_brand_daily / monitoring_brand_platform_daily +``` + +### 6.3 渐进落地原则 + +推荐分三阶段,详见 §16: + +1. Phase 1:业务任务表加优先级字段 + collect-now 高优提升 + queued/leased 抢占(不抢 running)+ 直接复用 WebSocket 下发 `task_available / task_control`,SSE 同步下线。 +2. Phase 2:引入 `desktop_tasks(kind=monitor)` + 收敛执行控制面 + 支持 running 任务抢占。 +3. Phase 3:ingest 分片串行 + 多租户公平调度 + aging + 自动降速。 + +## 7. 任务模型设计 + +### 7.1 业务任务:`monitoring_collect_tasks`(Phase 1 新增字段) + +**架构决策(C1)**:**不新增** `execution_status` 字段,但必须明确“谁是唯一执行真相源”。Phase 1 沿用现有 `status` 承载 legacy 执行流转;Phase 2 当 `execution_owner='desktop_tasks'` 时,`desktop_tasks.status` 成为**唯一执行真相**,`monitoring_collect_tasks.status` 只保留业务终态,不再镜像中间执行态,也不再作为新路径的租约判断依据。 + +`status` 兼容值保持与当前实现一致: + +| 值 | 语义 | +| --- | --- | +| `pending` | 待执行或待再次派发 | +| `leased` | 已被 desktop 领取租约(legacy 路径) | +| `received` | desktop 已回调结果,等待 ingest worker 最终落库 | +| `completed` | 今天已完成 | +| `failed` | 今天尝试但失败(达到最大重试) | +| `skipped` | 今天跳过(配额/关闭/排除) | +| `expired` | 今天到期未完成 | + +约束说明: + +1. `execution_owner='legacy'` 时,`LeaseTasks / ResumeTasks / expire / result callback` 继续依赖本表 `status`,保持与现网代码一致。 +2. `execution_owner='desktop_tasks'` 时,执行归属、抢占、续租、取消与中间执行态一律以 `desktop_tasks.status` 为准;`monitoring_collect_tasks.status` 只允许保留 `pending / completed / failed / skipped / expired` 五类业务态,**禁止**写入 `leased / received`。 +3. Phase 2 迁移期不得同时让两张表都成为“执行真相源”,否则会出现双状态竞争。 +4. Phase 2 灰度仅影响新生成任务;已 in-flight 的 `legacy` 存量任务继续沿用旧写法直至自然结束,不做半程切换。 + +**Phase 2 状态写入矩阵(强制约束)**: + +| 场景 | `execution_owner='legacy'` | `execution_owner='desktop_tasks'` | +| --- | --- | --- | +| dispatch / lease 中 | `monitoring_collect_tasks.status` 可写 `leased` | `monitoring_collect_tasks.status` 保持 `pending`;执行细节写 `desktop_tasks.status` | +| desktop 回调结果已接收 | `monitoring_collect_tasks.status` 可写 `received` | `monitoring_collect_tasks.status` 仍保持 `pending`;原始结果照常入 MQ,等待最终终态 | +| 最终成功 | `monitoring_collect_tasks.status = completed` | `monitoring_collect_tasks.status = completed` | +| 最终失败 / 跳过 / 过期 | `failed / skipped / expired` | `failed / skipped / expired` | + +**下游兼容策略**: + +| 下游 | 读取策略 | +| --- | --- | +| UI | `monitoring_collect_tasks.status` 仅用于“是否已完成/是否终态”判断;当前执行细节读取 active `desktop_tasks.status` | +| 报表 | 以 `monitoring_collect_tasks` 为业务主表;若需要执行维度(排队、执行中、重试中),显式 join `desktop_tasks` | +| 审计 | 业务审计读 `monitoring_collect_tasks`;执行与 attempt 细节读 `desktop_tasks` / `desktop_task_attempts` | +| reconcile | `execution_owner='desktop_tasks'` 时只对账“是否存在 active desktop_task / 是否收敛为正确终态”,不再比较 `leased / received` 之类中间态 | + +**术语约定**: + +1. 业务任务层使用 `completed`,表示“该业务日已完成采集”。 +2. 执行任务层与 `monitoring_collect_requests` 使用 `succeeded`,表示“本次执行 / 本次请求成功结束”。 +3. 两套命名是刻意区分业务语义与执行语义,不是 typo。 + +新增字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `trigger_source` | varchar(20) | `automatic` / `manual`(已存在,保留) | +| `dispatch_priority` | int | 服务端调度排序值,见 §7.3 值域规范 | +| `dispatch_lane` | varchar(20) | `high` / `normal` / `normal_boosted` / `retry` | +| `target_client_id` | uuid nullable | 绑定的 desktop client,见 §10.6 | +| `dispatch_after` | timestamptz | 不早于该时间派发 | +| `interrupt_generation` | int | 抢占代际,详见 §10.2 | +| `superseded_by_request_id` | uuid nullable | 被哪次手动请求覆盖 | +| `last_dispatched_at` | timestamptz nullable | 最近一次派发时间 | +| `dispatch_attempts` | int | 派发次数 | +| `ingest_shard_key` | varchar(120) | 结果入库分片键,见 §12.2 | +| `execution_owner` | varchar(20) | `legacy` / `desktop_tasks`,Phase 1→2 过渡路由字段,见 §16.2 | + +说明: + +1. `dispatch_priority` 不直接等同于 RabbitMQ 优先级,而是服务端调度排序依据。 +2. `target_client_id` 在 `collect-now` 场景必须明确绑定到当前用户在线 client;普通定时任务可为 NULL。 +3. `execution_owner` 默认 `legacy`,Phase 2 灰度切换为 `desktop_tasks`。 + +### 7.2 执行任务:`desktop_tasks(kind=monitor)`(Phase 2 新增字段) + +建议扩展已有表,增加以下字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `priority` | int | 执行优先级,见 §7.3 值域 | +| `lane` | varchar(20) | `high` / `normal` / `normal_boosted` / `retry` | +| `lane_weight` | int | 由 lane 衍生,排序用(见 §7.3) | +| `source` | varchar(20) | `collect_now` / `daily_schedule` / `retry_recovery` | +| `scheduler_group_key` | varchar(120) | 问题并发互斥键 | +| `monitor_task_id` | bigint nullable | 关联 `monitoring_collect_tasks.id` | +| `supersedes_task_id` | uuid nullable | 抢占时被替代的旧执行任务 | +| `control_flags` | jsonb | 如 `{"interrupt_requested": true, "interrupt_generation": 7}` | +| `interrupt_generation` | int | 与业务任务同步,权威副本 | +| `started_at` | timestamptz nullable | 真正开始执行时间 | +| `interrupted_at` | timestamptz nullable | 被请求中断时间 | +| `interrupt_reason` | varchar(64) | `collect_now_preempt` 等 | +| `enqueued_at` | timestamptz | 入队时间(aging 用) | + +**新增唯一约束**(防止双写): + +```sql +CREATE UNIQUE INDEX uq_desktop_tasks_monitor_active + ON desktop_tasks (monitor_task_id) + WHERE kind = 'monitor' AND status IN ('queued', 'in_progress', 'unknown'); +``` + +补充说明: + +1. `unknown` 视为“未决活跃态”,在 reconcile 前不得为同一 `monitor_task_id` 再创建新的 active monitor 执行任务。 +2. 只有当 `unknown` 被人工或自动对账收敛为 `retry / failed / succeeded / aborted` 后,才允许重新派发。 + +### 7.3 priority 值域规范(W3) + +为避免 monitor 与 publish 共表时优先级错配,严格划分值域: + +| kind | lane | priority 范围 | lane_weight | 典型值 | +| --- | --- | --- | --- | --- | +| publish | urgent | 20000–29999 | 100 | 25000 | +| publish | high | 15000–19999 | 90 | 18000 | +| publish | normal | 10000–14999 | 80 | 12000 | +| monitor | high(collect-now) | 5000–5999 | 70 | 5000 | +| monitor | normal_boosted(aged) | 1000–4999 | 50 | 1000~4999 | +| monitor | normal | 100–999 | 40 | 100 | +| monitor | retry | 10–99 | 20 | 50 | + +**统一排序规则**: + +```sql +ORDER BY lane_weight DESC, priority DESC, enqueued_at ASC +``` + +保证"优先级跨 lane 不能越级"——monitor 的 high (5000) 永远不会压过 publish 的 normal (10000),publish 始终拥有最高话语权。 + +### 7.4 手动请求表 `monitoring_collect_requests` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | uuid | 请求 ID | +| `tenant_id` | bigint | 租户 | +| `workspace_id` | bigint | 工作空间 | +| `brand_id` | bigint | 品牌 | +| `keyword_id` | bigint nullable | 可选关键词 | +| `requested_by_user_id` | bigint | 发起人 | +| `target_client_id` | uuid nullable | 本次绑定 client(可能解绑) | +| `scope_hash` | varchar(64) | 幂等用,见 §10.6 | +| `request_scope` | jsonb | 选择了哪些问题/平台 | +| `status` | varchar(30) | `accepted` / `dispatching` / `running` / `partial` / `succeeded` / `failed` / `timed_out` / `superseded` | +| `requested_task_count` | int | 目标任务数 | +| `dispatched_task_count` | int | 已派发任务数 | +| `completed_task_count` | int | 已完成任务数 | +| `first_dispatched_at` | timestamptz nullable | 第一条派发时间 | +| `first_result_at` | timestamptz nullable | 第一条结果时间 | +| `ttl_expires_at` | timestamptz | TTL,默认创建后 5 分钟 | + +作用: + +1. 给 UI 返回"这次立即采集是否真的在推进"。 +2. 支撑重复点击幂等。 +3. 支撑超时告警与链路追踪。 + +## 8. 状态机设计 + +### 8.1 业务任务状态机 + +```text +pending + -> (dispatch) -> (desktop lease+run+callback) -> completed + +pending + -> (dispatch+lease timeout) -> expired -> retry -> pending + +pending (被抢占场景,Phase 2) + -> (promote to high lane) -> (interrupt old execution) -> (new execution completed) -> completed + +pending + -> failed (超过 max_retry) + +pending + -> skipped (配额/规则排除) +``` + +约束: + +1. 同一业务日同一问题同一平台只保留一条业务任务,不复制第二套任务。 +2. `collect-now` 只提升优先级、刷新调度窗口、触发抢占,不再复制业务任务。 +3. 最终结果事实仍归并到同一业务任务。 +4. 当 `execution_owner='desktop_tasks'` 时,业务任务在执行全过程保持 `pending`,直到最终终态才跳到 `completed / failed / skipped / expired`;`leased / received` 不再写入业务表。 + +### 8.2 执行任务状态机(Phase 2 起) + +```text +queued + -> in_progress + -> succeeded + +queued + -> aborted (Phase 1/2 均支持:直接取消) + +in_progress + -> (task_control: interrupt_requested with gen) -> (desktop safe point) -> aborted (Phase 2) + +in_progress + -> failed | unknown | succeeded +``` + +补充约束: + +1. `unknown` 在 reconcile 前视为活跃执行态,与 `queued / in_progress` 一样阻止同一 `monitor_task_id` 生成新的 active monitor 执行任务。 +2. `unknown` 不是可长期堆积的常态,必须通过 `ReconcileDesktopTask` 收敛回 `queued / failed / succeeded / aborted`。 + +## 9. 调度与优先级设计 + +### 9.1 定时调度策略 + +`scheduler` 不直接把所有到期任务一股脑发出,而是分成三步: + +1. 扫描:找出 `next_run_at <= now()` 的计划任务。 +2. claim:把到期任务转成 `monitoring_collect_tasks`(设置 `dispatch_lane='normal'`、`dispatch_priority=100`、`execution_owner` 按灰度规则)。 +3. dispatch:由 `monitor dispatch coordinator` 按预算从 `monitoring_collect_tasks` 中选择一小批执行任务下发。 + +### 9.2 dispatch 预算决策树(C5) + +当 dispatch coordinator 决定是否派发一条任务时,按下列顺序串行检查,**任一失败则当前 task 跳过**: + +```text +dispatch_allowed(task) iff + 1. cluster.minute_budget.remaining > 0 + 2. tenant(task.tenant_id).minute_budget.remaining > 0 + 3. client(task.target_client_id).concurrent_monitor < client.max_concurrent + 4. client(task.target_client_id).queue_depth < client.max_queue_depth + 5. platform(task.ai_platform_id).concurrent < platform.max_concurrent +``` + +**拒绝处置矩阵**: + +| 拒绝维度 | 处置 | +| --- | --- | +| budget 类(1、2) | 暂留 ready 队列,下一分钟预算窗口重试 | +| concurrent 类(3、5) | 暂留 ready 队列,等待 `completion` 事件驱动重试 | +| queue depth 类(4) | 选择次优 client(`target_client_id=NULL` 的任务);若指定 client 饱和且 `target_client_id` 非空,暂留 | + +**预算值建议**(可配置,热更新): + +| 维度 | 初始值 | 熔断阈值 | 说明 | +| --- | --- | --- | --- | +| 集群每分钟派发上限 | 300 | 500 | 保护 ingest 链路 | +| 单租户每分钟上限 | 30 | 60 | 公平性 | +| 单 client monitor 并发 | 2 | 4 | 浏览器资源 | +| 单 client 待执行队列深度 | 20 | 40 | 避免堆积失真 | +| 单平台并发(跨 client 聚合) | 1 | 2 | 登录态/风控保护 | + +**high lane 绕过规则**: + +- `dispatch_lane='high'` 的任务**绕过** 2、4 两项(租户预算、队列深度) +- 仍受 1、3、5 约束(集群预算、client 浏览器并发、平台风控) +- 保障 collect-now 体验同时不压垮基础设施 + +**耦合说明**: + +- 集群预算 > 所有租户预算之和,预防极端峰值 +- 单 client 并发仅对 **monitor kind** 生效,不含 publish(publish 有独立通道) +- 单平台并发跨 client 聚合计数(例如 OpenAI 在 client A 并发 1 + client B 并发 0 = 平台并发 1) + +### 9.3 lane 调度规则 + +推荐调度顺序: + +1. `high` 非空时,优先耗尽 `high` +2. `high` 非空时,暂停向**同一 client** 派发新的 `normal`(其他 client 上的 normal 不受影响) +3. 全局仍保留 10%~20% 的 `normal` 背景吞吐,防止定时任务永久饿死 +4. `retry` 只在 `high` 与 `normal` 都低水位时进入 + +### 9.4 aging 提升(百分位 + 对数衰减,W2) + +简单阶梯加分在批量积压时会**集体失效**(所有 normal 都 +50 等于没有 aging)。改为百分位触发式 aging: + +**触发条件**:每 30s 扫描一次 `normal` lane ready 列表: + +1. 按 `enqueued_at` 升序排序 +2. 选出等待时间最长的 **top 10%** 任务 +3. 对这批任务执行 `priority += agingBoost(wait_time)`,`dispatch_lane = 'normal_boosted'` + +**boost 函数**: + +```go +func agingBoost(waitMinutes float64) int { + // 对数衰减:10min +50, 30min +85, 60min +115, 120min +141 + return int(50 * math.Log2(1 + waitMinutes/5)) +} +``` + +**上限保护**: + +- 单任务 aging 累计不超过 `+900`(保证 `normal_boosted` priority 永远 `< 5000`,不跨入 high 区) +- 若 aging 后 priority 达到 `normal_boosted` 上限,进入 `normal_boosted` lane 并停止继续 aging +- 观测指标 `monitor_normal_starvation_age_p99` 暴露 normal 最长等待时间 + +## 10. collect-now 抢占设计 + +### 10.1 抢占范围 + +默认只抢占以下任务: + +1. 同一 `target_client_id` +2. `kind=monitor` +3. `source=daily_schedule` +4. `status in (queued, in_progress)`(Phase 1 仅 `queued`;Phase 2 起含 `in_progress`) + +不抢占: + +1. publish 任务 +2. 其他用户或其他 client 的 monitor 任务 +3. 已接近完成且无法安全中断的任务 + +### 10.2 interrupt_generation 规范(C2) + +**定义**:`interrupt_generation` 归属 `desktop_tasks`(Phase 2)或 `monitoring_collect_tasks`(Phase 1),每次 collect-now 针对该 client 原子递增。 + +**递增操作**(单次 UPDATE): + +```sql +-- Phase 2 示例 +UPDATE desktop_tasks +SET interrupt_generation = interrupt_generation + 1 +WHERE target_client_id = $1 + AND kind = 'monitor' + AND status IN ('queued', 'in_progress') +RETURNING id, interrupt_generation; +``` + +**下发约束**: + +1. `task_control` 事件必须携带 `generation` +2. `task_available`(high lane)事件必须携带 `generation` +3. dispatch coordinator 发出 `abort` 指令时必须带当次 generation + +**desktop 执行规则**: + +1. 本地维护 per-task `last_seen_generation` +2. 仅执行 `event.generation >= local.last_seen_generation` 的指令 +3. 收到指令后更新 `last_seen_generation` + +**回写约束**: + +desktop 回写 `aborted(preempted)` 时必须带产生 abort 的 generation;服务端比对: + +```go +if callback.generation < currentTask.interrupt_generation { + // 旧 abort 指令迟到,忽略本次回写并下发新的 abort + log.Warn("stale abort callback", callback.generation, currentTask.interrupt_generation) + publishInterruptSignal(currentTask, currentTask.interrupt_generation) + return +} +``` + +**双击场景保护**: + +用户双击 collect-now 产生两次请求时: + +- 第一次递增 gen 从 N → N+1 +- 第二次递增 gen 从 N+1 → N+2 +- desktop 只会执行 gen=N+2 的指令(覆盖 N+1),第一次 abort 自然失效 +- 两次 collect_request 通过 `superseded_by_request_id` 形成引用链,第一次 request 标记为 `superseded` + +### 10.3 抢占流程 + +```text +用户点击 collect-now + -> 幂等检查(scope_hash + 5min TTL) + -> 短事务:create collect_request, increment gen, promote business tasks to high, write outbox + -> 提交事务 + -> outbox pump 投递 dispatch signal (monitor.high.{client_id}) + interrupt signal (task_control) + -> tenant-api dispatch hub 收到后 WebSocket 推送给 desktop + -> desktop 在安全点中断 running 任务(Phase 2)或在下次 pull 时发现租约失效(Phase 1) + -> desktop 回写 aborted(preempted) with generation + -> desktop 立即领取 high lane monitor 任务 +``` + +### 10.4 安全点(Phase 2 起) + +desktop client 必须只在安全点响应抢占: + +1. 打开平台页面前 +2. 输入问题前 +3. 提交请求前 +4. 收到原始回答后、结构化解析前 +5. 单次问题执行结束后 + +不要在以下阶段强杀: + +1. 平台登录态关键跳转中 +2. 人机验证流程中 +3. 页面提交后等待平台响应但浏览器状态不确定时 + +**测量**:每个安全点埋点 `safe_point_interval_ms`,用于 §10.5 动态调参。 + +### 10.5 安全中断超时策略(参数化,C4) + +**参数化原则**:硬编码的 15s/60s 无法适应不同平台的响应差异,改为 per-platform 配置: + +| 平台 | 安全点 P95 | `interrupt_soft_timeout` | `interrupt_hard_timeout` | +| --- | --- | --- | --- | +| OpenAI 快答 | 8s | 15s | 45s | +| OpenAI 深度推理 | 40s | 60s | 180s | +| Gemini | 20s | 30s | 90s | +| Perplexity | 30s | 45s | 120s | +| 其他 | 动态测量 | max(P95 × 1.5, 15s) | soft × 3 | + +**分级策略**: + +1. `soft_timeout` 内未中断:禁止向该 client 派发新 normal,high 任务仍然等待 +2. `soft_timeout` 到 `hard_timeout`:允许当前任务自然结束,结束后立即领 high +3. 超过 `hard_timeout`:触发告警 + 自动 reconcile(强制 cancel 租约、high 任务转派其他 client 或降级 web 端提示) + +**测量落地**: + +- 每个安全点埋点 `safe_point_interval_ms` +- 每周基于 P95 自动更新各平台 `interrupt_soft_timeout` 参数 +- 参数热更新,无需重启服务 + +### 10.6 target_client_id 绑定策略(W1) + +**绑定时机**:collect-now 接受请求时立刻选择 target client。 + +**选择规则**(按优先级): + +1. 请求体指定 `target_client_id` 且该 client **在线 + 健康** → 使用指定 client +2. 未指定或指定不可用 → 在当前用户 `tenant_id / workspace_id` 下的在线 client 中按下列权重选: + - 最近心跳 < 30s(必要条件) + - monitor 并发余量最大 + - 最近 1 分钟 abort 率最低 + - 任一平台登录态健康 + +**漂移处理矩阵**: + +| 场景 | 策略 | +| --- | --- | +| 用户多设备同时在线 | 按上述权重选一个,不广播;`collect_request.target_client_id` 记录选择结果 | +| 绑定后 client 掉线(30s 无心跳) | 解绑:业务任务 `target_client_id = NULL`,`lane='high'` 不变,任一在线 client 可领 | +| 用户换设备登录 | 收到新 client 心跳后,正在执行的 collect_request 不迁移;新提交的 collect-now 绑定新 client | +| 所有 client 掉线 | collect_request 保持 `accepted`,TTL 5 分钟;5 分钟内有 client 上线则重新 dispatch;超时转 `timed_out` | + +**失败边界**: + +- collect_request `ttl_expires_at` = 创建时间 + 5 分钟 +- `accepted` 状态下无任何 dispatch 动作超过 30s 触发预警 +- 同一 `(user_id, brand_id, scope_hash)` 5 分钟内重复 collect-now 返回上一次的 `request_id`(幂等复用) + +## 11. RabbitMQ 拓扑设计 + +### 11.1 下行 dispatch + +复用现有 `desktop.task.dispatch` topic exchange,建议约定路由键: + +```text +monitor.high.{client_id} +monitor.normal.{client_id} +monitor.retry.{client_id} +publish.{client_id} (已存在) +``` + +说明: + +1. tenant-api 实例通过临时队列消费所有 `monitor.*` 与 `publish.*`(现有绑定 `publish.*`,Phase 2 新增 `monitor.*`) +2. dispatch hub 再按 `target_client_id` 过滤并推给对应 websocket +3. desktop client 不需要感知 RabbitMQ,只消费 websocket 事件即可 + +### 11.2 上行结果 + +保留现有结果 ingest 链路(已落地): + +```text +monitor.result.ingest +monitor.result.ingest.dlq +monitor.projection.rebuild (已存在并带 coalesce) +monitor.projection.rebuild.dlq +``` + +Phase 3 增强: + +1. `monitor.result.ingest` 按 `ingest_shard_key` 分片串行消费(见 §12.2) +2. projection worker 已支持 coalesce,同一个 `(tenant_id, brand_id, business_date)` 只保留一条重算请求 + +### 11.3 多队列而非单队列 priority + +推荐: + +1. `monitor.dispatch.high` +2. `monitor.dispatch.normal` +3. `monitor.dispatch.retry` + +不推荐仅依赖单队列 `x-max-priority`。原因: + +1. 单队列 priority 在消息堆积很深时观测和治理不清晰 +2. 多队列更容易做 pause、drain、限流和运维看板 +3. 更符合 lane 化流量治理方式 + +## 12. 结果回写与有序落库 + +### 12.1 写链路拆分 + +写链路分成两层(现状已落地): + +1. 原始事实层:`question_monitor_runs`、`question_monitor_parse_results`、`monitoring_citation_facts` +2. 聚合投影层:`monitoring_brand_daily`、`monitoring_brand_platform_daily` + +### 12.2 分片串行(Phase 3 落地) + +不建议全局单线程顺序落库。 + +**主选分片键**(唯一): + +```text +shard_key = hash(tenant_id, brand_id, business_date) % N +``` + +选择理由:与现有 `MonitoringProjectionRebuildQueue` coalesce 键对齐,避免同一投影维度上出现 ingest 与 projection 错位。**去掉** V1 中的备选 `(tenant_id, question_id, business_date)`,统一一个维度。 + +规则: + +1. 同 shard 内串行消费,保证局部有序 +2. 不同 shard 并行消费,保证总体吞吐 +3. 原始事实 upsert 幂等即可,不追求全局顺序 +4. 聚合快照按 `(tenant_id, brand_id, business_date)` 做局部重算与覆盖写 + +### 12.3 幂等键体系(与现有表对齐,W7) + +| 幂等维度 | 键 | 对应现有约束 | +| --- | --- | --- | +| 业务任务幂等 | `(tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)` | `monitoring_collect_tasks` 现有唯一索引 | +| 执行任务幂等 | `(monitor_task_id)` WHERE status IN (queued,in_progress,unknown) | `desktop_tasks` Phase 2 新增唯一索引(见 §7.2) | +| 原始 run 入库幂等 | `(tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)` | `question_monitor_runs.uk_monitor_run_idempotent` | +| parse 结果入库幂等 | `(run_id, citation_fingerprint)` | `question_monitor_parse_results` / `monitoring_citation_facts` 现有约束 | +| projection 重算幂等 | `(tenant_id, brand_id, business_date)` | `MonitoringProjectionRebuildQueue` 现有 coalesce 键(未含 collector_type) | + +**注意**: + +1. projection 重算 coalesce 键统一为 `(tenant_id, brand_id, business_date)`,V1 文档中提及的 `collector_type` 维度**不纳入**,与现有实现对齐。 +2. `question_monitor_runs` 当前没有 `task_id / attempt_id` 字段;attempt 级诊断信息仍在 `desktop_task_attempts`。如果后续要做 attempt 级 monitoring run 回放,需要单独迁移 `question_monitor_runs` 结构,不能假设现状已支持。 +3. `question_monitor_runs` 与业务任务共用同一业务键,语义是“同一业务日同一问题同一平台只保留一条最新有效 run”,而不是保留 attempt 级历史。 +4. `question_monitor_runs` 的 upsert 语义必须固定为“成功结果优先、最后一次有效结果生效”。列名以下仅为示意,实际实现按真实 schema 对应: + +```sql +INSERT INTO question_monitor_runs (...) +VALUES (...) +ON CONFLICT (tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date) +DO UPDATE SET + result = EXCLUDED.result, + status = EXCLUDED.status, + updated_at = now() +WHERE question_monitor_runs.status = 'failed' + OR EXCLUDED.status = 'succeeded'; +``` + +5. 上述语义等价于: + - 新 `succeeded` 可覆盖旧 `failed`,也可覆盖旧 `succeeded`(最后一次成功结果生效) + - 新 `failed` 只能覆盖旧 `failed`,不得覆盖已存在的 `succeeded` + - 失败 attempt 的诊断链路保留在 `desktop_task_attempts`,不在 `question_monitor_runs` 中展开 + +### 12.4 落库保护 + +当出现以下任一情况时,自动触发写链路降速: + +1. ingest queue lag 超过阈值(默认 30s) +2. projection queue lag 超过阈值(默认 60s) +3. Monitoring PG 写入延迟明显升高(P95 > 500ms) +4. 事务冲突率升高(> 5%) + +降速策略: + +1. 暂停 `normal` dispatch +2. 保留 `high` lane +3. 将 projection rebuild 改为批次合并 + +## 13. API 与协议设计 + +### 13.1 collect-now + +```http +POST /api/tenant/monitoring/brands/{brand_id}/collect-now +Content-Type: application/json + +{ + "keyword_id": 123, + "preempt": true, + "wait_for_first_dispatch": false, + "target_client_id": "optional-uuid" +} +``` + +返回建议: + +```json +{ + "request_id": "uuid", + "collection_mode": "desktop", + "target_client_id": "uuid", + "affected_task_count": 12, + "promoted_task_count": 12, + "aborted_queued_count": 4, + "interrupt_requested_count": 2, + "interrupt_generation": 7, + "ttl_expires_at": "2026-04-20T10:05:00Z", + "message": "已提升为高优先级并请求抢占当前定时采集任务" +} +``` + +**`collection_mode` 字段说明**:浏览器插件硬切换下线后,该字段恒为 `"desktop"`,保留仅用于日志审计与未来多端扩展。前端可忽略此字段的分支逻辑。 + +### 13.2 desktop dispatch event + +```json +{ + "type": "task_available", + "kind": "monitor", + "task_id": "uuid", + "target_client_id": "uuid", + "priority": 5000, + "lane": "high", + "status": "queued", + "scheduler_group_key": "qid:123", + "business_date": "2026-04-20", + "interrupt_generation": 7, + "updated_at": "2026-04-20T10:00:00Z" +} +``` + +### 13.3 desktop control event + +```json +{ + "type": "task_control", + "kind": "monitor", + "task_id": "uuid", + "target_client_id": "uuid", + "control": "interrupt_requested", + "reason": "collect_now_preempt", + "interrupt_generation": 7, + "replacement_task_id": "uuid", + "updated_at": "2026-04-20T10:00:05Z" +} +``` + +### 13.4 desktop 中断确认 + +优先复用现有 cancel 能力: + +```http +POST /api/desktop/tasks/{task_id}/cancel +Content-Type: application/json + +{ + "lease_token": "xxx", + "reason": "collect_now_preempt", + "interrupt_generation": 7 +} +``` + +若后续希望保留更细致的中断原因,可新增: + +```http +POST /api/desktop/tasks/{task_id}/interrupt-ack +``` + +### 13.5 立即采集请求查询 + +```http +GET /api/tenant/monitoring/collect-requests/{request_id} +``` + +用于前端轮询: + +1. 是否已经抢占成功 +2. 是否已开始执行 +3. 是否已出现第一条结果 +4. 当前状态与倒计时 TTL + +## 14. desktop client 设计要求 + +### 14.1 调度优先级 + +desktop client 本地调度规则: + +1. `publish` 继续保持最高优先级,不被 monitoring 抢占。 +2. `monitor.high` 高于 `monitor.normal_boosted` 高于 `monitor.normal` 高于 `monitor.retry` +3. 收到 `interrupt_requested` 时,正在运行的 `monitor.normal` 必须让路给 `monitor.high`(Phase 2 起) +4. Phase 1 desktop 仍以 pull 为主;Phase 2 迁至 push + lease + +### 14.2 本地队列 + +本地 monitor scheduler 需要新增: + +1. `lane` +2. `priority` +3. `interruptRequested` +4. `interruptDeadline` +5. `interruptGeneration`(与服务端同步的代际号) +6. `executionPhase`(精确定义“是否已开始执行”,见下表) + +`executionPhase` 建议统一枚举: + +| 值 | 说明 | Phase 1 active lease 行为 | Phase 2 行为 | +| --- | --- | --- | --- | +| `PREPARING` | 浏览器 context / page 尚未初始化完成 | 可在下一次串行检查时 cancel lease | 可立即进入中断处理 | +| `NAVIGATING` | 正在打开 AI 平台页面 | 可在下一次串行检查时 cancel lease | 可立即进入中断处理 | +| `AUTHENTICATING` | 登录态校验 / 验证流程 | 视为已开始,不再直接 cancel | 等待下一个安全点 | +| `INPUTTING` | 输入问题 | 视为已开始,不再直接 cancel | 等待下一个安全点 | +| `SUBMITTING` | 提交请求 | 视为已开始,不再直接 cancel | 等待下一个安全点 | +| `WAITING` | 等待 AI 回答 | 视为已开始,不再直接 cancel | 等待下一个安全点 | +| `PARSING` | 结构化解析 | 视为已开始,不再直接 cancel | 等待下一个安全点 | +| `POSTING` | 回写结果 | 视为已开始,不再直接 cancel | 等待下一个安全点 | + +本文中“尚未真正开始执行”严格定义为 `PREPARING | NAVIGATING`;自 `AUTHENTICATING` 起统一视为“已开始”。 + +状态推进与 `interruptRequested / interruptGeneration` 的更新,必须运行在同一个 desktop scheduler 单线程 loop 或同一把互斥锁内,不能分散在不同线程各自修改。 + +本地排序建议: + +```text +lane_weight DESC +priority DESC +availableAt ASC +enqueuedAt ASC +updatedAt ASC +``` + +### 14.3 Phase 1 / Phase 2 通道策略(SSE 早期下线) + +| 阶段 | `task_available` 下发通道 | `task_control` 下发通道 | SSE 状态 | +| --- | --- | --- | --- | +| Phase 1 | **WebSocket** (`desktop.task.dispatch`) 复用 publish 通道 | **WebSocket** 同通道 | **Phase 1 完成即下线** | +| Phase 2 | WebSocket(新增 running 抢占事件) | WebSocket | 已移除 | +| Phase 3 | WebSocket | WebSocket | 已移除 | + +**Phase 1 直接启用 WebSocket 的前提**(代码核实): + +1. `desktop.task.dispatch` exchange 已存在并在 publish 场景生产使用 +2. `PublishDesktopDispatch` 发布端已存在,Phase 1 扩展支持 `monitor.*` 路由键 +3. tenant-api dispatch hub 已支持 WebSocket push,Phase 1 新增订阅 `monitor.*` binding key +4. desktop client WebSocket 连接已存在(publish 场景),Phase 1 新增 `kind=monitor` 的事件 handler + +**Phase 1 WebSocket 事件扩展**: + +1. `task_available` 事件新增 `kind=monitor` 分支(见 §13.2) +2. 新增 `task_control` 事件(见 §13.3),承载抢占指令 +3. Phase 1 desktop 在 `task_available` 到来时仍以 pull-lease 确认任务归属(WebSocket 仅作为 dispatch 信号通知,lease 协议保持 pull 兼容),Phase 2 起改为 push-lease + +**SSE 下线收尾**(Phase 1 上线前置工作): + +1. 代码层面:`server/internal/tenant/transport/desktop_events_handler.go` 移除路由注册 +2. 代码层面:desktop-client 移除 `SseClient` 类、`api-client.ts` 相关 `sseConnected` 状态、`runtime-controller.ts` 的 SSE 启动调用 +3. 路由层面:`/api/desktop/events` 返回 410 Gone 一个版本周期后彻底移除 +4. 观测层面:SSE 连接数指标下线,改看 WebSocket 连接数 + +### 14.4 中断实现与串行化规则(Phase 1 active lease / Phase 2 running 通用) + +desktop client 需要: + +1. 监听 `task_control` 后,必须把处理投递到与主执行循环同一个 scheduler loop(或持有同一把互斥锁)的串行上下文中。 +2. 在该串行上下文内先比对 `interrupt_generation`,再原子写入 `interruptRequested=true / interruptGeneration / interruptReason`。 +3. `task_control` 处理线程**禁止**直接调用 cancel API;真正的 cancel / abort 只能由主执行循环在下一个检查点统一发出。 +4. Phase 1 中,若串行检查时 `executionPhase in (PREPARING, NAVIGATING)`,则发送 `cancel lease`;若已进入 `AUTHENTICATING` 及之后阶段,则忽略本次中断并等待自然完成。 +5. Phase 2 中,若任务已经开始执行,则在下一个安全点处理 `interruptRequested`,回写 `aborted(preempted)` 并携带 `interrupt_generation`。 +6. 中断完成后立刻触发下一轮 `pumpExecutionLoop()`,优先消费 `monitor.high`。 + +## 15. 可观测性与风控 + +### 15.1 关键指标(含新增) + +至少监控以下指标: + +**现有指标**: + +1. `collect_now_accept_count` +2. `collect_now_first_dispatch_latency_ms` +3. `collect_now_first_result_latency_ms` +4. `monitor_dispatch_high_backlog` +5. `monitor_dispatch_normal_backlog` +6. `monitor_result_ingest_lag_ms` +7. `monitor_projection_rebuild_lag_ms` +8. `monitor_preempt_request_count` +9. `monitor_preempt_success_count` +10. `monitor_preempt_timeout_count` +11. `desktop_client_monitor_queue_depth` +12. `monitoring_pg_write_qps` + +**V1.1 新增指标**: + +1. `collect_now_preempt_induced_retry_count` — 抢占导致定时任务额外重试次数 +2. `monitor_normal_starvation_age_p99` — normal lane 最长等待时间 +3. `safe_point_interval_ms_p95` — 各平台安全点间隔(per platform 维度) +4. `monitor_dispatch_reject_by_reason_count` — 按拒绝维度分桶(budget/concurrent/queue_depth) +5. `interrupt_generation_mismatch_count` — 旧 generation 被忽略次数(异常监控) +6. `collect_request_ttl_expired_count` — TTL 内未完成派发的请求数 +7. `execution_owner_legacy_count` / `execution_owner_desktop_tasks_count` — Phase 2 灰度进度 +8. `phase2_reconcile_fixed_count` — reconcile 任务修复数 +9. `monitor_dispatch_high_oldest_age_ms` — `high` lane 中最老未派发任务年龄 +10. `tenant_monitor_normal_backlog` — 按 tenant 分桶的 `normal` backlog 深度 + +### 15.2 告警 + +| 告警 | 指标 | 聚合方式 | 窗口 | 阈值 | 持续时间 | +| --- | --- | --- | --- | --- | --- | +| `high` lane 积压 | `monitor_dispatch_high_oldest_age_ms` | `max` | 1 分钟 | `> 60000ms` | 连续 2 个窗口 | +| `collect-now` 首条结果过慢 | `collect_now_first_result_latency_ms` | `P99` | 5 分钟 | `> 180000ms` | 连续 2 个窗口 | +| projection lag 过高 | `monitor_projection_rebuild_lag_ms` | `max` | 5 分钟 | `> 300000ms` | 连续 2 个窗口 | +| 单 client 抢占超时过多 | `increase(monitor_preempt_timeout_count{client_id})` | `sum by client_id` | 1 小时 | `>= 3` | 立即告警 | +| 单 tenant 的 `normal` backlog 无下降 | `tenant_monitor_normal_backlog{tenant_id}` | `min > 100` 且 `last-first >= 0` | 30 分钟 | 同时满足两条件 | 连续 1 个窗口 | +| generation mismatch 突增 | `increase(interrupt_generation_mismatch_count)` | `sum` | 10 分钟 | `>= 10` | 连续 2 个窗口 | +| Phase 2 reconcile 持续不一致 | `phase2_reconcile_fixed_count` | 每次 reconcile run | 15 分钟 | 连续 3 次 run `> 0` | 立即告警 | + +说明: + +1. 本节中的 queue lag / backlog age 一律按“最老未处理项的入队年龄”定义,而不是平均值。 +2. `collect_request_ttl_expired_count` 继续作为单请求超时兜底告警,不与 P99 聚合告警互相替代。 + +### 15.3 风控与公平性 + +1. 单用户 `collect-now` 基于 `(user_id, brand_id, scope_hash)` 的 5 分钟幂等窗口(取代 V1 的 10 秒简单去重) +2. 单租户同时最多 1~2 个活跃 `collect-now request` +3. 对异常频繁的手动采集做限流(每用户每小时 20 次) +4. 同平台登录态异常时暂停该平台 lane + +## 16. 分阶段落地方案 + +### 16.1 Phase 1:最小改造 + WebSocket 直上 + SSE 下线(W5 + W6) + +**范围收敛**:Phase 1 **不做 running 任务的运行时抢占**(运行时抢占放 Phase 2),但通道层面**一步到位直上 WebSocket**,不保留 SSE 作为过渡。 + +**Phase 1 实施项清单**: + +1. **DB migration**:`monitoring_collect_tasks` 加字段(`dispatch_priority / dispatch_lane / target_client_id / interrupt_generation / execution_owner / superseded_by_request_id / dispatch_after / last_dispatched_at / dispatch_attempts / ingest_shard_key`);新增 `monitoring_collect_requests` 表 +2. **lease 排序改造**:monitoring lease 查询从 `ORDER BY planned_at` 改为 `ORDER BY CASE dispatch_lane WHEN 'high' THEN 70 WHEN 'normal_boosted' THEN 50 WHEN 'normal' THEN 40 WHEN 'retry' THEN 20 ELSE 0 END DESC, dispatch_priority DESC, dispatch_after ASC NULLS FIRST, planned_at ASC`。 +3. **collect-now handler**:幂等检查 + gen 递增 + 任务提升 + 取消 queued normal / 对 active lease 写入抢占控制信号 + 创建 collect_request(见 §17.1 outbox 模式) +4. **RabbitMQ 生产端扩展**:`PublishDesktopDispatch` 支持 `monitor.high.{client_id} / monitor.normal.{client_id} / monitor.retry.{client_id}` 路由键 +5. **tenant-api dispatch hub 订阅扩展**:binding key 新增 `monitor.*`,与现有 `publish.*` 并列 +6. **WebSocket push 下发**:dispatch hub 把 `task_available(kind=monitor)` 与 `task_control` 事件推给匹配 `target_client_id` 的 desktop WebSocket +7. **desktop client 改造**: + - WebSocket handler 增加 `kind=monitor` 分支 + - 收到 `task_available(monitor)` 时触发一次 pull-lease(Phase 1 保持 lease 协议不变,仅把"定时 pull"改为"信号驱动 pull") + - 收到 `task_control(interrupt_requested)` 时:先在本地 scheduler 串行上下文内仅标记 `interruptRequested=true`;若目标任务处于本地队列(queued)则丢弃;若在下一次串行检查时 `executionPhase in (PREPARING, NAVIGATING)` 则主动 `cancel lease`;自 `AUTHENTICATING` 起视为已开始,Phase 1 忽略中断并等待自然完成 + - 比对 `interrupt_generation`,忽略过期指令 +8. **SSE 下线**(Phase 1 一并完成): + - 移除 `server/internal/tenant/transport/desktop_events_handler.go` 路由注册 + - 移除 desktop-client 的 `SseClient`、`api-client.ts` 中 `sseConnected` 状态、`runtime-controller.ts` 的 SSE 启动 + - 旧端点返回 410 Gone 一个发布周期后删除 +9. **指标埋点**:collect_now 全链路延迟、gen_mismatch、dispatch_reject、WebSocket 连接数 + +**Phase 1 的 active lease 处置**: + +legacy 路径下,服务端无法仅凭 `monitoring_collect_tasks` 精确区分“已 lease 但尚未真正开始执行”和“已在浏览器内运行”。因此 Phase 1 统一采用保守策略: + +1. 服务端只直接取消 queued 的 normal 任务。 +2. 对已有 active lease 的 normal 任务,不在服务端盲目清空 `lease_token_hash`,只下发 `task_control` 提示 desktop 自判。 +3. desktop 先在本地调度锁内原子标记 `interruptRequested`;仅当下一次串行检查仍处于 `PREPARING / NAVIGATING` 时,才主动 `cancel lease` 并让路给 high。 +4. 若串行检查时已进入 `AUTHENTICATING` 及之后阶段,则 Phase 1 忽略本次中断请求,待自然完成;期间 coordinator 暂停向该 client 派发新的 normal。 + +**Phase 1 的 collect-now 优先级保障**: + +1. 提升目标业务任务为 high lane 后,立即取消 queued 的 normal 任务,并向 active lease 的 normal 任务发送 `task_control` +2. 通过 WebSocket 立刻推 `task_available(high)` 给目标 client +3. desktop 收到信号后立即 pull-lease high 任务;若当前无 active lease,通常可直接开始执行 +4. 无 active lease 的典型场景:WebSocket push 延迟 < 200ms,client pull-lease < 500ms,总体 collect-now → 开始执行 < 2s;若遇到已 running 任务,则首响时间取决于当前任务尾延迟 + +**Phase 1 核心诉求与边界**: + +1. 解决 95% 场景:用户点击 collect-now 后 2s 内开始执行 +2. 不解决 5% 场景:client 已在 running 且 AI 平台响应很慢(30s+),此场景 UI 提示"正在等上一条任务完成" + +**优点**: + +1. 通道层面一步到位,无 SSE/WS 双通道并存的复杂度 +2. 解决"立即采集让路"核心诉求,延迟进入 2s 量级 +3. 不引入双层模型,无大规模迁移成本 + +**缺点**: + +1. monitoring 与 publish 仍是两套执行控制面(Phase 2 收敛) +2. 无法抢占 running 任务(业务方可接受,Phase 2 补齐) +3. Phase 1 工作量略增(多了 WebSocket 通道对接和 SSE 下线),估时 1.5~2 周 + +### 16.2 Phase 2:执行控制面收敛 + 迁移期一致性(C3) + +**新增内容**(通道层面在 Phase 1 已就位,Phase 2 只做控制面收敛): + +1. 引入 `desktop_tasks(kind=monitor)` 全字段(见 §7.2),作为执行态权威表 +2. `monitoring_collect_tasks.execution_owner` 字段落地,按 tenant 灰度 +3. Phase 1 的"信号驱动 pull-lease"升级为"push-lease":dispatch hub push `task_available` 时直接携带 lease_token +4. 真正的 `running` 任务抢占能力(基于 `desktop_tasks.control_flags` + 安全点协议) +5. desktop client 监听 `task_control` 事件,安全点响应中断并回写 `aborted(preempted)` + +#### 16.2.1 执行源路由字段 + +`monitoring_collect_tasks.execution_owner` varchar(20): + +- `legacy` — 仍在业务任务表上做 lease(Phase 1 路径) +- `desktop_tasks` — 已迁移到执行任务表(Phase 2 路径) + +dispatch coordinator 按该字段路由: + +```go +switch task.ExecutionOwner { +case "legacy": + leaseOnMonitoringCollectTasks(task) +case "desktop_tasks": + dispatchViaDesktopTasks(task) +} +``` + +#### 16.2.2 灰度策略 + +按 `tenant_id % 100 < rollout_percentage` 灰度。Phase 2 上线时: + +| 日期(D 表示 Phase 2 上线日) | `rollout_percentage` | 预期观测 | +| --- | --- | --- | +| D1 | 5% | 抢占成功率、ingest lag、reconcile 产出 | +| D3 | 25% | 扩大样本,观察多租户并发表现 | +| D7 | 50% | 压测高峰是否平稳 | +| D14 | 100% | 全量 | + +灰度切换时: + +- 仅对**新生成**的业务任务按 tenant 落 `execution_owner` +- **不迁移**已 in-flight 的 legacy 任务,等自然结束 +- dispatch coordinator 同时消费两条路径,直到 legacy in-flight 清零 + +#### 16.2.3 回滚预案 + +若 Phase 2 发现严重问题: + +1. 立即将 rollout 回调至 0%(新任务全部走 legacy) +2. 已迁移到 `desktop_tasks` 的 in-flight 任务**不强制回迁**,等自然结束(或 `lease_expires_at` 回收) +3. 若必须强制回迁,执行脚本: + +```sql +-- 强制回迁仅在紧急故障场景使用 +BEGIN; +-- Step 1: cancel 所有 monitor kind 的 in-flight desktop_tasks +UPDATE desktop_tasks +SET status = 'aborted', interrupt_reason = 'phase2_emergency_rollback' +WHERE kind = 'monitor' AND status IN ('queued', 'in_progress', 'unknown'); + +-- Step 2: 业务任务回流 legacy +UPDATE monitoring_collect_tasks +SET execution_owner = 'legacy', + status = 'pending', + lease_token_hash = NULL, + leased_to_executor = NULL, + leased_at = NULL, + lease_expires_at = NULL +WHERE execution_owner = 'desktop_tasks' + AND status IN ('pending'); +COMMIT; +``` + +#### 16.2.4 双写一致性保护 + +**绝对禁止**: + +- 同一 `monitoring_collect_tasks.id` 同时存在两条 active `desktop_tasks(kind=monitor)` → 通过 §7.2 的 UNIQUE 约束保证 +- `legacy` 路径在业务任务标记 `execution_owner='desktop_tasks'` 后继续 lease → 通过 coordinator 启动前检查 + 定期 reconcile 保证 + +**Reconcile 任务**(每 5 分钟运行): + +1. 扫描 `execution_owner='legacy' AND leased_to_executor IS NOT NULL AND lease_expires_at < now()` 的僵尸租约 → 清理 +2. 扫描 `execution_owner='desktop_tasks' AND status='pending'` 且无对应 active desktop_task 的业务任务 → 重新 dispatch +3. 扫描 `execution_owner='desktop_tasks'` 但 `desktop_tasks` 表无对应记录的异常业务任务 → 记录告警、人工介入 + +### 16.3 Phase 3:治理增强 + +目标:面向规模化与稳定性。 + +实施项: + +1. result ingest 按 `ingest_shard_key` 分片串行消费(见 §12.2) +2. projection rebuild coalesce 已有,Phase 3 补充按租户分片 +3. per-tenant fair scheduling +4. aging 提升按 §9.4 完全落地 +5. 运维看板与自动降速 + +(SSE 相关移除已在 Phase 1 完成,无需 Phase 3 处理) + +## 17. 推荐伪代码 + +### 17.1 collect-now(outbox 模式,W4) + +```go +func CollectNow(ctx context.Context, req CollectNowRequest) (*CollectNowResponse, error) { + // 阶段 1:幂等复用(无事务) + scopeHash := hashScope(req) + if existing := findActiveRequest(ctx, req.UserID, req.BrandID, scopeHash); existing != nil { + return buildResponseFromExisting(existing), nil + } + + // 阶段 2:选择 target_client(见 §10.6) + targetClientID, err := selectTargetClient(ctx, req) + if err != nil { + return nil, err + } + + requestID := uuid.New() + var effects *CollectNowEffects + + // 阶段 3:短事务,仅写 DB 和 outbox,不做外部副作用 + err = withTx(ctx, func(tx Tx) error { + createCollectRequest(tx, CollectRequest{ + ID: requestID, + UserID: req.UserID, + BrandID: req.BrandID, + ScopeHash: scopeHash, + TargetClientID: targetClientID, + Status: "accepted", + TTLExpiresAt: time.Now().Add(5 * time.Minute), + }) + + // 原子递增 generation + gen := incrementInterruptGeneration(tx, targetClientID) + + // 批量提升业务任务为 high lane(单条 UPDATE,WHERE 带 scope 条件) + effects = promoteToHighLane(tx, req.Scope, targetClientID, requestID, gen) + + // 仅统计本次真正从 queued normal -> canceled 的任务数;不含已被旧请求取消或非 normal 任务 + effects.AbortedQueuedCount = abortQueuedNormal(tx, targetClientID, gen) + // 仅统计本次 newly-marked interrupt_requested 的 active lease 数;不含已存在更高/相同 generation 的任务 + effects.InterruptRequestedCount = markInterruptRequested(tx, targetClientID, gen, requestID) + + // 写 outbox + writeOutbox(tx, OutboxEvent{ + Kind: "monitor.dispatch.high", + Payload: DispatchPayload{ClientID: targetClientID, Generation: gen}, + CreatedAt: time.Now(), + }) + writeOutbox(tx, OutboxEvent{ + Kind: "monitor.interrupt", + Payload: InterruptPayload{ClientID: targetClientID, TaskIDs: effects.RunningTaskIDs, Generation: gen, RequestID: requestID}, + CreatedAt: time.Now(), + }) + return nil + }) + if err != nil { + return nil, err + } + + // 阶段 4:外部副作用由独立 outbox pump goroutine 处理(非阻塞) + return &CollectNowResponse{ + RequestID: requestID, + CollectionMode: "desktop", + TargetClientID: targetClientID, + AffectedTaskCount: effects.TotalCount, + PromotedTaskCount: effects.PromotedCount, + AbortedQueuedCount: effects.AbortedQueuedCount, + InterruptRequestedCount: effects.InterruptRequestedCount, + InterruptGeneration: effects.Generation, + TTLExpiresAt: time.Now().Add(5 * time.Minute), + }, nil +} + +// Outbox pump(独立 goroutine) +func OutboxPump(ctx context.Context) { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + events := fetchUndeliveredOutbox(ctx, 100) + for _, e := range events { + switch e.Kind { + case "monitor.dispatch.high": + publishDispatchSignal(e.Payload) + case "monitor.interrupt": + publishInterruptSignal(e.Payload) + } + markOutboxDelivered(ctx, e.ID) + } + } + } +} +``` + +**CollectNowResponse 字段语义**: + +1. `aborted_queued_count` = 本次 `collect-now` 实际取消的 queued normal 任务数,不含此前已取消或已失活任务。 +2. `interrupt_requested_count` = 本次新写入 `task_control / interruptRequested` 的 active lease 任务数,不含已被更高或相同 generation 覆盖的任务。 + +### 17.2 dispatch coordinator + +```go +func DispatchLoop(ctx context.Context) { + for { + // 先 drain high lane + if highLaneBacklog() > 0 { + drainHighLane() + keepNormalBudget(0.2) // 保留 20% normal 背景吞吐 + continue + } + + // 检查降速信号 + if dbLagTooHigh() || ingestLagTooHigh() { + pauseNormalLane() + sleep(5 * time.Second) + continue + } + + // 按预算 dispatch normal + dispatchByBudget("normal") + sleep(1 * time.Second) + } +} + +func dispatchByBudget(lane string) { + candidates := fetchReadyTasks(lane, 100) + for _, task := range candidates { + reason, ok := checkDispatchBudget(task) + if !ok { + metrics.Inc("monitor_dispatch_reject_by_reason_count", reason) + continue + } + doDispatch(task) + } +} +``` + +### 17.3 desktop client 抢占串行化(Phase 1 active lease / Phase 2 running,TypeScript) + +文件:`apps/desktop-client/src/scheduler/monitor-scheduler.ts` + +```ts +function onTaskControl(event: TaskControlEvent) { + scheduler.enqueue(() => { + const active = state.activeExecutions.get(event.task_id) + if (!active) return + if (active.kind !== "monitor") return + + // 代际比对必须和状态写入在同一个串行上下文内 + if (event.interrupt_generation < active.lastSeenGeneration) { + metrics.inc("interrupt_generation_mismatch_count") + return + } + + active.lastSeenGeneration = event.interrupt_generation + active.interruptRequested = true + active.interruptReason = event.reason + }) +} + +function canPhase1Cancel(phase: ExecutionPhase) { + return phase === "PREPARING" || phase === "NAVIGATING" +} + +async function maybeHandleInterrupt(taskId: string) { + const active = state.activeExecutions.get(taskId) + if (!active?.interruptRequested) return + + // Phase 1 不允许 control handler 直接 cancel,只能在主执行循环串行检查后决定 + if (runtime.phase === 1 && !canPhase1Cancel(active.executionPhase)) { + return + } + + await cancelDesktopTask(taskId, { + lease_token: currentLeaseToken(taskId), + reason: "collect_now_preempt", + interrupt_generation: active.lastSeenGeneration, + }) + + // 记录安全点间隔(用于动态调参) + metrics.observe("safe_point_interval_ms", Date.now() - active.lastSafePointAt) + + pumpExecutionLoop() +} +``` + +## 18. 最终建议 + +推荐决策如下: + +1. 短期(Phase 1,1.5~2 周):业务任务表加字段 + collect-now 高优提升 + queued/leased 抢占 + **WebSocket 通道直上(monitor.\* 路由键 + dispatch hub 订阅扩展)+ SSE `/api/desktop/events` 同步下线**。不抢 running,收敛业务范围。 +2. 中期(Phase 2,4 周):引入 `desktop_tasks(kind=monitor)` + `execution_owner` 灰度迁移 + pull-lease 升级为 push-lease + 支持 running 抢占(安全点协议)。严格遵守灰度节奏(5% → 25% → 50% → 100%)。 +3. 长期(Phase 3,持续):ingest 分片串行 + per-tenant fair scheduling + aging + 自动降速。 +4. 结果回写坚持 `API ingress -> MQ -> shard serial ingest -> projection rebuild`,不要回退到 desktop 直写 DB。 + +如果只保留一句话概括本方案,就是: + +**业务任务存在 `monitoring_collect_tasks`(Phase 1 兼容承载现有状态流转,Phase 2 仅保留业务终态与调度属性),执行任务逐步收敛到 `desktop_tasks(kind=monitor)`(承载唯一执行态),`collect-now` 通过 high lane + 代际化协作式抢占 + outbox 模式优先插队,结果再经 MQ 分片有序入库;迁移期用 `execution_owner` 字段做双路径路由,灰度上线、reconcile 兜底、随时可回滚。** + +## 附录 A. V1.1 修订项与原文档对照速查 + +| V1.1 章节 | V1 章节 | 修订类型 | 修订要点 | +| --- | --- | --- | --- | +| §2 修订记录 | 新增 | 新增 | V1→V1.1 变更全景 | +| §6.1 当前态 | §5.1 | 更正 | `monitor.projection.rebuild` 实际已存在 | +| §7.1 | §6.1 | 重写 | 不新增 `execution_status`;Phase 1 兼容复用现有 `status`,Phase 2 明确 `desktop_tasks` 为唯一执行真相,并补齐状态写入矩阵 | +| §7.2 | §6.2 | 补充 | 新增 UNIQUE 约束防止双写 | +| §7.3 | 新增 | 新增 | priority 值域规范 + lane_weight | +| §9.2 | §8.2 | 重写 | dispatch 预算决策树 + 拒绝矩阵 | +| §9.4 | §8.4 | 重写 | aging 百分位触发 + 对数衰减 | +| §10.2 | 新增 | 新增 | interrupt_generation 规范 | +| §10.5 | §9.4 | 重写 | 参数化超时,per-platform 配置 | +| §10.6 | 新增 | 新增 | target_client_id 绑定与漂移 | +| §12.2 | §11.2 | 精简 | 去掉 shard_key 备选维度,统一 | +| §12.3 | §11.3 | 对齐 | 与现有表约束映射 + `question_monitor_runs` upsert 语义 | +| §13.1 | §12.1 | 补充 | collection_mode 字段语义 | +| §14.3 | 新增 | 新增 | Phase 1/2/3 通道策略(**修订 2:SSE 在 Phase 1 即下线**) | +| §16.1 Phase 1 通道部分 | §15.1 | 重写 | WebSocket 通道 Phase 1 直上,SSE 同步下线(**修订 2**) | +| §14.2 / §14.4 | 新增 | 新增 | `executionPhase` 定义 + desktop 中断串行化规则 | +| §15.1 | §14.1 | 扩充 | 新增 10 项指标 | +| §15.2 | §14.2 | 重写 | 告警五元组(指标/聚合/窗口/阈值/持续时间) | +| §16.1 | §15.1 | 重写 | Phase 1 范围收敛 + WebSocket 直上 + SSE 下线 | +| §16.2 | §15.2 | 重写 | 迁移期一致性(execution_owner + 灰度 + 回滚 + reconcile) | +| §17.1 | §16.1 | 重写 | outbox 模式 | +| 附录 A | 新增 | 新增 | 修订对照表 | diff --git a/docs/ai-brand-monitoring-tech-design-v2.md b/docs/ai-brand-monitoring-tech-design-v2.md deleted file mode 100644 index 83908f3..0000000 --- a/docs/ai-brand-monitoring-tech-design-v2.md +++ /dev/null @@ -1,1348 +0,0 @@ -# AI 品牌曝光监测系统技术方案 V2 - -## 1. 文档信息 - -| 项目 | 内容 | -| --- | --- | -| 文档名称 | AI 品牌曝光监测系统技术方案 V2 | -| 文档版本 | V2.0 | -| 文档状态 | 待评审 | -| 创建日期 | 2026-04-05 | -| 适用范围 | 数据追踪模块全部页面 | -| 关联文档 | `docs/geo-platform-prd-v1.md` (PRD 8.4 数据追踪) | -| 关联文档 | `docs/question-driven-monitoring-design-v1.md` (V1 数据模型) | -| 容量目标 | 5 万并发在线用户 | - -## 2. 与 V1 设计的关系 - -本方案基于 V1 (`question-driven-monitoring-design-v1.md`) 的核心结论,保留其"问题驱动 + 日级汇总 + 版本绑定"的数据模型哲学,在此基础上扩展以下能力: - -| V1 已定义 | V2 扩展 | -| --- | --- | -| 品牌曝光趋势 + 高频问题 + 引用排行 | 新增:主页总览、平台占比分析、竞争对手分析、业务主题分析、AI 对话问题列表、AI 引用来源排名 | -| 单维度(品牌+关键词+模型) | 多维度:按 AI 平台、按竞品、按主题、按引用来源交叉分析 | -| 汇总表 3 张 | 扩展至 8 张汇总表 + 2 张原始采集表 | -| 未涉及性能架构 | 明确 5 万并发的缓存层、连接池、部署拓扑 | -| 未涉及前端 | 6 个页面 + ECharts 可视化完整方案 | - -V1 定义的采集逻辑、版本化策略、去重策略、保留策略 **全部沿用,不再重复**。 - -## 3. 目标页面与数据需求 - -基于产品截图,数据追踪模块共 6 个页面: - -### 3.1 主页(Dashboard) - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 曝光趋势图 | 近 7/30 天品牌在所有 AI 平台的综合曝光度日趋势 | `monitoring_brand_daily_overview` | -| 前五竞争对手 | 品牌及竞品的情感得分、曝光度排名 | `monitoring_competitor_daily` | -| 引用来源类型占比 | 编辑类、企业、UGC、参考资料、机构、其他的占比饼图 | `monitoring_citation_source_daily` | -| 前五引用平台 | 引用次数最多的外部平台,含百度权重、引用次数、覆盖度 | `monitoring_citation_source_daily` | -| 最新 AI 对话 | 最近被触发的 AI 问答问题、排名、前三品牌 | `monitoring_question_daily` | -| AI 品牌印象 | 品牌相关的高频词词云 | `monitoring_brand_impression` | - -### 3.2 平台占比分析 - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 提及次数排名 | 各 AI 平台对品牌的提及总次数横向柱状图 | `monitoring_platform_daily` | -| 各平台排名表现 | 按平台展开:AI 对话问题、提及次数、平均位置、曝光度 | `monitoring_platform_question_daily` | - -### 3.3 竞争对手分析 - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 竞争对手曝光度排名 | 品牌 + 竞品在各 AI 平台上的曝光度分组柱状图 | `monitoring_competitor_daily` | -| 竞争对手排名情况 | 按品牌/竞品展开:各平台提及次数、平均位置、曝光度 | `monitoring_competitor_platform_daily` | - -### 3.4 业务主题分析 - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 业务主题 | 主题名称、问题数、曝光度、提及次数 | `monitoring_topic_daily` | -| 引用最高的前五平台 | 外部引用来源平台排名 | `monitoring_citation_source_daily` | -| 引用次数最多的页面 | 具体被引用的外部 URL | `monitoring_citation_page_daily` | - -### 3.5 AI 对话问题 - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 问题列表 | AI 对话问题、热度指数、曝光度、平均位置、情感倾向、前三品牌 | `monitoring_question_daily` | -| 查看效果 / 查看引用 | 单条问题的详细回答效果和引用来源 | `question_monitor_parse_results` | - -### 3.6 AI 引用排名 - -| 区域 | 数据需求 | 数据来源 | -| --- | --- | --- | -| 最高引用来源平台趋势 | 前 5 引用来源平台的引用次数日趋势折线图 | `monitoring_citation_source_daily` | -| 引用页面详情 | 引用平台、百度权重、引用页面 URL、引用次数、覆盖率 | `monitoring_citation_page_daily` | - -## 4. 与现有数据模型的关系 - -### 4.1 已有表复用 - -监测系统直接复用以下现有表,**不新建冗余表**: - -| 现有表 | 用途 | 关键字段 | -| --- | --- | --- | -| `brands` | 被监测的品牌主体 | `id, tenant_id, name` | -| `brand_keywords` | 监测问题的分类容器 | `id, brand_id, name` | -| `brand_questions` | 监测采集的最小单元 | `id, brand_id, keyword_id, current_version_id` | -| `brand_question_versions` | 保证历史口径稳定 | `id, question_id, question_text, question_hash, version_no` | -| `competitors` | 竞品品牌数据 | `id, brand_id, name, website` | -| `articles` | 被引用的系统文章 | `id, tenant_id, source_type` | - -### 4.2 数据流向 - -``` -现有品牌词库 新建监测模块 -┌──────────────┐ ┌──────────────────┐ -│ brands │──────────────────▶│ 采集调度器 │ -│ keywords │ 品牌+关键词+问题 │ (按问题×模型调度) │ -│ questions │──────────────────▶│ │ -│ question_ │ 绑定问题版本 └────────┬─────────┘ -│ versions │ │ -│ competitors │──── 竞品清单 ──────▶ │ 调用 AI 平台 API -└──────────────┘ ▼ - ┌──────────────────┐ -现有文章表 │ 原始采集表 │ -┌──────────────┐ │ question_monitor_ │ -│ articles │◀── 引用检测 ──────│ runs / results │ -│ article_ │ └────────┬─────────┘ -│ versions │ │ 日级聚合 Job -└──────────────┘ ▼ - ┌──────────────────┐ - │ 汇总表 × 8 │ - │ (monitoring_*) │ - └────────┬─────────┘ - │ - ▼ - ┌──────────────────┐ - │ Redis 缓存层 │ - └────────┬─────────┘ - │ - ▼ - ┌──────────────────┐ - │ Dashboard API │ - │ (6 个页面) │ - └──────────────────┘ -``` - -## 5. 数据库 Schema - -### 5.1 参考表 - -#### `ai_platforms` — AI 平台注册表 - -```sql -CREATE TABLE ai_platforms ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - name VARCHAR(50) NOT NULL, -- DeepSeek / 通义千问 / 豆包 / 腾讯元宝 / 文心一言 - icon VARCHAR(255), - provider_key VARCHAR(50) NOT NULL, -- deepseek / qwen / doubao / hunyuan / ernie - api_endpoint TEXT, - api_model VARCHAR(100), - display_order INT NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE UNIQUE INDEX uk_ai_platform_tenant_key ON ai_platforms(tenant_id, provider_key); -``` - -#### `monitoring_topics` — 业务主题分类 - -```sql -CREATE TABLE monitoring_topics ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - brand_id BIGINT NOT NULL REFERENCES brands(id), - name VARCHAR(200) NOT NULL, -- 瑜伽服饰 / 运动生活方式品牌 / 女士运动内衣 - description TEXT, - 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 UNIQUE INDEX uk_topic_tenant_brand_name ON monitoring_topics(tenant_id, brand_id, name) WHERE deleted_at IS NULL; -``` - -#### `monitoring_topic_questions` — 主题与问题的多对多关系 - -```sql -CREATE TABLE monitoring_topic_questions ( - id BIGSERIAL PRIMARY KEY, - topic_id BIGINT NOT NULL REFERENCES monitoring_topics(id), - question_id BIGINT NOT NULL REFERENCES brand_questions(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_topic_question UNIQUE (topic_id, question_id) -); -``` - -### 5.2 原始采集表 - -沿用 V1 设计,增加 `tenant_id` 租户隔离字段。 - -#### `question_monitor_runs` — 单次采集执行记录 - -```sql -CREATE TABLE question_monitor_runs ( - 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_id BIGINT NOT NULL REFERENCES brand_questions(id), - question_version_id BIGINT NOT NULL REFERENCES brand_question_versions(id), - ai_platform_id BIGINT NOT NULL REFERENCES ai_platforms(id), - run_date DATE NOT NULL, - run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - task_batch_id VARCHAR(50), - status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/running/completed/failed - raw_answer_key TEXT, -- MinIO 对象存储路径 - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) PARTITION BY RANGE (run_date); - -CREATE INDEX idx_monitor_runs_tenant_date ON question_monitor_runs(tenant_id, run_date DESC); -CREATE INDEX idx_monitor_runs_batch ON question_monitor_runs(task_batch_id); -``` - -> **分区策略**:按 `run_date` 月级 Range 分区,自动创建未来 3 个月分区,到期后可 `DROP PARTITION` 清理。 - -#### `question_monitor_parse_results` — 单次采集解析结果 - -```sql -CREATE TABLE question_monitor_parse_results ( - id BIGSERIAL PRIMARY KEY, - run_id BIGINT NOT NULL, -- 指向 question_monitor_runs.id(分区表不建 FK) - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - - -- 品牌提及指标 - brand_mentioned BOOLEAN NOT NULL DEFAULT false, - brand_position INT, -- 品牌出现位置(1=首位) - top1_mentioned BOOLEAN NOT NULL DEFAULT false, - first_recommended BOOLEAN NOT NULL DEFAULT false, - positive_sentiment BOOLEAN NOT NULL DEFAULT false, - sentiment_label VARCHAR(20), -- excellent/normal/negative - - -- 竞品提及 - competitor_mentions JSONB, -- [{"competitor_id":1,"name":"Nike","position":2,"sentiment":"positive"}] - - -- 引用检测 - citation_urls JSONB, -- [{"url":"https://...","platform":"什么值得买","platform_type":"UGC","baidu_weight":7}] - cited_article_ids BIGINT[], -- 系统文章 ID 数组 - citation_count INT NOT NULL DEFAULT 0, - - -- 品牌印象关键词 - brand_keywords_extract JSONB, -- [{"word":"性价比高","weight":5},{"word":"设计时尚","weight":3}] - - parser_version VARCHAR(20) NOT NULL DEFAULT 'v1', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_parse_results_run ON question_monitor_parse_results(run_id); -CREATE INDEX idx_parse_results_tenant ON question_monitor_parse_results(tenant_id); -``` - -### 5.3 汇总表 - -所有汇总表均由后台聚合 Job 写入,Dashboard 查询 **只读** 汇总表。 - -#### 1) `monitoring_brand_daily_overview` — 品牌日级总览(主页指标卡 + 趋势图) - -```sql -CREATE TABLE monitoring_brand_daily_overview ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - -- 核心指标 - total_question_count INT NOT NULL DEFAULT 0, -- 采集的问题总数 - total_ask_count INT NOT NULL DEFAULT 0, -- 总提问次数(跨平台) - mention_count INT NOT NULL DEFAULT 0, - top1_mention_count INT NOT NULL DEFAULT 0, - first_recommend_count INT NOT NULL DEFAULT 0, - positive_mention_count INT NOT NULL DEFAULT 0, - citation_count INT NOT NULL DEFAULT 0, - - -- 比率 - mention_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - top1_mention_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - first_recommend_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - positive_mention_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, -- 曝光度 - - -- 情感得分 - sentiment_score INT, -- 0~100 - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_brand_overview UNIQUE (tenant_id, brand_id, metric_date) -); -``` - -#### 2) `monitoring_platform_daily` — 按 AI 平台维度的品牌日统计(平台占比分析) - -```sql -CREATE TABLE monitoring_platform_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - ai_platform_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - mention_count INT NOT NULL DEFAULT 0, - avg_position NUMERIC(5,2), -- 平均排名位置 - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - top1_count INT NOT NULL DEFAULT 0, - question_count INT NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_platform_daily UNIQUE (tenant_id, brand_id, ai_platform_id, metric_date) -); -``` - -#### 3) `monitoring_platform_question_daily` — 按平台×问题的明细(平台展开表) - -```sql -CREATE TABLE monitoring_platform_question_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - ai_platform_id BIGINT NOT NULL, - question_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - question_text TEXT NOT NULL, -- 快照 - mention_count INT NOT NULL DEFAULT 0, - avg_position NUMERIC(5,2), - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_platform_question_daily UNIQUE (tenant_id, brand_id, ai_platform_id, question_id, metric_date) -); -``` - -#### 4) `monitoring_competitor_daily` — 竞品对比日统计(竞争对手分析) - -```sql -CREATE TABLE monitoring_competitor_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, -- 主品牌 - compared_brand_name VARCHAR(200) NOT NULL, -- 品牌名或竞品名 - is_self BOOLEAN NOT NULL DEFAULT false, - competitor_id BIGINT, -- NULL 表示自身品牌 - metric_date DATE NOT NULL, - - mention_count INT NOT NULL DEFAULT 0, - avg_position NUMERIC(5,2), - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - sentiment_score INT, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_competitor_daily UNIQUE (tenant_id, brand_id, compared_brand_name, metric_date) -); -``` - -#### 5) `monitoring_competitor_platform_daily` — 竞品×平台交叉统计 - -```sql -CREATE TABLE monitoring_competitor_platform_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - compared_brand_name VARCHAR(200) NOT NULL, - ai_platform_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - mention_count INT NOT NULL DEFAULT 0, - avg_position NUMERIC(5,2), - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_comp_platform_daily UNIQUE (tenant_id, brand_id, compared_brand_name, ai_platform_id, metric_date) -); -``` - -#### 6) `monitoring_topic_daily` — 业务主题日统计 - -```sql -CREATE TABLE monitoring_topic_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - topic_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - question_count INT NOT NULL DEFAULT 0, - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - mention_count INT NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_topic_daily UNIQUE (tenant_id, brand_id, topic_id, metric_date) -); -``` - -#### 7) `monitoring_question_daily` — 单问题日统计(AI 对话问题页) - -```sql -CREATE TABLE monitoring_question_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - question_id BIGINT NOT NULL, - question_version_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - question_text TEXT NOT NULL, - heat_index INT NOT NULL DEFAULT 0, -- 热度指数 - exposure_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - avg_position NUMERIC(5,2), - sentiment_label VARCHAR(20), -- excellent/normal/negative - top3_brands JSONB, -- [{"name":"lululemon","position":1},...] - ask_count INT NOT NULL DEFAULT 0, - mention_count INT NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_question_daily UNIQUE (tenant_id, brand_id, question_id, metric_date) -); -``` - -#### 8) `monitoring_citation_source_daily` — 引用来源日统计(AI 引用排名) - -```sql -CREATE TABLE monitoring_citation_source_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - source_platform VARCHAR(200) NOT NULL, -- 夸克 / 排行榜123网 / 什么值得买 - source_type VARCHAR(50) NOT NULL, -- UGC / 编辑类 / 企业 / 参考资料 / 机构 / 其他 - baidu_weight INT, -- 百度权重 0~10 - citation_count INT NOT NULL DEFAULT 0, - coverage_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_citation_source_daily UNIQUE (tenant_id, brand_id, source_platform, metric_date) -); -``` - -#### 9) `monitoring_citation_page_daily` — 引用页面明细日统计 - -```sql -CREATE TABLE monitoring_citation_page_daily ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - source_platform VARCHAR(200) NOT NULL, - baidu_weight INT, - page_url TEXT NOT NULL, - citation_count INT NOT NULL DEFAULT 0, - coverage_rate NUMERIC(5,4) NOT NULL DEFAULT 0, - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_citation_page_daily UNIQUE (tenant_id, brand_id, page_url, metric_date) -); -``` - -#### 10) `monitoring_brand_impression` — 品牌印象词(词云数据) - -```sql -CREATE TABLE monitoring_brand_impression ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL, - brand_id BIGINT NOT NULL, - metric_date DATE NOT NULL, - - word VARCHAR(100) NOT NULL, - weight INT NOT NULL DEFAULT 1, -- 词频权重 - - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uk_impression_word UNIQUE (tenant_id, brand_id, word, metric_date) -); -``` - -### 5.4 分区策略 - -| 表 | 分区方式 | 保留周期 | -| --- | --- | --- | -| `question_monitor_runs` | RANGE by `run_date`(月级) | 90 天 | -| `question_monitor_parse_results` | 不分区(通过 `run_id` 关联) | 90 天 | -| 所有 `monitoring_*` 汇总表 | 不分区(数据量可控) | 180 天+ | - -### 5.5 索引策略 - -所有汇总表的 UNIQUE 约束自动创建复合索引,天然覆盖 Dashboard 的查询路径(`WHERE tenant_id = ? AND brand_id = ? AND metric_date BETWEEN ? AND ?`)。 - -额外索引: - -```sql --- 主页趋势图和指标卡的高频查询 -CREATE INDEX idx_brand_overview_range ON monitoring_brand_daily_overview(tenant_id, brand_id, metric_date DESC); - --- 竞品分析页的高频查询 -CREATE INDEX idx_competitor_range ON monitoring_competitor_daily(tenant_id, brand_id, metric_date DESC); - --- 问题页排序 -CREATE INDEX idx_question_daily_heat ON monitoring_question_daily(tenant_id, brand_id, metric_date DESC, heat_index DESC); -``` - -## 6. 后端 API 设计 - -### 6.1 路由规划 - -所有路由挂载在 `tenant` 认证组下,复用现有 JWT + TenantScope 中间件。 - -``` -GET /api/tenant/monitoring/dashboard/overview → 主页指标卡 -GET /api/tenant/monitoring/dashboard/exposure-trend → 主页曝光趋势图 -GET /api/tenant/monitoring/dashboard/competitors → 主页前五竞争对手 -GET /api/tenant/monitoring/dashboard/citation-sources → 主页引用来源类型占比 + 前五引用平台 -GET /api/tenant/monitoring/dashboard/recent-questions → 主页最新 AI 对话 -GET /api/tenant/monitoring/dashboard/brand-impression → 主页品牌印象词云 - -GET /api/tenant/monitoring/platforms/stats → 平台占比 - 提及次数排名 -GET /api/tenant/monitoring/platforms/:id/questions → 平台占比 - 各平台问题明细 - -GET /api/tenant/monitoring/competitors/exposure → 竞争对手 - 曝光度排名 -GET /api/tenant/monitoring/competitors/detail → 竞争对手 - 排名情况明细 - -GET /api/tenant/monitoring/topics → 业务主题列表 -GET /api/tenant/monitoring/topics/:id/citations → 业务主题 - 引用详情 - -GET /api/tenant/monitoring/questions → AI 对话问题列表(分页) -GET /api/tenant/monitoring/questions/:id/effect → 问题效果详情 -GET /api/tenant/monitoring/questions/:id/citations → 问题引用详情 - -GET /api/tenant/monitoring/citations/trend → AI 引用排名 - 来源平台趋势 -GET /api/tenant/monitoring/citations/pages → AI 引用排名 - 引用页面列表 - -GET /api/tenant/monitoring/ai-platforms → AI 平台下拉列表 -``` - -### 6.2 通用查询参数 - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -| --- | --- | --- | --- | --- | -| `brand_id` | int64 | 是 | - | 品牌 ID | -| `ai_platform_id` | int64 | 否 | 0 (全部) | AI 平台筛选 | -| `days` | int | 否 | 7 | 时间范围:7 / 30 / 90 | -| `page` | int | 否 | 1 | 分页页码 | -| `page_size` | int | 否 | 20 | 分页大小,最大 100 | - -### 6.3 响应格式 - -复用现有 `response.Success` / `response.Error` 统一格式: - -```json -{ - "code": 0, - "data": { ... }, - "request_id": "req_abc123" -} -``` - -### 6.4 关键 API 响应结构 - -#### Dashboard Overview - -```json -{ - "exposure_rate": 0.6333, - "mention_rate": 0.7500, - "top1_mention_rate": 0.4800, - "positive_rate": 0.8200, - "sentiment_score": 83, - "exposure_rate_change": 0.05, - "mention_rate_change": -0.02 -} -``` - -#### Competitors - -```json -{ - "items": [ - { - "rank": 1, - "name": "lululemon", - "is_self": true, - "sentiment_score": 83, - "sentiment_label": "excellent", - "exposure_rate": 0.64 - }, - { - "rank": 2, - "name": "Nike", - "is_self": false, - "sentiment_score": 69, - "sentiment_label": "normal", - "exposure_rate": 0.47 - } - ] -} -``` - -#### Questions (分页) - -```json -{ - "items": [ - { - "question_id": 101, - "question_text": "瑜伽垫品牌推荐", - "heat_index": 5, - "exposure_rate": 1.0, - "avg_position": 1, - "sentiment_label": "excellent", - "top3_brands": ["lululemon", "Alo Yoga", "Nike"] - } - ], - "total": 42, - "page": 1, - "page_size": 20 -} -``` - -## 7. 后端架构 - -### 7.1 服务层设计 - -新增文件与现有代码的关系: - -``` -server/internal/tenant/ -├── app/ -│ ├── monitoring_service.go ← 新建:Dashboard 查询服务 -│ ├── monitoring_collector.go ← 新建:AI 平台采集服务 -│ ├── monitoring_aggregator.go ← 新建:日级聚合 Job -│ ├── monitoring_parser.go ← 新建:回答解析器 -│ ├── brand_service.go ← 已有:品牌 CRUD -│ └── ... -├── transport/ -│ ├── monitoring_handler.go ← 新建:监测 API Handler -│ ├── router.go ← 修改:注册监测路由 -│ └── ... -└── repository/ - └── queries/ - ├── monitoring.sql ← 新建:监测查询 SQL - └── ... -``` - -### 7.2 MonitoringService 结构 - -```go -type MonitoringService struct { - pool *pgxpool.Pool - cache cache.Cache // 复用现有 cache.Cache 接口(Redis) -} - -// Dashboard 查询方法(全部走缓存 → 汇总表路径) -func (s *MonitoringService) DashboardOverview(ctx, tenantID, brandID, days) → DashboardOverviewResp -func (s *MonitoringService) ExposureTrend(ctx, tenantID, brandID, days) → []TrendPoint -func (s *MonitoringService) TopCompetitors(ctx, tenantID, brandID, days, limit) → []CompetitorItem -func (s *MonitoringService) CitationSources(ctx, tenantID, brandID, days) → CitationSourceResp -func (s *MonitoringService) RecentQuestions(ctx, tenantID, brandID, limit) → []RecentQuestionItem -func (s *MonitoringService) BrandImpression(ctx, tenantID, brandID, days) → []ImpressionWord -func (s *MonitoringService) PlatformStats(ctx, tenantID, brandID, days) → []PlatformStatItem -func (s *MonitoringService) PlatformQuestions(ctx, tenantID, brandID, platformID, days, page) → PagedQuestions -func (s *MonitoringService) CompetitorExposure(ctx, tenantID, brandID, days) → CompetitorExposureResp -func (s *MonitoringService) CompetitorDetail(ctx, tenantID, brandID, days) → []CompetitorDetailItem -func (s *MonitoringService) Topics(ctx, tenantID, brandID, platformID, days) → []TopicItem -func (s *MonitoringService) Questions(ctx, tenantID, brandID, platformID, days, page, pageSize) → PagedQuestions -func (s *MonitoringService) CitationTrend(ctx, tenantID, brandID, platformID, days) → []CitationTrendPoint -func (s *MonitoringService) CitationPages(ctx, tenantID, brandID, days, page, pageSize) → PagedCitationPages -func (s *MonitoringService) AIPlatforms(ctx, tenantID) → []AIPlatform -``` - -### 7.3 MonitoringCollector 结构 - -```go -type MonitoringCollector struct { - pool *pgxpool.Pool - llmClients map[string]llm.Client // 每个 AI 平台一个 LLM Client - objectStorage objectstorage.Client // 原始回答存 MinIO - logger *zap.Logger -} - -// 采集流程 -func (c *MonitoringCollector) RunDailyCollection(ctx, tenantID, brandID) error -func (c *MonitoringCollector) CollectSingle(ctx, question, platform) (*MonitorRun, error) -func (c *MonitoringCollector) ParseAnswer(ctx, run, rawAnswer) (*ParseResult, error) -``` - -### 7.4 MonitoringAggregator 结构 - -```go -type MonitoringAggregator struct { - pool *pgxpool.Pool - cache cache.Cache - logger *zap.Logger -} - -// 聚合 Job(通常每小时或每天运行一次) -func (a *MonitoringAggregator) AggregateDaily(ctx, tenantID, brandID, date) error -func (a *MonitoringAggregator) InvalidateCache(ctx, tenantID, brandID) error -``` - -### 7.5 Bootstrap 集成 - -在 `bootstrap.go` 的 `App` 结构中添加: - -```go -type App struct { - // ... 现有字段 ... - MonitoringCollector *app.MonitoringCollector - MonitoringAggregator *app.MonitoringAggregator -} -``` - -在 `New()` 中初始化新的 LLM clients(每个 AI 平台一个),注入到 Collector。 - -### 7.6 配置扩展 - -在 `config.yaml` 中新增: - -```yaml -monitoring: - # 采集配置 - collection: - daily_run_count: 1 # 每题每天默认执行次数 - worker_concurrency: 5 # 并发采集 worker 数 - question_timeout: 30s # 单次采集超时 - batch_size: 50 # 每批提交数量 - - # 聚合配置 - aggregation: - schedule: "0 3 * * *" # 每天凌晨 3 点聚合 - lookback_days: 1 # 回溯天数 - - # AI 平台 API - platforms: - deepseek: - base_url: "https://api.deepseek.com/v1" - api_key: "" - model: "deepseek-chat" - qwen: - base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1" - api_key: "" - model: "qwen-max" - doubao: - base_url: "https://ark.cn-beijing.volces.com/api/v3" - api_key: "" - model: "doubao-seed-2-0-lite-260215" - hunyuan: - base_url: "https://hunyuan.tencentcloudapi.com" - api_key: "" - model: "hunyuan-lite" - ernie: - base_url: "https://aip.baidubce.com/rpc/2.0/ai_custom/v1" - api_key: "" - model: "ernie-4.0-8k" - - # 缓存配置 - cache: - overview_ttl: 5m - trend_ttl: 15m - competitor_ttl: 15m - topic_ttl: 30m - question_ttl: 10m - citation_ttl: 15m -``` - -## 8. 缓存策略(5 万并发核心) - -### 8.1 缓存架构 - -``` -Browser → CDN (静态资源) → Nginx → Go API → Redis Cache → PostgreSQL - ↑ - 90%+ 命中率 - 直接返回 -``` - -### 8.2 缓存 Key 设计 - -复用 V1 的 Key 格式,扩展到新维度: - -``` -mon:{tenant}:{brand}:overview:{days} → 指标卡 -mon:{tenant}:{brand}:trend:{days} → 趋势图 -mon:{tenant}:{brand}:competitors:{days} → 竞品排名 -mon:{tenant}:{brand}:citation_sources:{days} → 引用来源 -mon:{tenant}:{brand}:recent_questions → 最新对话 -mon:{tenant}:{brand}:impression:{days} → 品牌印象 -mon:{tenant}:{brand}:platform_stats:{days} → 平台统计 -mon:{tenant}:{brand}:platform:{pid}:questions:{days}:{page} → 平台问题 -mon:{tenant}:{brand}:comp_exposure:{days} → 竞品曝光 -mon:{tenant}:{brand}:topics:{pid}:{days} → 业务主题 -mon:{tenant}:{brand}:questions:{pid}:{days}:{page} → 对话问题列表 -mon:{tenant}:{brand}:citation_trend:{pid}:{days} → 引用趋势 -mon:{tenant}:{brand}:citation_pages:{days}:{page} → 引用页面 -``` - -### 8.3 缓存 TTL 矩阵 - -| 数据类型 | TTL | 原因 | -| --- | --- | --- | -| Overview 指标卡 | 5 min | 高频访问入口,需要相对实时 | -| 趋势图 | 15 min | 日级数据变化慢 | -| 竞品排名 | 15 min | 日级数据 | -| 引用来源 / 引用页面 | 15 min | 日级聚合 | -| 业务主题 | 30 min | 低频变更 | -| 问题列表 | 10 min | 带分页,命中粒度细 | -| 品牌印象 | 30 min | 日级聚合 | -| AI 平台列表 | 1 hour | 几乎不变 | - -### 8.4 缓存失效策略 - -1. **TTL 自动过期**:主要策略,简单可靠。 -2. **聚合 Job 主动失效**:`MonitoringAggregator.InvalidateCache()` 在每次聚合完成后删除对应品牌的所有缓存 Key。 -3. **不做 Cache-Aside Write-Through**:汇总表是离线 Job 写入的,不是用户请求触发的,因此只需被动读取缓存。 - -### 8.5 50000 并发容量分析 - -| 层 | 单机容量 | 5 万并发时 QPS 估算 | 需要实例数 | -| --- | --- | --- | --- | -| Go API | 20K+ 并发连接 | ~10K QPS | 3 实例 | -| Redis | 100K+ QPS | ~10K QPS(90% 命中) | 1 主实例 | -| PostgreSQL | 5K QPS | ~1K QPS(缓存穿透) | 1 主 + 1 只读副本 | - -> 假设:平均用户每 30 秒发起 1 次 API 请求 → 50000/30 ≈ 1700 QPS;峰值 3x → ~5000 QPS。Redis 缓存承接 90%+ → PG 仅 ~500 QPS。 - -## 9. 前端架构 - -### 9.1 页面结构 - -``` -apps/admin-web/src/ -├── views/monitoring/ -│ ├── MonitoringDashboardView.vue ← 主页 -│ ├── MonitoringPlatformView.vue ← 平台占比分析 -│ ├── MonitoringCompetitorView.vue ← 竞争对手分析 -│ ├── MonitoringTopicView.vue ← 业务主题分析 -│ ├── MonitoringQuestionsView.vue ← AI 对话问题 -│ └── MonitoringCitationView.vue ← AI 引用排名 -├── components/monitoring/ -│ ├── ExposureTrendChart.vue ← ECharts 折线图 -│ ├── CompetitorBarChart.vue ← 分组柱状图 -│ ├── PlatformHorizontalBar.vue ← 横向柱状图 -│ ├── CitationDonutChart.vue ← 环形图 -│ ├── CitationTrendChart.vue ← 引用趋势折线图 -│ ├── BrandImpressionCloud.vue ← 词云 -│ ├── HeatIndicator.vue ← 热度指示器(IIIII) -│ ├── SentimentBadge.vue ← 情感标签(优秀/一般) -│ ├── CompetitorRankTable.vue ← 竞品排名表格 -│ └── MonitoringFilters.vue ← 通用筛选栏(品牌+平台+时间) -└── lib/ - └── api.ts ← 修改:新增 monitoringApi -``` - -### 9.2 路由配置 - -在 `router/index.ts` 中新增,替换现有的 `/tracking` FeatureStubView: - -```typescript -// 数据追踪 - 主路由组 -{ - path: '/monitoring', - name: 'monitoring', - redirect: '/monitoring/dashboard', - children: [ - { path: 'dashboard', name: 'monitoring-dashboard', component: MonitoringDashboardView }, - { path: 'platforms', name: 'monitoring-platforms', component: MonitoringPlatformView }, - { path: 'competitors', name: 'monitoring-competitors', component: MonitoringCompetitorView }, - { path: 'topics', name: 'monitoring-topics', component: MonitoringTopicView }, - { path: 'questions', name: 'monitoring-questions', component: MonitoringQuestionsView }, - { path: 'citations', name: 'monitoring-citations', component: MonitoringCitationView }, - ] -} -``` - -### 9.3 Chart Library - -**选用 ECharts + vue-echarts**: - -- 已在现有 plan(Phase 18)中确定 -- 原生中文支持,社区生态成熟 -- 支持所有所需图表类型:折线图、分组柱状图、横向柱状图、环形图 -- 词云使用 `echarts-wordcloud` 扩展 - -依赖添加: - -```json -{ - "echarts": "^5.5", - "vue-echarts": "^7.0", - "echarts-wordcloud": "^2.1" -} -``` - -### 9.4 Shared Types - -在 `packages/shared-types/src/index.ts` 中新增接口: - -```typescript -// AI 平台 -export interface AIPlatform { - id: number - name: string - icon: string - provider_key: string -} - -// 主页 -export interface MonitoringOverview { - exposure_rate: number - mention_rate: number - top1_mention_rate: number - positive_rate: number - sentiment_score: number - exposure_rate_change: number - mention_rate_change: number -} - -export interface TrendPoint { - date: string - exposure_rate: number - brands: Array<{ name: string; exposure_rate: number }> -} - -export interface CompetitorRankItem { - rank: number - name: string - is_self: boolean - icon?: string - sentiment_score: number - sentiment_label: string - exposure_rate: number -} - -export interface CitationSourceItem { - source_platform: string - source_type: string - baidu_weight: number - citation_count: number - coverage_rate: number -} - -export interface MonitoringQuestionItem { - question_id: number - question_text: string - heat_index: number - exposure_rate: number - avg_position: number - sentiment_label: string - top3_brands: string[] -} - -export interface CitationPageItem { - source_platform: string - baidu_weight: number - page_url: string - citation_count: number - coverage_rate: number -} - -export interface ImpressionWord { - word: string - weight: number -} - -export interface PlatformStatItem { - ai_platform_id: number - platform_name: string - platform_icon: string - mention_count: number - avg_position: number - exposure_rate: number -} - -export interface TopicItem { - topic_id: number - topic_name: string - question_count: number - exposure_rate: number - mention_count: number -} -``` - -### 9.5 API Client - -```typescript -export const monitoringApi = { - // 主页 - dashboardOverview: (brandId: number, days = 7) => - http.get('/monitoring/dashboard/overview', { params: { brand_id: brandId, days } }), - exposureTrend: (brandId: number, days = 7) => - http.get('/monitoring/dashboard/exposure-trend', { params: { brand_id: brandId, days } }), - topCompetitors: (brandId: number, days = 7) => - http.get('/monitoring/dashboard/competitors', { params: { brand_id: brandId, days } }), - citationSources: (brandId: number, days = 7) => - http.get<{ type_distribution: ..., top_platforms: CitationSourceItem[] }>('/monitoring/dashboard/citation-sources', { params: { brand_id: brandId, days } }), - recentQuestions: (brandId: number) => - http.get('/monitoring/dashboard/recent-questions', { params: { brand_id: brandId } }), - brandImpression: (brandId: number, days = 7) => - http.get('/monitoring/dashboard/brand-impression', { params: { brand_id: brandId, days } }), - - // 平台占比 - platformStats: (brandId: number, days = 7) => - http.get('/monitoring/platforms/stats', { params: { brand_id: brandId, days } }), - platformQuestions: (brandId: number, platformId: number, days = 7, page = 1) => - http.get>(`/monitoring/platforms/${platformId}/questions`, { params: { brand_id: brandId, days, page } }), - - // 竞争对手 - competitorExposure: (brandId: number, days = 7) => - http.get('/monitoring/competitors/exposure', { params: { brand_id: brandId, days } }), - competitorDetail: (brandId: number, days = 7) => - http.get('/monitoring/competitors/detail', { params: { brand_id: brandId, days } }), - - // 业务主题 - topics: (brandId: number, platformId = 0, days = 7) => - http.get('/monitoring/topics', { params: { brand_id: brandId, ai_platform_id: platformId, days } }), - - // AI 对话问题 - questions: (brandId: number, params: { ai_platform_id?: number; days?: number; page?: number; page_size?: number }) => - http.get>('/monitoring/questions', { params: { brand_id: brandId, ...params } }), - - // AI 引用排名 - citationTrend: (brandId: number, platformId = 0, days = 7) => - http.get('/monitoring/citations/trend', { params: { brand_id: brandId, ai_platform_id: platformId, days } }), - citationPages: (brandId: number, days = 7, page = 1, pageSize = 20) => - http.get>('/monitoring/citations/pages', { params: { brand_id: brandId, days, page, page_size: pageSize } }), - - // 通用 - aiPlatforms: () => http.get('/monitoring/ai-platforms'), -} -``` - -## 10. 采集流水线设计 - -### 10.1 采集流程 - -``` -┌──────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ -│ 调度器 │ │ Redis Stream │ │ Collector │ │ Parser │ -│ (cron) │────▶│ / 内部队列 │────▶│ Worker ×5 │────▶│ (解析回答) │ -│ 生成任务 │ │ │ │ 调用 AI API │ │ 提取指标 │ -└──────────┘ └───────────────┘ └──────┬───────┘ └──────┬───────┘ - │ │ - ▼ ▼ - ┌─────────────┐ ┌──────────────┐ - │ MinIO │ │ PostgreSQL │ - │ (原始回答) │ │ (解析结果) │ - └─────────────┘ └──────────────┘ -``` - -### 10.2 调度策略 - -```go -// 每天凌晨 1 点执行 -func (c *MonitoringCollector) RunDailyCollection(ctx context.Context, tenantID int64) error { - // 1. 查询该租户所有 active 品牌 - brands := listActiveBrands(ctx, tenantID) - for _, brand := range brands { - // 2. 查询品牌下所有 active 问题(含 question_hash 去重) - questions := listUniqueActiveQuestions(ctx, brand.ID) - // 3. 查询所有 active AI 平台 - platforms := listActivePlatforms(ctx, tenantID) - // 4. 生成采集任务 = questions × platforms - for _, q := range questions { - for _, p := range platforms { - enqueueTask(ctx, brand, q, p) - } - } - } -} -``` - -### 10.3 回答解析逻辑 - -```go -func (c *MonitoringCollector) ParseAnswer(ctx context.Context, question, brand string, competitors []string, rawAnswer string) *ParseResult { - // 1. 品牌提及检测:在回答中查找品牌名出现位置 - // 2. 首位提及检测:品牌是否出现在推荐列表第一位 - // 3. 首选推荐检测:品牌是否被明确推荐 - // 4. 情感分析:使用 LLM 对品牌相关段落做情感判断 - // 5. 竞品提及检测:检测 competitors 列表中的品牌出现情况 - // 6. 引用 URL 提取:从回答中提取所有外部链接 - // 7. 系统文章引用匹配:与 articles 表做 URL/标题匹配 - // 8. 品牌印象词提取:使用 LLM 从回答中提取品牌相关形容词 -} -``` - -### 10.4 聚合 Job - -```go -// 每天凌晨 3 点执行(在采集完成后) -func (a *MonitoringAggregator) AggregateDaily(ctx context.Context, date time.Time) error { - tenants := listAllTenants(ctx) - for _, t := range tenants { - brands := listActiveBrands(ctx, t.ID) - for _, b := range brands { - // 1. 从 parse_results 聚合 → monitoring_brand_daily_overview - a.aggregateBrandOverview(ctx, t.ID, b.ID, date) - // 2. 聚合 → monitoring_platform_daily / monitoring_platform_question_daily - a.aggregatePlatformStats(ctx, t.ID, b.ID, date) - // 3. 聚合 → monitoring_competitor_daily / monitoring_competitor_platform_daily - a.aggregateCompetitorStats(ctx, t.ID, b.ID, date) - // 4. 聚合 → monitoring_topic_daily - a.aggregateTopicStats(ctx, t.ID, b.ID, date) - // 5. 聚合 → monitoring_question_daily - a.aggregateQuestionStats(ctx, t.ID, b.ID, date) - // 6. 聚合 → monitoring_citation_source_daily / monitoring_citation_page_daily - a.aggregateCitationStats(ctx, t.ID, b.ID, date) - // 7. 聚合 → monitoring_brand_impression - a.aggregateBrandImpression(ctx, t.ID, b.ID, date) - // 8. 清除该品牌缓存 - a.InvalidateCache(ctx, t.ID, b.ID) - } - } -} -``` - -## 11. 部署拓扑(5 万并发) - -### 11.1 推荐部署架构 - -``` - ┌───────────────────────────┐ - │ CDN / 云 LB (SLB) │ - │ 静态资源 + API 路由 │ - └─────────────┬─────────────┘ - │ - ┌─────────────┼─────────────┐ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ tenant-api│ │ tenant-api│ │ tenant-api│ ×3 (API 服务) - │ Pod/容器 │ │ Pod/容器 │ │ Pod/容器 │ - └────┬──────┘ └────┬──────┘ └────┬──────┘ - │ │ │ - ┌────┴─────────────┴─────────────┴────┐ - │ Redis 6+ (单主) │ 缓存 + 会话 - │ 或 Redis Cluster │ - └───────────────────┬──────────────────┘ - │ - ┌───────────────────┼──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ PG Primary │ │ PG Replica │ │ MinIO │ - │ (读写) │────▶│ (只读) │ │ (原始回答) │ - └───────────┘ └───────────┘ └───────────┘ - - ╔════════════════════════════════════════════════════════╗ - ║ 独立进程 / CronJob(不接入 LB) ║ - ║ ║ - ║ ┌─────────────────┐ ┌─────────────────┐ ║ - ║ │ Collector Worker │ │ Aggregator Job │ ║ - ║ │ (采集 AI 平台) │ │ (日级聚合) │ ║ - ║ └─────────────────┘ └─────────────────┘ ║ - ╚════════════════════════════════════════════════════════╝ -``` - -### 11.2 资源估算 - -| 组件 | 规格建议 | 说明 | -| --- | --- | --- | -| API 实例 ×3 | 2C4G | Go 服务轻量,3 实例可处理 ~60K 并发连接 | -| Redis | 4G 内存 | 缓存数据量不大,主要是 Key 数量 | -| PostgreSQL Primary | 4C16G | 汇总表小,索引少,500 QPS 绰绰有余 | -| PostgreSQL Replica ×1 | 4C16G | 可选,用于聚合 Job 的重查询分流 | -| MinIO | 50G+ | 原始回答存储,按 90 天保留 | -| Collector Worker ×1 | 2C4G | 采集任务非实时,单实例 5 并发即可 | -| Aggregator Job ×1 | 2C4G | 凌晨运行,单实例即可 | - -### 11.3 数据库连接池配置 - -```yaml -database: - max_open_conns: 50 # 从 25 提升到 50(3 个 API 实例共享) - max_idle_conns: 10 -``` - -按 3 个 API 实例计算:每实例 ~17 连接上限,足够应对缓存穿透后的查询。 - -## 12. 数据量估算 - -### 12.1 汇总表大小 - -假设:100 个品牌,每品牌 5 个竞品,5 个 AI 平台,10 个主题,30 天窗口。 - -| 汇总表 | 行数/天 | 30 天行数 | 估算大小 | -| --- | --- | --- | --- | -| `monitoring_brand_daily_overview` | 100 | 3,000 | < 1 MB | -| `monitoring_platform_daily` | 500 | 15,000 | < 5 MB | -| `monitoring_platform_question_daily` | 50,000 | 1,500,000 | ~200 MB | -| `monitoring_competitor_daily` | 600 | 18,000 | < 5 MB | -| `monitoring_competitor_platform_daily` | 3,000 | 90,000 | ~15 MB | -| `monitoring_topic_daily` | 1,000 | 30,000 | < 5 MB | -| `monitoring_question_daily` | 50,000 | 1,500,000 | ~250 MB | -| `monitoring_citation_source_daily` | 5,000 | 150,000 | ~25 MB | -| `monitoring_citation_page_daily` | 10,000 | 300,000 | ~60 MB | -| `monitoring_brand_impression` | 5,000 | 150,000 | ~20 MB | - -**30 天汇总表总计 < 600 MB**,PostgreSQL 完全可控。 - -### 12.2 原始采集表大小 - -``` -100 品牌 × 100 问题 × 5 平台 × 1 次/天 = 50,000 条/天 -90 天保留 = 4,500,000 条原始记录 -每条约 1KB(不含原始回答文本)→ ~4.5 GB -原始回答文本(~5KB/条)→ MinIO 约 22 GB/90 天 -``` - -## 13. 实施计划 - -### Phase 15: 数据库迁移 + AI 平台注册表 - -- 创建所有新表的 migration 文件 -- 种子数据:5 个默认 AI 平台 -- 估时:1 天 - -### Phase 16: 后端 - 监测查询服务 + API - -- 实现 `MonitoringService` 的所有查询方法 -- 实现 `monitoring_handler.go` 的所有路由 -- 实现 Redis 缓存层 -- 注册路由到 `router.go` -- 估时:3 天 - -### Phase 17: 后端 - 采集流水线 - -- 实现 `MonitoringCollector` + `MonitoringParser` -- 对接 5 个 AI 平台 API(复用现有 `llm.Client` 接口) -- 原始回答存储 MinIO -- 估时:3 天 - -### Phase 18: 后端 - 聚合 Job - -- 实现 `MonitoringAggregator` 的 8 个聚合函数 -- 实现缓存失效逻辑 -- 实现定时调度(可先用 in-process cron) -- 估时:2 天 - -### Phase 19: 种子数据 - -- 生成 30 天真实结构的 mock 数据,覆盖所有汇总表 -- 确保前端开发可立即使用 -- 估时:1 天 - -### Phase 20: 前端 - 6 页面 + ECharts - -- 安装 echarts / vue-echarts / echarts-wordcloud -- 实现 6 个页面 + 10 个图表组件 -- 实现通用筛选栏组件 -- i18n 扩展 -- 估时:4 天 - -### Phase 21: 前端验证 + 集成测试 - -- `pnpm typecheck:admin && pnpm build:admin` 通过 -- `go test ./...` 通过 -- 全链路验证:种子数据 → API → 前端图表渲染 -- 估时:1 天 - -**总估时:约 15 个工作日** - -## 14. 风险与决策项 - -### 14.1 待确认 - -| 序号 | 决策项 | 影响范围 | 建议 | -| --- | --- | --- | --- | -| 1 | 各 AI 平台 API Key 如何获取和管理 | 采集流水线 | 建议 config.yaml 配置,生产环境用环境变量覆盖 | -| 2 | 业务主题的分类方式(人工还是 LLM 自动归类) | 主题分析页 | V1 建议人工创建主题并关联问题,V2 可自动归类 | -| 3 | 百度权重数据来源 | 引用排名页 | 建议接入第三方 SEO API 或定期手动维护 | -| 4 | 情感分析的准确度要求 | 竞品分析 + 问题列表 | 建议先用规则匹配,重要场景用 LLM 二次判断 | -| 5 | 是否需要支持自定义 AI 平台 | 平台管理 | V1 建议固定 5 个平台,用户不可增删 | -| 6 | 部署环境选择(K8s / Docker Compose / 裸机) | 部署架构 | 开发阶段 Docker Compose,生产建议 K8s | - -### 14.2 已确认(沿用 V1 决策) - -| 决策 | 来源 | -| --- | --- | -| 问题是最小监控采集单元 | V1 核心结论 | -| 原始回答存对象存储,不存 PG | V1 Section 8.2 | -| 每问题每天默认执行 1 次 | V1 Section 8.3 | -| 问题 hash 去重 | V1 Section 8.4 | -| 问题版本化保证历史口径 | V1 Section 9 | -| 页面只查汇总表 | V1 Section 10 | -| 原始记录保留 90 天 | V1 Section 12 | - -## 15. 附录 - -### 15.1 已有表 DDL 参考 - -```sql --- brands(已存在) -CREATE TABLE brands ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - name VARCHAR(200) NOT NULL, - description TEXT, - status VARCHAR(20) NOT NULL DEFAULT 'active', - ... -); - --- brand_keywords(已存在) -CREATE TABLE brand_keywords ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - brand_id BIGINT NOT NULL REFERENCES brands(id), - name VARCHAR(200) NOT NULL, - ... -); - --- brand_questions(已存在) -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, - ... -); - --- brand_question_versions(已存在) -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, - ... -); - --- competitors(已存在) -CREATE TABLE competitors ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id), - brand_id BIGINT NOT NULL REFERENCES brands(id), - name VARCHAR(200) NOT NULL, - website TEXT, - description TEXT, - product_lines_json JSONB, - ... -); -``` - -### 15.2 指标口径定义 - -| 指标 | 计算公式 | 说明 | -| --- | --- | --- | -| 曝光度 (exposure_rate) | 品牌在 AI 回答中被提及的问题数 / 采集的总问题数 | 反映品牌在 AI 平台的总体可见度 | -| 提及率 (mention_rate) | 品牌被提及的回答次数 / 总提问次数 | 跨平台汇总 | -| 首位提及率 (top1_mention_rate) | 品牌出现在推荐列表首位的次数 / 总提问次数 | 衡量品牌在 AI 推荐中的优先级 | -| 首选推荐率 (first_recommend_rate) | 品牌被明确推荐的次数 / 总提问次数 | 衡量 AI 对品牌的推荐倾向 | -| 正面提及率 (positive_mention_rate) | 正面情感的提及次数 / 总提及次数 | 衡量品牌的 AI 口碑 | -| 情感得分 (sentiment_score) | 正面提及占比 × 100,修正后 0~100 | 综合情感指标 | -| 热度指数 (heat_index) | 近期被触发的 AI 问答频次标准化后 1~5 档 | 问题热度 | -| 覆盖率 (coverage_rate) | 该来源被引用的问题数 / 总问题数 | 引用来源的覆盖广度 | diff --git a/docs/ai-brand-monitoring-tech-design-v3.md b/docs/ai-brand-monitoring-tech-design-v3.md deleted file mode 100644 index 4f6df54..0000000 --- a/docs/ai-brand-monitoring-tech-design-v3.md +++ /dev/null @@ -1,934 +0,0 @@ -# AI 品牌曝光监测系统技术方案 V3 - -## 1. 文档信息 - -| 项目 | 内容 | -| --- | --- | -| 文档名称 | AI 品牌曝光监测系统技术方案 V3(容量与口径修订稿) | -| 文档版本 | V3.0 | -| 文档状态 | 待评审 | -| 创建日期 | 2026-04-05 | -| 基线文档 | `docs/ai-brand-monitoring-tech-design-v2.md` | -| 适用范围 | 数据追踪模块全部页面 | -| 关联文档 | `docs/geo-platform-prd-v1.md` (PRD 8.4 数据追踪) | -| 关联文档 | `docs/question-driven-monitoring-design-v1.md` (V1 数据模型) | -| 容量目标 | 5 万并发 **活跃** 用户(同时打开页面、切换筛选、触发突刺流量) | - -## 2. V3 修订范围 - -本文档不再只是 V2 的“容量补丁”。本版同时修正了 V2 与采集可行性方案之间的口径断层,尤其是: - -1. `run_mode` 未入模,导致标准回答和搜索增强回答不可比。 -2. 问题版本未完整进入汇总表唯一键,历史口径会被覆盖。 -3. 采集时间窗与聚合时序冲突,无法同时满足 T+1 发布与一致性。 -4. 在线缓存与离线任务队列共用同一 Redis 故障域,风险过高。 - -读者应先读完 V2 全文,再用本文档替换或补充以下章节: - -| V2 章节 | V3 替换 / 补充章节 | 变更原因 | -| --- | --- | --- | -| 5. 数据库 Schema | 2.2 数据口径与 Schema Delta | V2 未建模 `run_mode`、版本键、引用事实 | -| 8. 缓存策略 | 3. 读流量模型与缓存架构 | V2 低估页面 fan-out,缺少防雪崩手段 | -| 10. 采集流水线设计 | 4. 采集流水线扩容 | V2 采集时间窗 / worker 容量不足 | -| 11. 部署拓扑 | 5. 部署拓扑与资源 | V2 连接池计算有误、单点问题 | -| 7. 后端架构 / 7.6 配置扩展 | 4.5、5.3、5.4 | V2 仍按单 provider / 单 Redis / 单 DB 结构假设 | -| (无) | 6. 降级与容灾 | V2 缺失 | -| 13. 实施计划 (Phase 21) | 7. 压测验收标准 | V2 缺失 | - -V2 其他章节(页面需求、前端组件、API 响应结构、指标定义)原则上保持不变;但只要与本文中的口径分层、Schema Delta、采集时序冲突,应以 V3 为准。 - -### 2.1 数据口径分层与 UI 取数规则 - -为避免把不可比的数据混到同一张榜单中,V3 将监测数据拆成 3 种采集模式: - -| `run_mode` | 含义 | 是否进入主统计 | 默认消费页面 | -| --- | --- | --- | --- | -| `api_standard` | 固定 prompt、低温、无搜索增强的标准 API 回答 | 是 | Dashboard、平台占比、竞争对手、业务主题、AI 对话问题、品牌印象 | -| `api_search_grounded` | 开启平台官方搜索 / 引文能力后的回答 | 是,但单独统计 | AI 引用排名;未来可选做“搜索增强视角”专题页 | -| `ui_sample` | 面向 PoC / 验证的前台 UI 采样结果 | 否 | 仅验证报表和 badcase,不进主 Dashboard | - -核心规则: - -1. **跨平台曝光、竞品、问题排行默认只看 `api_standard`。** -2. **引用相关页面默认只看 `api_search_grounded`。** -3. **不支持搜索增强或引文的平台,在引用页面中标记为“未接入 / 不支持”,不按 0 值并入分母。** -4. **`ui_sample` 只用于验证,不参与 `monitoring_*` 主汇总。** - -### 2.2 Schema Delta(覆盖 V2 Schema 的必要修订) - -V2 的基础表结构仍可复用,但以下修订是必需项。 - -#### 2.2.1 `ai_platforms` - -增加平台能力字段,避免查询层硬编码: - -1. `supports_standard BOOLEAN NOT NULL DEFAULT true` -2. `supports_search_grounded BOOLEAN NOT NULL DEFAULT false` -3. `supports_citation BOOLEAN NOT NULL DEFAULT false` -4. `supports_batch BOOLEAN NOT NULL DEFAULT false` -5. `default_run_mode VARCHAR(30) NOT NULL DEFAULT 'api_standard'` - -#### 2.2.2 `question_monitor_runs` - -新增以下字段: - -1. `business_date DATE NOT NULL`:统计归属日,允许 `run_at` 发生在次日凌晨。 -2. `run_mode VARCHAR(30) NOT NULL`:`api_standard` / `api_search_grounded` / `ui_sample` -3. `provider_model VARCHAR(100) NOT NULL` -4. `prompt_template_version VARCHAR(30) NOT NULL` -5. `search_enabled BOOLEAN NOT NULL DEFAULT false` -6. `request_id VARCHAR(100)` -7. `retry_count INT NOT NULL DEFAULT 0` - -建议增加幂等索引: - -```sql -CREATE UNIQUE INDEX uk_monitor_run_idempotent -ON question_monitor_runs(tenant_id, brand_id, question_version_id, ai_platform_id, run_mode, business_date); -``` - -#### 2.2.3 `question_monitor_parse_results` - -新增以下字段: - -1. `run_mode VARCHAR(30) NOT NULL` -2. `search_hit BOOLEAN NOT NULL DEFAULT false` -3. `search_info_json_key TEXT` -4. `extractor_version VARCHAR(30) NOT NULL DEFAULT 'v1'` - -#### 2.2.4 新增任务与质量表 - -新增下列表: - -1. `monitoring_collect_tasks`:调度任务、重试、死信、优先级、租约状态 -2. `monitoring_citation_facts`:引用规范化事实表,保存 `normalized_url/domain/citation_hash` -3. `monitoring_parse_badcases`:低置信度样本与人工回标结果 - -#### 2.2.5 汇总表统一增加 `run_mode` - -以下汇总表必须增加 `run_mode`,并进入唯一键: - -1. `monitoring_brand_daily_overview` -2. `monitoring_platform_daily` -3. `monitoring_platform_question_daily` -4. `monitoring_competitor_daily` -5. `monitoring_competitor_platform_daily` -6. `monitoring_topic_daily` -7. `monitoring_question_daily` -8. `monitoring_citation_source_daily` -9. `monitoring_citation_page_daily` -10. `monitoring_brand_impression` - -#### 2.2.6 问题版本进入汇总主键 - -以下表必须增加 `question_version_id` 并进入唯一键: - -1. `monitoring_platform_question_daily` -2. `monitoring_question_daily` - -推荐唯一键修订: - -```sql -UNIQUE (tenant_id, brand_id, ai_platform_id, question_id, question_version_id, run_mode, metric_date) -UNIQUE (tenant_id, brand_id, question_id, question_version_id, run_mode, metric_date) -``` - -#### 2.2.7 引用汇总改为从规范化事实表聚合 - -`monitoring_citation_source_daily` 与 `monitoring_citation_page_daily` 不再直接从 `citation_urls JSONB` 聚合,而是从 `monitoring_citation_facts` 聚合: - -1. 页面级汇总按 `normalized_url` -2. 站点级汇总按 `domain` / `site_name` -3. 原始 `page_url` 仅用于回放与调试 - ---- - -## 3. 读流量模型与缓存架构 - -### 3.1 页面 Fan-out 矩阵 - -V2 错误地按"每人 30 秒 1 个请求"建模。实际上,每个页面打开时会 **并行** 发出多个 API 请求。 - -| 页面 | 并行请求数 | 对应 API | -| --- | --- | --- | -| 主页 Dashboard | 6 | overview, exposure-trend, competitors, citation-sources, recent-questions, brand-impression | -| 平台占比分析 | 2 | platform-stats, platform-questions | -| 竞争对手分析 | 2 | competitor-exposure, competitor-detail | -| 业务主题分析 | 1 | topics | -| AI 对话问题 | 1 | questions (分页) | -| AI 引用排名 | 2 | citation-trend, citation-pages | - -加权平均(按访问占比估算:主页 50%、其他页各 10%): - -``` -weighted_fan_out = 0.50 × 6 + 0.10 × 2 + 0.10 × 2 + 0.10 × 1 + 0.10 × 1 + 0.10 × 2 - = 3.0 + 0.2 + 0.2 + 0.1 + 0.1 + 0.2 - = 3.8 次/页面打开 -``` - -### 3.2 QPS 模型 - -| 场景 | 计算 | QPS | -| --- | --- | --- | -| 稳态(50K 用户,平均 30 秒交互 1 次) | 50000 × 3.8 / 30 | **6,333** | -| 突刺(早会/开盘集中打开,10 秒内 30% 用户首屏加载) | 15000 × 6 / 10 | **9,000** | -| 筛选切换(20% 用户同时切换品牌/时间范围) | 10000 × 3.8 / 5 | **7,600** | -| 设计容量(取 P99 峰值 × 1.5 安全余量) | max(6333, 9000, 7600) × 1.5 | **~13,500** | - -**结论**:系统必须在 Redis 层稳定承接 13,500 QPS。穿透到 PG 的流量目标 < 1,350 QPS(10% miss rate)。 - -### 3.3 BFF 聚合层(减少 fan-out 的关键手段) - -为降低首页 6 次并行请求的压力,引入 **BFF 聚合接口**: - -``` -GET /api/tenant/monitoring/dashboard/composite - → 服务端并行查询 6 个数据源,一次返回全部首页数据 - → 默认固定 `scope=standard` - → 单次 Redis GET 命中完整 composite 缓存即可返回 - → 缓存 miss 时服务端内部 goroutine 并行查 6 个汇总表 -``` - -前端首屏只发 **1 个请求**,极端情况下用户手动刷新某个区块再发单接口。 - -| 方案 | 首页 QPS (50K 用户) | Redis 压力 | -| --- | --- | --- | -| 6 个独立接口 | ~10,000 | 60K ops/s | -| composite 聚合 | ~1,667 | 1,667 ops/s | - -其他页面 fan-out ≤ 2,不需要聚合。 - -### 3.4 缓存防雪崩机制 - -V2 仅使用 TTL + 聚合后全量删除,存在雪崩风险。V3 引入 4 层防护: - -#### 3.4.1 Singleflight - -同一 cache key 的并发 miss 请求,只放行 1 个打到 PG,其余等待结果。 - -```go -import "golang.org/x/sync/singleflight" - -var sf singleflight.Group - -func (s *MonitoringService) getWithCache(ctx context.Context, key string, ttl time.Duration, loader func() (any, error)) (any, error) { - // 1. 尝试 Redis GET - if cached, err := s.cache.Get(ctx, key); err == nil { - return cached, nil - } - // 2. Singleflight: 同 key 只执行一次 loader - result, err, _ := sf.Do(key, func() (any, error) { - data, err := loader() - if err != nil { - return nil, err - } - // 写回 Redis,TTL 加随机抖动 - jitteredTTL := ttl + time.Duration(rand.Int63n(int64(ttl/5))) - _ = s.cache.Set(ctx, key, data, jitteredTTL) - return data, nil - }) - return result, err -} -``` - -#### 3.4.2 TTL 随机抖动 - -防止大量 key 同时过期导致集中穿透。 - -``` -实际 TTL = 基础 TTL + random(0, 基础 TTL × 20%) -``` - -例如 5 分钟基础 TTL → 实际 300~360 秒。 - -#### 3.4.3 Stale-While-Revalidate - -缓存过期后,先返回旧数据,后台异步刷新。用户不感知延迟。 - -```go -// Redis 存储两个 key: -// mon:{key} → 数据 (TTL = base_ttl) -// mon:{key}:stale → 数据 (TTL = base_ttl × 3,作为兜底) -// -// 读取流程: -// 1. GET mon:{key} → 命中 → 返回 -// 2. miss → GET mon:{key}:stale → 命中 → 返回 stale 数据 + 异步触发刷新 -// 3. 都 miss → 同步查 PG + singleflight -``` - -#### 3.4.4 热点保护(Hot Key) - -对高频访问的品牌(如首页默认品牌),使用 **进程内 L1 缓存**(Go sync.Map 或 groupcache): - -``` -请求 → L1 进程缓存 (TTL 10s) → Redis (TTL 5min) → PG -``` - -L1 缓存的极短 TTL 保证数据不会太旧,但能挡住 Redis 的瞬时压力。 - -### 3.5 缓存 Key 修订 - -修复 V2 的分页缓存 key 缺少 `page_size` 的问题,同时将统计口径 `scope` 纳入缓存维度。 - -约定: - -1. `scope=standard` 对应 `run_mode=api_standard` -2. `scope=search` 对应 `run_mode=api_search_grounded` -3. `ui_sample` 不进入线上缓存 - -``` -# 分页接口必须包含 scope + page + page_size -mon:{tenant}:{brand}:platform:{pid}:questions:{days}:{scope}:{page}:{pageSize} -mon:{tenant}:{brand}:questions:{pid}:{days}:{scope}:{page}:{pageSize} -mon:{tenant}:{brand}:citation_pages:{days}:{scope}:{page}:{pageSize} - -# composite 聚合接口 -mon:{tenant}:{brand}:dashboard_composite:{days}:{scope} - -# 非分页接口也加 scope -mon:{tenant}:{brand}:overview:{days}:{scope} -mon:{tenant}:{brand}:trend:{days}:{scope} -mon:{tenant}:{brand}:competitors:{days}:{scope} -... -``` - -### 3.6 缓存失效策略修订 - -V2 的"聚合后删除品牌全部缓存"策略改为 **渐进式失效**: - -| 时机 | 操作 | 原因 | -| --- | --- | --- | -| 聚合 Job 完成 | **不删除** 现有缓存 | 避免 5 万用户同时穿透 | -| 聚合 Job 完成 | 异步预热 Top 20 热门品牌的 composite 缓存 | 用新数据替换旧缓存 | -| 聚合 Job 完成 | 设置全局标记 `mon:agg_version:{tenant}` +1 | 通知各实例 L1 缓存过期 | -| 自然过期 | TTL 到期后正常 singleflight 回源 | 冷数据自然刷新 | - -### 3.7 修订后的 TTL 矩阵 - -| 数据类型 | Redis TTL | Stale TTL | L1 TTL | 说明 | -| --- | --- | --- | --- | --- | -| Dashboard composite | 5 min | 15 min | 10 s | 首页入口,高频访问 | -| Overview 指标卡 | 5 min | 15 min | 10 s | 同上 | -| 趋势图 | 15 min | 45 min | 30 s | 日级数据,变化慢 | -| 竞品排名 | 15 min | 45 min | 30 s | 同上 | -| 引用来源 | 15 min | 45 min | 30 s | 同上 | -| 业务主题 | 30 min | 90 min | 60 s | 低频变更 | -| 问题列表(分页) | 10 min | 30 min | 不缓存 | 粒度太细,L1 命中率低 | -| 品牌印象 | 30 min | 90 min | 60 s | 日级聚合 | -| AI 平台列表 | 1 hour | 3 hour | 5 min | 几乎不变 | - ---- - -## 4. 采集流水线扩容 - -### 4.1 V2 瓶颈分析 - -V2 的问题: -- 5 worker、50,000 任务 → 每任务仅 0.72 秒,不现实 -- 凌晨 1 点采集、3 点聚合 → 2 小时时间窗太紧 -- Collector ×1、Aggregator ×1 → 单点故障 - -### 4.2 任务量重新估算 - -``` -标准模式: -100 品牌 × 100 问题/品牌 × 5 AI 平台 = 50,000 任务/天 - -搜索增强模式(首批仅 3 平台: qwen / doubao / hunyuan): -100 品牌 × 100 问题/品牌 × 3 AI 平台 = 30,000 任务/天 - -合计 = 80,000 任务/天 - -# 但问题 hash 去重后(V1 设计) -假设去重率 30% → 实际 56,000 唯一任务 - -# 每次 AI API 调用 + 解析 + 入库: -平均耗时: 8 秒 (含 AI 生成 5s + 解析 2s + 入库 1s) -P99 耗时: 20 秒 (含重试) -``` - -### 4.3 Worker 池设计 - -``` -所需总处理时间 = 56,000 × 8s = 448,000 秒 -可用采集时间窗 = 6 小时 = 21,600 秒 (0:00 ~ 6:00) - -所需并发 worker = 448,000 / 21,600 ≈ 21 -安全系数 1.4 → 30 worker -``` - -**架构**: - -``` -┌─────────────────────────────────────────────────────┐ -│ 采集调度器 (Scheduler) │ -│ • 凌晨 0:00 启动 │ -│ • 生成 business_date = yesterday 的全部任务 │ -│ • 任务维度: 品牌 × 去重问题 × 平台 × run_mode │ -│ • 写入 Queue Redis Stream: mon:collect:tasks │ -└───────────────────────┬─────────────────────────────┘ - │ - ┌─────────────┼─────────────┬─────────────┐ - ▼ ▼ ▼ ▼ - ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ - │ Collector │ │ Collector │ │ Collector │ │ Collector │ - │ Pod #1 │ │ Pod #2 │ │ Pod #3 │ │ standby HPA│ - │ worker ×10 │ │ worker ×10 │ │ worker ×10 │ │ 按需扩容 │ - └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ - ┌─────────────────────────────────────────┐ - │ Queue Redis Stream │ - │ mon:collect:tasks (pending → ack) │ - │ Consumer Group: collectors │ - └─────────────────────────────────────────┘ -``` - -**为什么用 Redis Stream 而不是内存队列**: -- 多 Pod 竞争消费,自动负载均衡 -- 消息持久化,Pod 重启不丢任务 -- Consumer Group 的 XACK 机制提供 at-least-once 保证 -- 但必须使用 **独立 Queue Redis 故障域**,不能和在线缓存共用实例 - -### 4.4 时间窗修订 - -``` -0:00 调度器生成 business_date = yesterday 的全部采集任务 → Queue Redis Stream -0:00 ~ 6:00 Collector workers 消费任务,调用 AI API + 解析 + 写 PG/MinIO -6:00 检查未完成任务数量,告警如果 > 5% -6:10 将未完成任务转入 backfill 队列,不阻塞晨间主聚合 -6:30 Aggregator 聚合 business_date = yesterday 且 status = succeeded 的数据 -7:00 聚合完成 → 预热热门品牌缓存 -7:30 T+1 数据上线,用户访问“昨日完整快照” -``` - -说明: - -1. `run_at` 是实际执行时间,可能发生在次日凌晨。 -2. `business_date` 是统计归属日,Dashboard 晨间看到的是 T+1 快照。 -3. 晚到任务走 backfill,单独触发品牌级重聚合,不污染主发布窗口。 - -预留 6 小时采集窗口(vs V2 的 2 小时),即使 P99 延迟也有足够 buffer。 - -### 4.5 采集重试与限流 - -```go -// 每个 AI 平台独立的限流器 -type PlatformRateLimiter struct { - limiters map[string]*rate.Limiter // provider_key → limiter -} - -// 默认限流配置 -var defaultLimits = map[string]rate.Limit{ - "deepseek": 10, // 10 QPS - "qwen": 10, - "doubao": 10, - "hunyuan": 5, - "ernie": 5, -} - -// 重试策略 -const ( - maxRetries = 3 - retryBaseWait = 2 * time.Second // 指数退避: 2s, 4s, 8s -) -``` - -此外,Collector 必须基于 `run_mode` 和平台能力做任务路由: - -1. 不支持 `api_search_grounded` 的平台不生成搜索增强任务。 -2. `ui_sample` 任务只进入 PoC / badcase 流水线,不进入主聚合队列。 -3. `api_standard` 与 `api_search_grounded` 使用不同的 prompt 模板版本。 - -### 4.6 采集容错 - -```go -// 单条任务失败不阻塞批次 -func (w *CollectorWorker) processTask(ctx context.Context, task CollectTask) { - for attempt := 1; attempt <= maxRetries; attempt++ { - err := w.collect(ctx, task) - if err == nil { - w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID) - return - } - if isRateLimited(err) { - time.Sleep(retryBaseWait * time.Duration(1<<(attempt-1))) - continue - } - // 非限流错误直接标记失败 - w.markFailed(ctx, task, err) - w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID) - return - } - w.markFailed(ctx, task, fmt.Errorf("max retries exceeded")) - w.stream.XAck(ctx, "mon:collect:tasks", "collectors", task.ID) -} -``` - -### 4.7 聚合 Job 扩容 - -``` -┌───────────────────────────────────────────────────────┐ -│ Aggregator Job │ -│ • 6:30 启动(聚合 business_date = yesterday) │ -│ • 按租户 × 品牌分片,每片独立事务 │ -│ • 并发聚合 goroutine: 10 │ -│ • 聚合完一个品牌 → 异步预热该品牌缓存 │ -│ • 全部完成后 → 全局 agg_version +1 │ -└───────────────────────────────────────────────────────┘ -``` - -聚合 Job 部署 **2 个实例**(主备),使用 **PG advisory lock** 选主,只有一个实例实际运行,另一个待命。 - ---- - -## 5. 部署拓扑与资源 - -### 5.1 修正:连接池是 per-process 的 - -V2 错误地把 `max_open_conns: 50` 当作"3 个实例共享"。实际上 `bootstrap.go` 的 `pgxpool.Pool` 是每个进程独立初始化的。 - -**修正后的连接池规划**: - -```yaml -# 每个 API 实例 -database: - max_open_conns: 20 # 每实例 20 - max_idle_conns: 5 - -# 3 个 API 实例 → PG 总连接: 60 -# 3 个 Collector 实例 → PG 总连接: 30 (各 10) -# 1 个 Aggregator 实例 → PG 总连接: 20 -# 合计: 110 连接 - -# PostgreSQL 需配置: -# max_connections = 160 (留 50 余量给运维和监控) -``` - -### 5.1.1 配置模型 Delta - -V3 不能继续沿用当前项目的单 `database`、单 `redis`、单 `llm` 配置模型,必须扩展为以下结构: - -```yaml -database: - primary: - host: pg-primary - ... - replica: - host: pg-replica - ... - -redis: - cache: - sentinel: - master_name: geo-cache-master - addrs: ["cache-sentinel-1:26379", "cache-sentinel-2:26379", "cache-sentinel-3:26379"] - db: 0 - queue: - sentinel: - master_name: geo-queue-master - addrs: ["queue-sentinel-1:26379", "queue-sentinel-2:26379", "queue-sentinel-3:26379"] - db: 0 - -monitoring: - providers: - deepseek: - enabled: true - supports_standard: true - supports_search_grounded: false - qwen: - enabled: true - supports_standard: true - supports_search_grounded: true - doubao: - enabled: true - supports_standard: true - supports_search_grounded: true - hunyuan: - enabled: true - supports_standard: true - supports_search_grounded: true - ernie: - enabled: false # PoC 通过后再开启 -``` - -这意味着: - -1. `server/internal/shared/config/config.go` 必须扩展结构体,不是“小改动”。 -2. `bootstrap.go` 必须初始化双 DB pool、双 Redis client、provider registry。 -3. 监测模块必须摆脱全局单 provider `llm.Client` 的限制。 - -### 5.2 部署架构 - -``` - ┌─────────────────────────────┐ - │ CDN (静态资源) │ - │ admin-web 打包后的 JS/CSS │ - └──────────────┬──────────────┘ - │ - ┌──────────────▼──────────────┐ - │ Nginx / 云 LB (SLB/ALB) │ - │ API 路由 + 健康检查 │ - │ 限流: 每 IP 200 req/s │ - └──────────┬───────────────────┘ - │ - ┌─────────────────────┼─────────────────────┐ - ▼ ▼ ▼ - ┌────────────┐ ┌────────────┐ ┌────────────┐ - │ tenant-api │ │ tenant-api │ │ tenant-api │ - │ Pod #1 │ │ Pod #2 │ │ Pod #3 │ - │ API 20conn │ │ API 20conn │ │ API 20conn │ - │ L1 cache │ │ L1 cache │ │ L1 cache │ - └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ - │ │ │ - ┌──────┴─────────────────────┴─────────────────────┴──────┐ - │ Cache Redis 6+ (主从 Sentinel) │ - │ 用途: API 缓存 + JWT 会话 │ - └────────────────────────┬─────────────────────────────────┘ - │ - ┌────────────────────────┼────────────────────────┬────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ PG Primary │────────▶│ PG Replica │ │ MinIO │ │ Queue Redis │ -│ (读写) │ 流复制 │ (只读) │ │ (原始回答) │ │ Streams │ -│ 160 conns │ │ 50 conns │ │ S3 兼容 │ │ 独立故障域 │ -└─────────────┘ └─────────────┘ └─────────────┘ └──────┬──────┘ - ▲ ▲ │ - │ │ │ - │ ┌────────────────────┘ Aggregator 的重查询走 Replica │ - │ │ │ -┌──────┴───┴──────────────────────────────────────────────┐ -│ 独立进程(不接入 LB) │ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ │ Collector Pod #1 │ │ Collector Pod #2 │ │ Collector Pod #3 │ ×3 Pod -│ │ worker ×10 │ │ worker ×10 │ │ worker ×10 │ (竞争消费) -│ │ PG conn: 10 │ │ PG conn: 10 │ │ PG conn: 10 │ -│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ -│ │ │ │ -│ └────────────────────┴────────────────────┘ -│ │ -│ ▼ -│ Queue Redis Stream -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Aggregator #1 │ │ Aggregator #2 │ 主备(锁选主)│ -│ │ (active) │ │ (standby) │ │ -│ │ PG conn: 20 │ │ PG conn: 0 │ │ -│ │ 读走 Replica │ │ │ │ -│ └─────────────────┘ └─────────────────┘ │ -╚══════════════════════════════════════════════════════════╝ -``` - -### 5.3 Redis 高可用与故障域隔离 - -V2 使用 Redis 单点,且在线缓存与离线任务共用同一实例。V3 修正为 **双 Redis 故障域**: - -1. `redis.cache`:面向 API 读流量,承接缓存与会话 -2. `redis.queue`:面向离线采集,承接 Stream 队列 - -推荐配置: - -```yaml -redis: - cache: - sentinel: - master_name: "geo-cache-master" - addrs: - - "cache-sentinel-1:26379" - - "cache-sentinel-2:26379" - - "cache-sentinel-3:26379" - db: 0 - queue: - sentinel: - master_name: "geo-queue-master" - addrs: - - "queue-sentinel-1:26379" - - "queue-sentinel-2:26379" - - "queue-sentinel-3:26379" - db: 0 -``` - -Go 客户端(go-redis v9)原生支持 Sentinel,但这里需要初始化 **两个** client: - -```go -cacheRDB := newRedisClient(cfg.Redis.Cache) -queueRDB := newRedisClient(cfg.Redis.Queue) -``` - -### 5.4 PostgreSQL 读写分离 - -API 服务的监测查询走 **PG Replica**。通过在 `MonitoringService` 中注入两个 pool: - -```go -type MonitoringService struct { - writePool *pgxpool.Pool // Primary,仅供 aggregator 写入 - readPool *pgxpool.Pool // Replica,Dashboard 查询 - cache cache.Cache - sf singleflight.Group -} -``` - -在 `bootstrap.go` 中初始化时,如果配置了 `database.replica`,则创建第二个连接池。 - -### 5.5 资源规格修订 - -| 组件 | 实例数 | 规格 | PG 连接 | 说明 | -| --- | --- | --- | --- | --- | -| **tenant-api** | 3 | 4C8G | 20/实例 | API 含 L1 缓存、singleflight | -| **Cache Redis Sentinel** | 1主1从3哨兵 | 8G 内存 | - | API 缓存 + JWT 会话 | -| **Queue Redis Sentinel** | 1主1从3哨兵 | 4G 内存 | - | Stream 队列,独立故障域 | -| **PG Primary** | 1 | 8C32G | max 160 | SSD,汇总表 + 原始采集表 | -| **PG Replica** | 1 | 4C16G | max 50 | SSD,Dashboard 只读查询 | -| **MinIO** | 1 | 4C8G, 100G SSD | - | 原始回答对象存储 | -| **Collector** | 3 Pod | 2C4G | 10/Pod | Queue Redis Stream 竞争消费 | -| **Aggregator** | 2 Pod (主备) | 4C8G | 20 (active only) | 分布式锁选主 | -| **CDN** | - | - | - | 静态资源 + Nginx 限流 | - ---- - -## 6. 降级与容灾 - -### 6.1 降级策略 - -| 故障场景 | 降级措施 | 用户感知 | -| --- | --- | --- | -| **Cache Redis 主节点故障** | Sentinel 自动切换从节点为主(< 30s) | 短暂超时后恢复 | -| **Cache Redis 完全不可用** | API 直接查 PG,跳过缓存;Nginx 层限流降至 50 req/s/IP | 响应变慢但可用 | -| **Queue Redis 故障** | 采集调度与 Collector 暂停;线上查询不受影响 | 当天数据延迟,但 Dashboard 继续服务旧数据 | -| **PG Primary 故障** | Dashboard 查询走 Replica 不受影响;采集/聚合暂停 | Dashboard 可用,数据冻结在最后聚合时间 | -| **PG Replica 故障** | Dashboard 查询回退到 Primary | 无感知,Primary 负载上升 | -| **单个 AI 平台 API 超时** | 该平台任务标记 failed,其他平台正常采集 | 该平台在对应模式下标记“数据缺失”,不强制展示为 0 | -| **全部 AI 平台不可用** | 当天采集任务全部失败,Dashboard 展示前一天数据 | 显示"数据更新时间:昨日" | -| **Collector Pod 全挂** | Queue Redis 消息堆积,等待 Pod 恢复后继续消费 | 当天数据延迟 | -| **Aggregator 异常** | 另一个 Aggregator Pod 抢锁接管 | 无感知 | - -### 6.2 熔断器 - -对外部 AI 平台 API 使用熔断器(circuit breaker): - -```go -// 每个 AI 平台独立的熔断器 -type PlatformBreaker struct { - breakers map[string]*circuitbreaker.CircuitBreaker -} - -// 配置 -// 连续 5 次失败 → 打开熔断 → 30 秒后半开 → 1 次成功 → 关闭 -``` - -### 6.3 监控告警 - -| 指标 | 告警阈值 | 通知方式 | -| --- | --- | --- | -| API P99 延迟 | > 2s 持续 5 分钟 | 钉钉/飞书 | -| Cache Redis 命中率 | < 80% 持续 10 分钟 | 钉钉/飞书 | -| Queue Redis backlog | > 10,000 持续 10 分钟 | 钉钉/飞书 | -| PG 连接池使用率 | > 80% | 钉钉/飞书 | -| 采集任务失败率 | > 10% 单平台 | 钉钉/飞书 | -| 聚合 Job 未在 8:00 前完成 | 超时 | 电话 + 钉钉 | -| Cache/Queue Redis Sentinel 主从切换 | 任何切换事件 | 电话 + 钉钉 | - -### 6.4 数据一致性保障 - -| 场景 | 措施 | -| --- | --- | -| 聚合 Job 被中断 | 每个品牌的聚合是独立事务,失败后可重跑该品牌 | -| 采集与聚合时间窗重叠 | 聚合只处理 `business_date = yesterday AND status = succeeded` 的数据 | -| 缓存与 PG 不一致 | stale-while-revalidate 保证最终一致,不保证强一致 | -| 原始回答写 MinIO 失败 | 标记该 run 为 failed,解析结果不写入 | -| 晚到补采任务 | 进入 backfill 队列,只重聚合受影响品牌,不改写已发布快照的全局口径 | - ---- - -## 7. 压测验收标准 - -### 7.1 压测场景模型 - -| 场景编号 | 场景名称 | 虚拟用户数 | 行为 | 持续时间 | -| --- | --- | --- | --- | --- | -| S1 | 稳态浏览 | 50,000 | 每 30 秒打开 1 个页面(加权 fan-out 3.8) | 30 min | -| S2 | 早高峰突刺 | 50,000 | 0~60 秒内全部打开首页(composite 接口) | 1 min ramp-up | -| S3 | 筛选风暴 | 20,000 | 每 5 秒切换品牌/时间范围 | 10 min | -| S4 | 缓存失效 | 50,000 | 手动清空 Cache Redis 后观察恢复 | 5 min | -| S5 | Cache Redis 故障 | 50,000 | kill Cache Redis master,观察 Sentinel 切换 | 2 min | -| S6 | Queue Redis 故障 | 0 | kill Queue Redis master,观察采集暂停与恢复 | 30 min | - -### 7.2 性能目标(SLA) - -| 指标 | 目标值 | 说明 | -| --- | --- | --- | -| API P50 延迟 | < 50 ms | 稳态,Redis 命中 | -| API P95 延迟 | < 200 ms | 稳态 | -| API P99 延迟 | < 500 ms | 含缓存 miss + PG 查询 | -| API P99 延迟(突刺) | < 2 s | S2 场景 | -| 错误率 | < 0.1% | 5xx 错误 | -| Cache Redis 命中率 | > 90% | 稳态 | -| PG QPS(穿透) | < 1,500 | 10% miss rate | -| 采集完成率 | > 95% | 日采集任务成功率 | -| 聚合 SLA | 8:00 前完成 | 用户上班前数据可用 | -| Cache Redis 故障恢复 | < 30 s | Sentinel 自动切换 | -| Queue Redis 恢复后 backlog 清空 | < 60 min | 采集可恢复性 | - -### 7.3 压测工具与脚本 - -``` -# 推荐工具: k6 (grafana/k6) -# 脚本位置: tests/load/ - -tests/load/ -├── k6.config.js # 共享配置 -├── scenarios/ -│ ├── s1_steady_browse.js # 稳态浏览 -│ ├── s2_peak_burst.js # 早高峰突刺 -│ ├── s3_filter_storm.js # 筛选风暴 -│ ├── s4_cache_flush.js # 缓存失效 -│ └── s5_cache_redis_failover.js # Cache Redis 故障 -├── helpers/ -│ ├── auth.js # 登录获取 token -│ └── brands.js # 随机品牌选择 -└── README.md # 运行说明(S6 Queue Redis 故障为运维演练,无 k6 脚本) -``` - -### 7.4 压测前置条件 - -1. **数据准备**:100 品牌 × 100 问题 × 5 平台 × 30 天汇总数据已填充 -2. **用户池**:50,000 个预创建的测试用户 token -3. **环境**:与生产同规格的独立压测环境 -4. **监控**:Prometheus + Grafana 大盘实时观察 - -### 7.5 验收流程 - -``` -Phase 1: 基线测试 - → 5,000 用户稳态,确认 P99 < 100ms - → 记录基线指标 - -Phase 2: 容量爬坡 - → 逐步提升: 10K → 20K → 30K → 40K → 50K - → 每级停留 5 分钟,观察指标是否劣化 - → 记录每级的 P50/P95/P99、错误率、Redis/PG 指标 - -Phase 3: 极限测试 - → 50K 稳态 30 分钟 - → 中间插入 S2 突刺、S3 筛选风暴 - → 确认所有 SLA 指标达标 - -Phase 4: 容灾演练 - → S4 缓存失效:确认 singleflight 生效,PG 不被打爆 - → S5 Cache Redis 故障:确认 Sentinel 切换 < 30s,API 降级可用 - → S6 Queue Redis 故障:确认在线 API 不受影响,采集恢复后 backlog 可消化 - -Phase 5: 报告 - → 输出压测报告,包含: - - 每个场景的延迟分布图 - - 资源使用率(CPU/内存/连接池/磁盘IO) - - 瓶颈分析与调优建议 - - 通过/未通过结论 -``` - ---- - -## 8. 实施计划修订 - -在 V2 原有 Phase 15~21 的基础上,调整以下 Phase: - -### Phase 15 修订:DB 迁移 + 数据口径 - -新增: -- `run_mode`、`business_date`、平台能力字段 migration -- `question_version_id` 进入问题级汇总唯一键 -- 新增 `monitoring_collect_tasks` / `monitoring_citation_facts` / `monitoring_parse_badcases` -- 引用聚合改为从规范化事实表出发 - -### Phase 16 修订:后端查询服务 - -新增: -- `getWithCache()` 通用缓存方法 + singleflight + stale-while-revalidate -- Dashboard composite 聚合接口 -- `MonitoringService` 注入 readPool/writePool -- L1 进程内缓存层 -- 所有查询默认显式带 `run_mode` / `scope` - -### Phase 17 修订:采集流水线 - -新增: -- Queue Redis Stream 任务队列替代内存队列 -- `MonitoringProvider` 平台适配器注册表 -- `PlatformRateLimiter` 每平台独立限流 -- `PlatformBreaker` 熔断器 -- 多 Pod 竞争消费架构 -- 重试 + 容错逻辑 -- `business_date` + backfill 流程 -- `api_standard` / `api_search_grounded` 分流调度 - -### Phase 18 修订:聚合 Job - -新增: -- 分布式锁选主 -- 聚合后预热热门品牌缓存(替代全量删除) -- 并发 goroutine 聚合(10 并发) -- 聚合读走 PG Replica -- 晚到任务品牌级重聚合 -- 引用事实表 → 日汇总表的聚合逻辑 - -### 新增 Phase 22: 基础设施 - -- Cache Redis Sentinel 部署配置 -- Queue Redis Sentinel 部署配置 -- PG 流复制 + Replica 配置 -- Nginx 限流配置 -- 监控告警配置(Prometheus + Grafana) -- 估时:3 天 - -### 新增 Phase 23: 压测与验收 - -- 编写 k6 压测脚本(5 个压测场景)+ 1 个运维演练场景 -- 数据准备脚本(100 品牌 × 30 天种子数据) -- 执行 Phase 1~4 验收流程 -- 输出压测报告 -- 估时:3 天 - -### 新增 Phase 24: 平台 PoC 与口径验收 - -- 3 品牌 × 30 问题 × 3 天的平台 PoC -- 验证搜索增强引用质量与波动 -- 决定百度千帆是否进入首批上线白名单 -- 估时:4 天 - -### 修订后总估时 - -| Phase | 内容 | V2 估时 | V3 估时 | 增量 | -| --- | --- | --- | --- | --- | -| 15 | DB 迁移 + 数据口径 | 1 天 | 3 天 | +2(run_mode、business_date、任务/引用事实表) | -| 16 | 查询服务 + 缓存层 | 3 天 | 6 天 | +3(singleflight、composite、L1、读写分离、scope 过滤) | -| 17 | 采集流水线 | 3 天 | 7 天 | +4(Provider Registry、双 run_mode、Queue Redis、多 Pod、限流熔断) | -| 18 | 聚合 Job | 2 天 | 4 天 | +2(分布式锁、预热、并发聚合、backfill) | -| 19 | 种子数据 | 1 天 | 1 天 | 0 | -| 20 | 前端 6 页面 + ECharts | 4 天 | 4 天 | 0 | -| 21 | 前端验证 + 集成测试 | 1 天 | 1 天 | 0 | -| 22 | 基础设施(新增) | - | 3 天 | +3 | -| 23 | 压测与验收(新增) | - | 3 天 | +3 | -| 24 | 平台 PoC 与口径验收(新增) | - | 4 天 | +4 | -| **合计** | | **15 天** | **32 天** | **+17 天** | - ---- - -## 9. V2 → V3 变更汇总 - -| 问题编号 | V2 问题 | V3 解决方案 | -| --- | --- | --- | -| **H0** | `run_mode` 未入模,标准回答与搜索增强回答混算 | 主统计按 `api_standard` / `api_search_grounded` 分层,默认页面显式取数规则 | -| **H1** | 读流量按 1 req/30s 估算,实际首页 6 个并行请求 | 重新建模 fan-out,引入 BFF composite 接口将首页降为 1 请求 | -| **H2** | TTL + 聚合后全删缓存,无防雪崩 | 4 层防护:singleflight + TTL 抖动 + stale-while-revalidate + L1 热点缓存 | -| **H3** | 5 worker、2 小时时间窗,采集跑不完 | 30 worker、6 小时窗口、Queue Redis 多 Pod 竞争消费 | -| **H4** | 问题版本未进入全部汇总唯一键 | `question_version_id` 进入问题级汇总唯一键,避免历史口径覆盖 | -| **H5** | 日采集与日聚合时序矛盾 | 使用 `business_date = yesterday` 的 T+1 发布模型 + backfill | -| **M1** | max_open_conns 50 被当作共享,实际 per-process | 显式规划每进程连接数,PG max_connections=160 | -| **M2** | 缺少压测验收 | 6 个场景模型 + SLA 指标 + k6 脚本 + 4 阶段验收流程 | -| **M3** | 分页缓存 key 缺 page_size | 所有分页 key 加入 `scope + page_size` 维度 | -| **M4** | 引用按原始 URL 聚合,易重复计数 | 引入 `monitoring_citation_facts`,按 `normalized_url/domain` 聚合 | -| **M5** | 在线缓存与离线队列共用 Redis 故障域 | Cache Redis / Queue Redis 拆分,分别高可用 | -| **新增** | 无降级容灾策略 | 双 Redis Sentinel + PG 读写分离 + 熔断器 + 降级措施矩阵 | -| **新增** | 无监控告警 | 7 项核心告警指标 + 阈值 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfc0183..378fe0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,24 +126,6 @@ importers: apps/desktop-client: dependencies: - '@ant-design/icons-vue': - specifier: ^7.0.1 - version: 7.0.1(vue@3.5.31(typescript@5.9.3)) - '@geo/http-client': - specifier: workspace:* - version: link:../../packages/http-client - '@geo/publisher-platforms': - specifier: workspace:* - version: link:../../packages/publisher-platforms - '@geo/shared-types': - specifier: workspace:* - version: link:../../packages/shared-types - '@geo/ui-shared': - specifier: workspace:* - version: link:../../packages/ui-shared - ant-design-vue: - specifier: ^4.2.6 - version: 4.2.6(vue@3.5.31(typescript@5.9.3)) better-sqlite3: specifier: ^12.8.0 version: 12.9.0 @@ -159,16 +141,25 @@ importers: playwright-core: specifier: ^1.55.0 version: 1.59.1 - vue: - specifier: ^3.5.31 - version: 3.5.31(typescript@5.9.3) - vue-router: - specifier: ^4.5.1 - version: 4.6.4(vue@3.5.31(typescript@5.9.3)) ws: specifier: ^8.20.0 version: 8.20.0 devDependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.31(typescript@5.9.3)) + '@geo/http-client': + specifier: workspace:* + version: link:../../packages/http-client + '@geo/publisher-platforms': + specifier: workspace:* + version: link:../../packages/publisher-platforms + '@geo/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@geo/ui-shared': + specifier: workspace:* + version: link:../../packages/ui-shared '@playwright/test': specifier: ^1.0.0 version: 1.59.1 @@ -181,6 +172,9 @@ importers: '@vitejs/plugin-vue': specifier: ^5.2.4 version: 5.2.4(vite@5.4.21(@types/node@24.12.0)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.9.3)) + ant-design-vue: + specifier: ^4.2.6 + version: 4.2.6(vue@3.5.31(typescript@5.9.3)) electron: specifier: 41.2.0 version: 41.2.0 @@ -202,6 +196,12 @@ importers: vitest: specifier: ^2.0.0 version: 2.1.9(@types/node@24.12.0)(lightningcss@1.32.0) + vue: + specifier: ^3.5.31 + version: 3.5.31(typescript@5.9.3) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.31(typescript@5.9.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3)