Compare commits

...

49 Commits

Author SHA1 Message Date
浅念 9d3cc8e76e fix(messages): 聊天记录大对话超时——DESC首屏+keyset游标+群聊直达索引
用户报:聊天记录多时「聊天记录加载失败:请求失败 (ECONNABORTED)」(2026-09-07)。
服务器 EXPLAIN ANALYZE 实测两级根因:
- 单聊 1 万条:ASC 末页 OFFSET 10486 逐条回表 2515ms(DESC LIMIT 15ms,167 倍);
- 群聊 16,290 条:查询刻意不带 owner(chat_group 去重后 owner 错位),现有
  (channel,owner,room,timestamp) 索引被 owner 挡住,退化到 room 单列索引+
  filter+filesort——count 4.7s/首页 5.1s/末页 10.7s;真凶群 53132846289@chatroom
  40,863 条,三段串行 ~40s 必撞前端 axios 15s 超时。

修法(不动共享 MySQL 配置,沿用既往修复模式):
1. 迁移 e6f7a8b9c0d1:message 补 (channel, room_id, timestamp) 索引,群查询
   seek+排序直达+覆盖 count;模型侧双声明防 autogenerate 反 DROP。
2. /api/messages 加 order=asc|desc(默认 asc 不变)与 before_ts/before_id
   keyset 游标(成对,单传 422)——排序列 (timestamp,msg_id) 全序,平手按
   msg_id 决胜,游标页恒 O(page_size)。
3. 前端 ChatMain/ChatTimeline 首屏改「order=desc 取最新 50 反转」单请求
   (原 page1 拿 total+深 OFFSET 末页两请求),「加载更多」改游标上翻;
   hasMoreOlder=已加载<总数。SessionPane 搜索等 order=asc 默认路径不受影响。

本地实测(37,532 条群):desc 首页 0.39s、游标上翻 0.24s,浏览器真机
渲染+上翻 150 条无错。测试 8 增(desc/游标/平手边界/422/窗口叠加/
前端流程不变式),全量 601 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 16:59:04 +08:00
浅念 085a9b4fd3 feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 14:41:08 +08:00
浅念 feb3b98665 feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合
批1(A1+A2):管理层要看「每个人的响应质量」。

- 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加
  cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0,
  幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步)
- summary.py _thread_stats:串=客户连续消息不被员工消息打断的段,
  is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息;
  日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring)
- recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护)
- staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列)
- 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿
- 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与
  明细完全一致,随机 5 行 SQL 独立复算全对

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 14:13:09 +08:00
浅念 39791b2a49 fix(analytics): 排行条形图按当前指标重排——切活跃客户/通话量不再沿用消息量序
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 13:54:13 +08:00
浅念 bbfad520f2 feat(analytics): 员工排行条形图对比+近30天范围——幅度一眼可读/本地停更窗口也可用
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 13:43:23 +08:00
浅念 1aadc80d6b feat(analytics): 看板v2全平台口径——默认双渠道合计/拆分小字/双线趋势/跳转带query预选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 12:20:18 +08:00
浅念 9f2dff08d9 fix(analytics): 趋势图可读性——摘要行/轴刻度稀疏与大数万化/实线细网格/峰值标注/均值参考线
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 11:56:48 +08:00
浅念 487f45b074 feat(analytics): 看板管理化补强——员工活跃排行表+预警行动入口卡片
管理者反馈「纯汇总数字看不到有用信息」:overview 载荷扩
staff_ranking(全员行:msg_total rollup 优先/空表回退 msg_daily_stats
聚合,active_* 两路径统一走 msg_daily_stats 窗内 distinct peer——
stat_daily_staff 的 active_* 是当日快照,跨日 SUM/MAX 语义皆错;
call_cnt 按 user_id 落窗直查;环比同期对齐)+ actions(该回未回 2h/
真流失 30d/敏感词 pending,复用已测试查询路径各取 total)。

前端看板:toolbar 下三张可点行动卡(0 灰显仍可点,直达处置页,
随 channel 切换刷新)+ 趋势卡下员工排行表(max-height 420 滚动,
未映射小 tag,环比色同卡片惯例 None 显 '-')。

测试 574→577 全绿(rollup 聚合/回退/行动计数三新);build 过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 11:48:24 +08:00
浅念 8499a74a72 fix(analytics): run_full水位前置防回填窗口漏扫+README/DEPLOY/脚本号收尾
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 23:27:41 +08:00
浅念 0fad1b09bb docs(deploy): 数据分析板块首部署回填注记
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 23:14:53 +08:00
浅念 c2d6d6feba fix(analytics): 敏感词页单字预览提示+文案耦合注释+处置按钮状态统一
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 23:11:47 +08:00
浅念 518d02bc8f feat(analytics): C1 敏感词页——词库CRUD/预览警示/批量处置/上下文跳转
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 23:03:35 +08:00
浅念 7f32f1a43c fix(analytics): 该回未回默认2h对齐spec口径+提示class统一
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:42:29 +08:00
浅念 1ba6768a46 feat(analytics): B2 该回未回页——时长筛选/等待着色/msgid定位跳转
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:33:32 +08:00
浅念 54ed7e745b fix(analytics): 流失页导出成功提示与空检+排序仅当前页提示
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:24:08 +08:00
浅念 1650a050e6 feat(analytics): A3 流失预警页——三分法/分层/CSV导出/跳聊天
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:14:30 +08:00
浅念 4ac73fff49 fix(analytics): 看板异常明细诊断优先排序+角标复用formatTimeShort+health解耦
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:02:41 +08:00
浅念 4cec162f53 feat(analytics): A1 数据看板页——卡片/异常警示/趋势折线/数据截至角标
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 21:48:55 +08:00
浅念 5fb19dd3ac feat(analytics): 前端板块骨架——echarts按需/useChart/路由/侧栏组/API模块
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 21:25:48 +08:00
浅念 f839a6db20 docs(analytics): tick docstring锁竞争与时区措辞修正+测试名对齐4job
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 21:14:52 +08:00
浅念 6b99202e9b feat(analytics): 4h 调度分析链+CLI 回填+full_sync 夜间自愈步
- scheduler: yunke_analytics_tick job(与消息增量同节奏 IntervalTrigger),
  _analytics_tick 纯 DB——stat_daily_staff 滚动3天刷新 + 敏感词增量扫描,
  不抢 _page_lock 无子进程,异常只记日志不断链;today 用 time.time()
  UTC epoch 喂 day_key,规避服务器非上海时区的 naive now 误判
- cli: staff-daily-stats(全量重建 rollup,幂等)+ sensitive-scan
  (默认增量水位扫描,--full 首跑建水位/新词回填历史;description 明示
  加词不回填历史)+ 幂等补种起步词库
- full_sync_all.sh: daily-stats 后追加 staff-daily-stats(rollup 随
  msg_daily_stats 重建同步自愈)
- 测试: tick 行为测试(今日 seed→stat_daily_staff 出行)+ 异常吞噬
  (monkeypatch 抛错不外抛);同步更新注册断言(3→4 jobs)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 21:02:41 +08:00
浅念 6a1c904018 fix(analytics): 命中处置ids上限+disposition枚举校验+排序断言
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 20:51:32 +08:00
浅念 6e67e9e631 feat(analytics): C1 命中列表默认过滤+批量处置
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 20:37:44 +08:00
浅念 956f243fce fix(analytics): 扫描器水位>=/无水位自卫/迟到测试判别力/分片flush
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 20:30:34 +08:00
浅念 aa59461b03 feat(analytics): C1 扫描器——fetched_at 水位增量/迟到免疫/幂等/全量回填
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 20:02:53 +08:00
浅念 c7b3ce9d1c fix(analytics): 词库单字MySQL预览拒扫+IntegrityError 409+MATCH短语净化
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 19:48:30 +08:00
浅念 0c14be0777 feat(analytics): C1 敏感词库 CRUD+加词预览——防一加就淹没
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 19:32:35 +08:00
浅念 1e0db3913c fix(analytics): unreplied is_send守卫防漂移假行+真预算注释+501候选分支测试
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 19:22:22 +08:00
浅念 52af531598 feat(analytics): B2 该回未回——尾部判定/有效类型/通话豁免/已删剔除
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 19:00:15 +08:00
浅念 247bfc7277 fix(analytics): churn 排序决胜列+active_days去重日+边界测试
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 18:45:34 +08:00
浅念 3b4254639d feat(analytics): A3 流失预警三分法——真流失/从未激活/待交接
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 18:25:08 +08:00
浅念 6790710965 test(analytics): overview channel 非法值 422 断言补齐
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 18:16:44 +08:00
浅念 b067d690f1 feat(analytics): /overview 端点+unmapped_accounts 标注字段
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 18:04:05 +08:00
浅念 ad153aa2f7 fix(analytics): 环比同期对齐口径+趋势窗校验+异常新员工明细
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 17:34:14 +08:00
浅念 02e100849b fix(analytics): anomaly回退同人多账号累加+今日无环比断言修正
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 17:17:24 +08:00
浅念 49f2ffe8ea feat(analytics): A1 总览指标——卡片/首沟通口径/周月环比/异常警示/空表回退
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 17:05:57 +08:00
浅念 af05e58976 perf(analytics): 5表 updated_at 窄索引——health MAX 消除全表扫(128MB buffer pool)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 16:40:01 +08:00
浅念 5b6a9bd35b fix(analytics): recompute_all 全表清空自愈+whatsapp/多日/空日测试+映射缓存
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 16:28:39 +08:00
浅念 e3bb97bdf3 feat(analytics): stat_daily_staff 员工日汇总维护层——rollup派生/幂等/判别法
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 16:06:18 +08:00
浅念 4cfc2fa6a9 test(analytics): health 契约加深——ISO 序列化断言/空库6实体/诊断端点500有意注释
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 16:00:38 +08:00
浅念 0b1d377db7 feat(analytics): E0-lite 数据健康端点——数据截至+实体新鲜度
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 15:37:13 +08:00
浅念 8b29326b2e fix(analytics): 命中表索引幂等守卫独立+created_at 去掉 onupdate 语义
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 15:30:14 +08:00
浅念 565006239b feat(analytics): 数据分析板块四新表+迁移——stat_daily_staff/敏感词三表/fetched_at 索引
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 15:04:33 +08:00
浅念 81af615738 docs(plan): 复核残留修订——exportListToCsv真实签名落地代码块/调度测试改相对日期
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 14:48:45 +08:00
浅念 0bc1250476 docs(plan): 评审修订——修5处seed/签名矛盾+起步词库补种+四页角标
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 14:42:56 +08:00
浅念 aecb556efd docs(plan): 数据分析板块 v1 实施计划——17 任务 TDD 全链(模型/指标/C1/调度/四页)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 14:25:20 +08:00
浅念 33c5596543 docs(spec): 数据分析板块 spec 评审修订——min_msgs 参数/周一起始/alembic 取代上游过时说法
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 13:51:11 +08:00
浅念 d1b636abdc docs(spec): 数据分析板块 v1 设计存档——A1/A3/B2/C1 骑日汇总层+打底架构
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 13:45:13 +08:00
浅念 c31d1cc450 fix(nginx): /api 反代按 TTL 重解析 backend DNS——根治 IP 漂移后 502
nginx 对 hostname 的 proxy_pass 只在启动时解析一次并永久缓存,backend
重建或网络栈劣化后持续 502(2026-09-03/04 两次实战,重启 frontend 才恢复)。
改用 docker 内嵌 DNS 127.0.0.11 + resolver valid=10s + variable
proxy_pass(不带 URI,仍转发原始 /api/... 完整路径,行为不变)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 09:55:54 +08:00
49 changed files with 10493 additions and 88 deletions
+9 -1
View File
@@ -86,6 +86,14 @@ python -m app.sync.cli sync --entity friends # one entity
python -m app.sync.cli --help # full usage
```
One-shot analytics backfills / self-heal (all idempotent):
```bash
python -m app.sync.cli daily-stats # msg_daily_stats 日汇总全量重建
python -m app.sync.cli staff-daily-stats # 员工日汇总(stat_daily_staff)全量重建
python -m app.sync.cli sensitive-scan --full # 敏感词全量回填+建扫描水位(首跑必须 --full)
```
### Docker mode (full stack)
```bash
@@ -176,7 +184,7 @@ Entity names accepted by `sync --entity` and `run_once`:
## Verification
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest -q # 323 tests
cd yunke-server && .venv/Scripts/python.exe -m pytest -q # 574 tests
cd yunkes && npm run build # clean production build
cd yunke-server && python -m app.sync.cli --help # CLI usage
docker compose config # validates the stack
+34
View File
@@ -194,6 +194,40 @@ docker compose -f docker-compose.server.yml exec scheduler \
(类型变更类迁移在 MySQL 上锁大表写入,避开抓取窗口:北京 02:00 全量、
每 4h 增量;增量耗时约 15-30 分钟)。
### 数据分析板块首部署附加步骤(一次性,2026-09-04)
alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_stats
日汇总表、`f0a1b2c3d4e5` 分析板块四新表 + `message.fetched_at` 索引、
`c3d4e5f6a7b8` 五表 `updated_at` 窄索引。
- **迁移随 `up -d --build` 自动跑**,平时无需手动。⚠️ 其中
`ix_message_fetched_at` 要在 86 万行 message 表上建索引,约十几秒
(InnoDB online DDL,量级同 c53f90b1e6a4)——**避开北京 02:00 全量抓取
窗口**执行升级:先停 scheduler 容器再 up,或白天低峰手动跑
`sudo docker compose -f docker-compose.server.yml exec backend alembic upgrade head`。
- 部署完成后在**夜间低峰**跑两条一次性回填,此后每 4h 的 analytics tick
自动增量维护,不用再手动:
```bash
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli staff-daily-stats
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli sensitive-scan --full
```
前者全量重建 stat_daily_staff(分钟级);后者逐词扫全库并建扫描水位
(高频词分钟级)。**首跑必须带 `--full`**:不建水位,增量扫描会拒绝扫库。
- **顺序**:sensitive-scan --full 宜赶在调度器首次 4h analytics tick 之前;
tick 首次在进程启动 4h 后才触发,部署当天跑回填通常天然满足。万一 tick
先到,每 4h 记一条 ERROR 日志(analytics tick failed,无扫描水位),且该
tick 整笔回滚——同笔的 staff rollup 增量刷新一并停摆,由夜间
full_sync_all.sh step 12(staff-daily-stats 全量重建)自愈兜底;补跑
--full 后即恢复,属预期提示非故障。
- 升级收尾 `sudo docker compose -f docker-compose.server.yml restart frontend`
(让 nginx 重新解析 backend 容器 IP,防漂移后 502,同既有升级流程)。
- 浏览器验收:登录后依次打开 `/analytics/dashboard`、`/analytics/churn`、
`/analytics/unreplied`、`/analytics/sensitive` 四页;各页角标「数据截至」
在上述回填完成后才会显示。
- 起步词库 6 词(私单/飞单/加私人微信/红包/转账/离职)由 sensitive-scan
幂等自动补种,无需手插;后续加新词默认只扫增量,要扫历史消息需重跑
`--full`。
## 8. 本地收尾(部署验证通过后)
```bash
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
# 云客数据镜像 · 数据分析板块 v1 设计(A1 总览 + A3 流失预警 + B2 该回未回 + C1 敏感词)
- **日期**:2026-09-04
- **状态**:设计定稿(已经三轮评审:架构权衡 → 修订复审 → 打磨级优化),待实施计划
- **上游方案**:`2026-08-24-yunke-analytics-portfolio-design.md`(23 项全景,本 spec 是其首批的落地设计,范围经用户确认有调整)
- **依据**:所有性能论断基于生产实测(1Panel MySQL buffer pool 128MB、msg_daily_stats 读路径 0.0009s、ngram 全文检索 0.4~0.7s);所有数据规模基于 2026-08-24 生产审计 + 2026-09-03 迁移实测(message 861,088 行)
- **用户定调**:不能只实现当前四个页面,要为后续路线图打底;价值论证基准=我们自己的数据。
---
## 1. 范围与背景
### 1.1 v1 范围(用户确认)
| 项 | 内容 | 系列 |
|---|---|---|
| A1 | 经营总览看板(卡片+趋势+异常警示) | 管理看板 |
| A3 | 客户流失预警(三分法) | 管理看板 |
| B2 | 该回未回清单 | 员工工具(v1 仅管理者用) |
| C1 | 敏感词扫描(词库+扫描+处置) | 合规风控 |
| E0-lite | 数据截至角标 + 实体新鲜度(完整 E0 二批) | 基建 |
| E1扩展 | stat_daily_staff 员工日汇总(新基建,见 §3.1) | 基建 |
### 1.2 相对 8-24 全景方案的关键变化(2026-09-04 现状)
1. **E1 已落地一半**:msg_daily_stats(渠道×账号×会话×上海自然日,send/recv/last_ts/last_is_send)2026-09-03 上线,写钩子挂 `repos.upsert_messages` 尾部,读路径生产实测 0.0009s。本 spec 全部读需求骑在此表上。
2. **E0 的 sync_run 账本失效**:页面抓取路径不写 sync_run。E0-lite 改用「表新鲜度」判定(§3.5)。
3. **性能约束被钉死**:生产 MySQL buffer pool 128MB,任何裸扫 message 的 GROUP BY 实测 18.9s——**全板块禁止裸扫**,明细只做索引点查。
### 1.3 明确不做(YAGNI)
stat_daily_customer/group 物化表(msg_daily_stats 按 peer_kind 分组即等价)、完整 E0(环比突变告警的完整版、调度日志解析)、跨渠道客户去重、报表订阅(E2)、多角色权限(仅留 API 前缀落点)、今日环比(误导)、D 系列 LLM。
---
## 2. 架构(分层 + 打底)
```
┌─ 前端板块「数据分析」(侧栏第四组)
│ yunkes/src/views/analytics/ 四页 + composables/useChart.ts
├─ API 层 /api/analytics/*(前缀分组 = 未来角色权限中间件落点)
├─ 分析服务层 yunke-server/app/analytics/(★v1 核心地基)
│ metrics.py 指标口径字典 + 查询函数(纯查询、无 HTTP 依赖)
│ health.py E0-lite 数据截至/新鲜度
│ sensitive/ C1 子系统(words / scanner / hits,按「内容源」抽象)
├─ 汇总底座
│ msg_daily_stats 原子事实层:会话×日(已有,6.2万行)
│ stat_daily_staff 员工×日 rollup(★新增,~2万行)
└─ 明细层 message / customer / phone_call(只许小表直查 + 索引点查)
```
### 2.1 打底承诺(后续路线图落点)
| 后续功能 | 落点 | v1 已铺 |
|---|---|---|
| A2 员工绩效深化 | stat_daily_staff 直读 | 员工口径表 + 账号→员工映射权威落点 |
| A4 时段热力图 | 新增 msg_hour_stats(复制 rollup 维护模式) | 模式已验证可复制 |
| B1 客户360 / B5 RFM | analytics 层加函数 | A3 已建 peer 聚合查询模式 |
| E2 报表订阅 | 复用 metrics.py 渲染 HTML | 指标函数纯查询无 HTTP 依赖 |
| E3 BI 直连 | MySQL 只读账号 | 零代码 |
| D4 ASR→内容分析 | sensitive scanner 加内容源 | scanner 按「内容源」抽象(text→refer_msg→ASR) |
| 多角色权限 | /api/analytics/* 挂中间件 | 路由前缀分组已隔离 |
---
## 3. 组件设计
### 3.1 stat_daily_staff(员工日汇总)
- **表**:`(day INT, channel VARCHAR(16), user_id VARCHAR(64))` 主键;`msg_sent, msg_recv, active_friends, active_rooms INT`(**活跃对话拆两列**——客户数与群运营数是两个绩效维度);`updated_at`。量级 ~2 万行。
- **派生**:`msg_daily_stats` 按 `owner_account_id → user_id` 映射聚合(微信查 wechat_account.user_id,WA 查 whatsapp_account)。
- **刷新(★不挂写钩子)**:4h 调度 job 刷当日 + 滚动 3 天窗(容常见迟到);夜间全量自愈(full_sync_all.sh 追加一步)。它是 rollup-of-rollup,滞后语义与看板"数据截至"角标天然一致,不值得付写路径代价。
- **空表回退**:表空/未回填时自动改为实时聚合 msg_daily_stats(同为毫秒级)——部署即用,不等回填。沿用 sessions 已验证的 has_rows 回退模式。
- **映射兜底**:映射不上的账号按 owner_account_id 原样展示(标"未映射"),夜间自愈随账号表更新纠正。
### 3.2 A1 经营总览(DashboardView)
- **卡片**(今日/本周/本月三档,channel tab:微信/WhatsApp):消息量、活跃客户、活跃员工、通话量、首次沟通客户数。**环比只显示在周/月卡片**(今日 vs 昨日全天会误导)。
- **异常警示条**(页顶,E0 核心能力提前):昨日消息总量 vs 前 7 日均值偏离 >50% → 黄条警示 + 点开按员工的偏离明细(某员工归零=掉同步或离职)。阈值 50% v1 固定。
- **趋势**:近 30 天折线(ECharts),指标可切(消息量/活跃客户/通话量)。
- **角标**:「截至今日 HH:mm」(读 health 端点)——镜像 ≤4h 滞后透明化。
- **通话卡片**:phone_call(3k 行小表)按 start_call_time 落窗直查,不进汇总表。
### 3.3 A3 流失预警(ChurnView)
- **三分法**(tab):
- **真流失**:历史消息 ≥5 条(默认,可调)且 last_chat_time 距今 >30/60/90 天分层;
- **从未激活**:last_chat_time IS NULL;
- **待交接**:归属员工已离职(is_on_job=0,**R1 风险**:数据源待 plan 核实,不可用则该 tab 降级隐藏)。
- 主体读 customer 表(12k 行直查:last_chat_time/user_id/delete_flag/is_block 全有);历史消息量与活跃天数从 msg_daily_stats 按 peer 聚合。全部剔除 delete_flag=1。
- **列**:客户/渠道/归属员工/最后沟通/历史消息量/历史活跃天数。
- **操作**:行点击跳员工聊天窗(复用 staff-chat);按员工分组 CSV 导出(复用 csvExport 惯例)。
### 3.4 B2 该回未回(UnrepliedView)
- **候选集**(msg_daily_stats 62k 行窗口查):每 (channel, owner, peer_kind=friend, peer) 取最新日行,`last_is_send=false` 且 `last_ts` 距今 > N 小时(默认 2h,可切 1/2/4/24)。
- **决赛圈处理**(仅分页后当页行,每页 ~20 行点查,毫秒级):
- message 点查(走 (channel, owner, timestamp) 索引)校验尾部消息**有效类型**(text/image/voice/video/file/link,排除 unknown/divider)+ 取预览文本;
- phone_call 点查通话豁免:`max(最后消息 ts, 该客户最后通话 ts)` 作为尾部时间——通话也算"已回应"(user_id 经账号映射 + call_number 匹配 customer.phone)。
- 剔除 delete_flag=1 / is_block=1。
- **列**:客户/员工/渠道/最后消息时间/等待时长/预览;行点击跳聊天窗。
- 工作时间感知放 v2。
### 3.5 E0-lite 数据健康(health.py)
- `GET /api/analytics/health` → `{data_as_of, entities: [{table, last_seen}]}`。
- data_as_of = MAX(msg_daily_stats.last_ts);实体新鲜度 = 各表 MAX(updated_at)/MAX(fetched_at)(message 走新索引)。据此可推断 4h job 是否活着(当日有无新行)。
- 完整 E0(调度日志解析、成功率账本)二批。
### 3.6 C1 敏感词子系统(app/analytics/sensitive/)
**表**:
- `sensitive_word(id PK, word UNIQUE, category, enabled, min_len DEFAULT 2, exclude_words JSON数组, created_at, updated_at)`
- `sensitive_hit(id PK, word_id FK, msg_id, channel, owner_account_id, talker_id, room_id /*★冗余跳转字段*/, ts, snippet, disposition DEFAULT 'pending', created_at; UNIQUE(word_id, msg_id) /*★防重扫膨胀*/; INDEX(disposition, ts))`
- `sensitive_scan_state(entity PK, last_fetched_at, last_run_at, scanned, hits)`
**扫描器**:
- **水位 = MAX(message.fetched_at)**(★非 timestamp——增量补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,水位按 timestamp 会永久漏扫)。message.fetched_at 需补索引(86 万行建索引十余秒,避开抓取窗口)。
- 增量:4h job(挂现有 asyncio.Lock),`fetched_at > 水位` 分批 1000 行;ngram MATCH 为主,**单字词 LIKE 回退**(ngram 最小 2 字,词库 UI 提示)。
- 首次全量回填:CLI 手动(65 万文本 × 词库,分批限速,分钟级),夜间窗口跑。
- v1 范围仅 message.text;scanner 按内容源抽象,预留 refer_msg / ASR 文本接入位。
**词库与处置 UX**:
- **加词预览**:保存前 COUNT 命中量,>500 警示收窄(加排除词/换长词)——防"词库一加就淹没"。
- **批量处置**:命中列表多选 + 批量置为 confirmed/false_positive/ignored;列表默认只显示 pending+confirmed。
- 词删除 = 级联删除其命中(UI 二次确认);禁用不删命中(保留历史证据)。
- 命中行点击跳聊天窗上下文(复用 staff-chat ?msgid= 定位)。
- **日志不打词库内容与命中正文**(词库是高敏数据,防 log 泄露面)。
- 内置起步词库(私单/飞单/加私人微信/红包转账/辱骂/离职倾向),上线后用户自补行业词。
---
## 4. API 设计(全部挂现有鉴权中间件)
| 端点 | 方法 | 说明 |
|---|---|---|
| `/api/analytics/overview?channel=&scope=today\|week\|month` | GET | 卡片+趋势+异常警示一次返回(单往返) |
| `/api/analytics/health` | GET | 数据截至 + 实体新鲜度 |
| `/api/analytics/churn?channel=&type=real\|inactive\|handover&tier=30\|60\|90&min_msgs=&page=` | GET | 流失名单(min_msgs=真流失历史消息阈值,默认 5,服务端兜底钳制 1~100) |
| `/api/analytics/unreplied?channel=&hours=&page=` | GET | 该回未回清单 |
| `/api/analytics/sensitive/words` | GET/POST | 词库列表/新增(新增带预览计数) |
| `/api/analytics/sensitive/words/{id}` | PATCH/DELETE | 改(enabled 等)/删(级联删命中) |
| `/api/analytics/sensitive/words/preview` | POST | 加词前命中量预览 |
| `/api/analytics/sensitive/hits?disposition=&word_id=&page=` | GET | 命中列表 |
| `/api/analytics/sensitive/hits` | PATCH | 单条/批量处置(body 带 ids 数组) |
响应沿用现有信封格式;路由文件归 analytics 前缀组(权限中间件落点)。
---
## 5. 前端设计
- **侧栏新组「数据分析」**(现三组:微信管理/WhatsApp管理/通用):数据看板 / 流失预警 / 该回未回 / 敏感词。路由 `/analytics/*` 懒加载(不拖累现有 bundle)。
- **useChart.ts composable**:echarts **按需引入**(echarts/core + 所用图表/组件,gzip ~100KB);统一注册、主题、ResizeObserver 自适应、卸载 dispose。后续所有图表页复用。
- **页面模板惯例**:筛选栏(channel tab + 参数选择)→ 卡片行 → 图表区 → 表格区;空态分语(有窗滤空提示 / 真无数据);seq 守卫防竞态(项目惯例)。
- 四页均带「数据截至 HH:mm」角标。
---
## 6. 数据库迁移与调度
- **alembic 一支迁移**:4 新表(stat_daily_staff / sensitive_word / sensitive_hit / sensitive_scan_state)+ message.fetched_at 索引。**本条取代上游方案 §4.2「create_all 建表」的过时说法**——以仓库近期实践(msg_daily_stats 迁移 a7d3e9f1c2b4、ngram 索引迁移 b41e8fa0c7d2)为准,一律走 alembic。
- **调度**:
- 4h job(挂现有调度器,asyncio.Lock 互斥):stat_daily_staff 刷当日+滚动 3 天 → C1 增量扫描。
- 夜间 full_sync_all.sh 追加一步:stat_daily_staff 全量重建(自愈)。
- 首次部署 CLI:staff-daily-stats 回填 + sensitive 全量回填(复用 daily-stats CLI 模式)。
- **服务器升级**:照 DEPLOY.md——pull/bundle → `up -d --build`(迁移自动)→ 首次回填 CLI → **restart frontend**(防 nginx DNS 缓存 502)。
---
## 7. 测试策略
- **方言防御(O4,踩过坑的红线)**:所有新 SQL 禁 bare column in GROUP BY(MySQL only_full_group_by 1055 坑)、禁 SQL 日期函数(时区坑)、日期一律 day 整数比较(Python 侧上海自然日)。测试跑内存 SQLite,写法双方言兼容。
- metrics 函数全覆盖单测;B2 判定矩阵(尾部方向×类型过滤×通话豁免×delete_flag 剔除)。
- **判别法测试**(9-03 先例):stat_daily_staff 回填后删光 message 明细,员工汇总仍出完整数据。
- scanner:水位幂等(重复扫描不产生重复命中,UNIQUE 兜底)、**迟到数据测试**(fetched_at 晚于水位入库的历史消息被扫到)。
- 空表回退路径测试(stat_daily_staff 空 → 实时聚合 fallback)。
---
## 8. 口径速查表(争议仲裁权威文本;analytics 层 docstring 同步维护)
| 指标 | 定义 |
|---|---|
| 消息量 | send+recv 计数,**含 unknown 类型**(13.7%),不含 timestamp NULL;角标 tooltip 注明 |
| 活跃客户 | distinct peer_id(peer_kind=friend,即 **1:1 对话客户**,群成员活动不计入),按渠道,跨员工去重 |
| 活跃员工 | distinct user_id(经账号→员工映射;未映射账号按账号计并标注) |
| 通话量 | phone_call 按 start_call_time 落窗,含全部 call-type |
| 首次沟通客户数 | 该 peer_id 按渠道在本库**全历史首条消息所在日=统计日**,跨员工归一,不跨渠道去重 |
| 真流失 | 历史消息 ≥5(默认可调)且 last_chat_time 距今 >30/60/90 天,剔除 delete_flag=1 |
| 从未激活 | last_chat_time IS NULL |
| 待交接 | 归属员工 is_on_job=0(依赖 R1) |
| 该回未回 | 1:1 对话尾部消息 is_send=false 且属有效类型,且 max(最后消息, 最后通话) 距今 > N 小时(默认 2h),剔除 delete_flag/is_block |
| 等待时长 | now − max(最后消息 ts, 最后通话 ts) |
| 环比 | 仅周/月卡片;**同期对齐**:周=本周各日 vs 上周各对应日(-7天),月=本月各日 vs 上月同日(短月钳月末);周起始=周一(中国业务惯例,Python 侧计算) |
| 日汇总 day | Asia/Shanghai 自然日 yyyymmdd,纯 Python 计算 |
| 异常警示 | 昨日消息总量 vs 前 7 日均值偏离 >50%,明细到员工级 |
---
## 9. 风险与遗留
| # | 风险 | 处置 |
|---|---|---|
| R1 | is_on_job(员工在职状态)数据源未定位(可能在账号表 raw_json 或 staff 实体) | plan 阶段核实;不可用则 A3"待交接"tab 降级隐藏,不阻塞其余 |
| R2 | sensitive_hit 首次回填后行数量级未知(取决于词库命中密度) | 分批 + 唯一约束已防膨胀;回填后实测存储量级再评估 |
| — | 员工监控合规(A1 异常警示/C1 属监控类) | 上线前公司内部明示告知(管理动作,非代码) |
---
## 10. 实施批次预估(供 plan 参考)
| 批 | 内容 | 预估 |
|---|---|---|
| 1 | stat_daily_staff(表+刷新+回退)+ analytics 层骨架 + health 端点 | ~2 天 |
| 2 | A1 总览(含异常警示条)+ useChart + ECharts 接入 | ~2 天 |
| 3 | A3 流失 + B2 该回未回 | ~2 天 |
| 4 | C1 敏感词全链 + 首次回填 + 服务器部署 | ~2 天 |
总计 ~8 个工作日(估),测试随批写。
+5
View File
@@ -0,0 +1,5 @@
"""分析服务层——所有数据分析指标的口径收敛处(spec 2026-09-04 §2)。
指标函数是纯查询(只依赖 AsyncSession,无 HTTP/请求上下文),E2 报表订阅
(二批)直接复用。口径速查表:spec §8;函数 docstring 与之同步。
"""
+57
View File
@@ -0,0 +1,57 @@
"""E0-lite 数据健康——「数据截至」角标 + 实体新鲜度。
完整 E0(调度日志解析/成功率账本)二批;v1 用表新鲜度判定:
msg_daily_stats 当日有行 = 4h 消息链路活着。
"""
from __future__ import annotations
import datetime as dt
from typing import Any
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
ChatGroup,
Customer,
Media,
Message,
MomentsPost,
MsgDailyStats,
PhoneCall,
)
async def data_as_of(session: AsyncSession) -> int | None:
"""数据截至(ms-epoch)= MAX(msg_daily_stats.last_ts)。
漏迁移导致表缺失时按 500 响亮报错是有意的——本模块是诊断端点,
要暴露部署问题而非掩盖(0ade58a 的空表静默守卫是业务端点的容错,
别好心搬到这里)。
"""
val = (
await session.execute(sa.select(sa.func.max(MsgDailyStats.last_ts)))
).scalar_one_or_none()
return int(val) if val is not None else None
async def entity_freshness(session: AsyncSession) -> list[dict[str, Any]]:
"""各实体最后更新时间(UPDATED_AT 列 MAX;message 用 fetched_at)。
同 data_as_of:表缺失按 500 响亮报错是有意的(诊断端点不容错)。
"""
async def _max(col) -> dt.datetime | None:
return (await session.execute(sa.select(sa.func.max(col)))).scalar_one_or_none()
out = []
for name, col in (
("customer", Customer.updated_at),
("chat_group", ChatGroup.updated_at),
("phone_call", PhoneCall.updated_at),
("media", Media.updated_at),
("moments_post", MomentsPost.updated_at),
("message", Message.fetched_at),
):
out.append({"table": name, "last_seen": await _max(col)})
return out
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"""C1 敏感词子系统(spec §3.6)——words(词库)/scanner(扫描)/hits(处置)。
内容源抽象:scanner 目前扫 message.text;预留 refer_msg 引文与 D4 ASR
转写文本接入位(二/三批)。日志禁打词内容与命中正文。
"""
@@ -0,0 +1,65 @@
"""命中查询与处置——默认只显示 pending+confirmed(误报治理闭环)。"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import SensitiveHit, SensitiveWord
DISPOSITIONS = ("pending", "confirmed", "false_positive", "ignored")
DEFAULT_SHOW = ("pending", "confirmed")
async def list_hits(
session: AsyncSession, *, disposition: str | None = None,
word_id: int | None = None, page: int, page_size: int,
) -> dict:
conds = []
if disposition:
conds.append(SensitiveHit.disposition == disposition)
else:
conds.append(SensitiveHit.disposition.in_(DEFAULT_SHOW))
if word_id:
conds.append(SensitiveHit.word_id == word_id)
total = (
await session.execute(
sa.select(sa.func.count()).select_from(SensitiveHit).where(*conds)
)
).scalar_one()
rows = (
await session.execute(
sa.select(SensitiveHit, SensitiveWord.word, SensitiveWord.category)
.join(SensitiveWord, SensitiveHit.word_id == SensitiveWord.id)
.where(*conds)
# ts 可 NULL/并列,id 决胜列保证翻页稳定
.order_by(SensitiveHit.ts.desc(), SensitiveHit.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).all()
items = [
{
"id": h.id, "word_id": h.word_id, "word": word, "category": category,
"msg_id": h.msg_id, "channel": h.channel,
"owner_account_id": h.owner_account_id, "talker_id": h.talker_id,
"room_id": h.room_id, "ts": h.ts, "snippet": h.snippet,
"disposition": h.disposition,
}
for h, word, category in rows
]
return {"items": items, "total": int(total), "page": page, "page_size": page_size}
async def dispose(session: AsyncSession, ids: list[int], disposition: str) -> int:
"""批量改 disposition(pending 合法——用于「重开」误报);不写日志(词/snippet 红线)。"""
if disposition not in DISPOSITIONS:
raise ValueError(f"bad disposition: {disposition!r}")
if not ids:
return 0
res = await session.execute(
sa.update(SensitiveHit)
.where(SensitiveHit.id.in_(ids))
.values(disposition=disposition)
)
return int(res.rowcount or 0)
@@ -0,0 +1,210 @@
"""敏感词扫描器——增量水位(fetched_at)+ 首次全量回填。
水位语义(spec §3.6 P2):last_fetched_at = MAX(message.fetched_at)。增量
补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,按 timestamp
做水位会永久漏扫。比较用 >=:fetched_at 是 MySQL DATETIME 秒精度,SELECT
快照后同秒 commit 的行 fetched_at == 水位,严格 > 会永久丢弃(只丢不重
扫)——>= 每 tick 重扫 ≤1 秒边界行,UNIQUE(word_id,msg_id)+已有命中
跳过去重,幂等(st.scanned 计数随边界重扫微膨胀,可接受)。
方言:MySQL ngram MATCH(≥2字,快);单字词与其他方言走 LIKE。语义差
异(MATCH 分词 vs LIKE 子串)可接受——命中可处置,误报治理闭环在。
分批:增量直接拉水位后行集(4h 增量仅数千);全量回填按词扫——MATCH 在
服务端把 86 万行预筛成命中行(每词仅数千),整批取回后按 500 行分片插
入/flush 控挂起对象数。不流式消费游标:MySQL 驱动(asyncmy)下结果未
取尽时同连接发下一条语句会 out-of-sync,全量路径无测试覆盖,取保守形。
"""
from __future__ import annotations
import datetime as dt
import logging
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics.sensitive.words import _hit_exclude
from app.db.models import Message, SensitiveHit, SensitiveScanState, SensitiveWord
logger = logging.getLogger(__name__)
ENTITY = "message_text"
_SNIPPET_RADIUS = 30 # 命中词前后各取 30 字
def _snippet(text: str, word: str) -> str:
i = text.find(word)
if i < 0:
return text[: _SNIPPET_RADIUS * 2]
lo, hi = max(0, i - _SNIPPET_RADIUS), min(len(text), i + len(word) + _SNIPPET_RADIUS)
return text[lo:hi]
def _text_match_cond(word: str, dialect: str) -> list:
"""全量回填路径:MySQL ≥2字 → ngram MATCH;否则 LIKE(单字 ngram 不可用)。
已知漏召:ngram 对嵌入标点的拉丁词可能分词不齐(如 "we-chat" 不含
"we-chat" 整词 token)——增量路径 Python 子串精确无此问题;起步词
全 CJK,当前无影响。
"""
if dialect == "mysql" and len(word) >= 2:
phrase = word.replace('"', "").replace("*", "")
return [
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{phrase}"')
]
escaped = word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return [Message.text.like(f"%{escaped}%", escape="\\")]
async def _get_state(session: AsyncSession) -> SensitiveScanState:
st = await session.get(SensitiveScanState, ENTITY)
if st is None:
st = SensitiveScanState(entity=ENTITY)
session.add(st)
await session.flush()
return st
async def _insert_hits(
session: AsyncSession, word: SensitiveWord, rows: list, existing: set[str]
) -> int:
n = 0
for msg_id, channel, owner, talker, room, ts, text in rows:
if msg_id in existing:
continue
if _hit_exclude(text, word.word, word.exclude_words or None):
continue
session.add(
SensitiveHit(
word_id=word.id, msg_id=msg_id, channel=channel,
owner_account_id=owner, talker_id=talker, room_id=room,
ts=ts, snippet=_snippet(text or "", word.word),
)
)
existing.add(msg_id)
n += 1
return n
async def _scan_batch(
session: AsyncSession, word: SensitiveWord, rows: list
) -> int:
"""对一个词扫一批 (msg_id..text) 行:查已有命中、插新命中。"""
sel_rows = [tuple(r[:7]) for r in rows if word.word in (r[6] or "")]
if not sel_rows:
return 0
existing = set(
(
await session.execute(
sa.select(SensitiveHit.msg_id).where(
SensitiveHit.word_id == word.id,
SensitiveHit.msg_id.in_(
[r[0] for r in sel_rows] or ["-"]
),
)
)
).scalars().all()
)
return await _insert_hits(session, word, sel_rows, existing)
async def run_incremental(session: AsyncSession) -> dict:
"""增量扫描:fetched_at > 水位 的行 × 全部 enabled 词。
增量行少(4h 数千)——统一 Python 侧子串判定(=LIKE 语义,精确;MATCH
只在全量回填的 MySQL 路径用,见 _text_match_cond)。
"""
words = (
await session.execute(
sa.select(SensitiveWord).where(SensitiveWord.enabled.is_(True))
)
).scalars().all()
st = await _get_state(session)
# 无水位自卫:无水位首跑=全表(86 万行 MEDIUMTEXT)进 API 进程内存。
# 增量由调度 tick 直调,大表首跑必须走 CLI sensitive-scan --full 建水位。
if st.last_fetched_at is None:
n = (await session.execute(
sa.select(sa.func.count()).select_from(Message)
)).scalar_one()
if n > 20_000:
raise RuntimeError(
"message 表超阈值且无扫描水位——首跑请先 CLI sensitive-scan"
" --full 建水位"
)
conds = [Message.text.is_not(None), Message.text != ""]
if st.last_fetched_at is not None:
# >=(非 >):fetched_at 秒精度,快照后同秒 commit 的行等值——严格 >
# 永久丢弃;>= 每 tick 重扫 ≤1s 边界行,existing/UNIQUE 去重。
conds.append(Message.fetched_at >= st.last_fetched_at)
rows = (
await session.execute(
sa.select(
Message.msg_id, Message.channel, Message.owner_account_id,
Message.talker_id, Message.room_id, Message.timestamp,
Message.text, Message.fetched_at,
).where(*conds)
)
).all()
scanned = len(rows)
hits = 0
for w in words:
hits += await _scan_batch(session, w, rows)
new_wm = max((r[7] for r in rows), default=None) if rows else None
if new_wm is not None:
st.last_fetched_at = new_wm
st.last_run_at = dt.datetime.now(dt.UTC)
st.scanned += scanned
st.hits += hits
await session.flush()
return {"scanned": scanned, "hits": hits, "new_watermark": new_wm}
async def run_full(session: AsyncSession) -> dict:
"""全量回填(CLI 一次性):每词单独扫全库;水位在扫描前取(见内注)。
每词结果整批取回(MATCH/LIKE 已在服务端预筛成命中行)再分片复用
_scan_batch——不在未取尽的结果上发新语句/flush(MySQL 驱动会
out-of-sync)。日志只打数量,不打词与正文。
"""
dialect = session.bind.dialect.name if session.bind else ""
words = (
await session.execute(
sa.select(SensitiveWord).where(SensitiveWord.enabled.is_(True))
)
).scalars().all()
st = await _get_state(session)
# 水位在扫描前取——回填期间新入库的行 fetched_at > 水位,下轮增量自然
# 扫到;若在词循环后才取,回填窗口内 commit 的行 fetched_at ≤ 水位,
# >= 增量也扫不到(首部署夜间回填与 02:00 全量批写重叠时漏扫)。
# >= 边界重扫由 UNIQUE(word_id,msg_id) 去重。
new_wm = (
await session.execute(sa.select(sa.func.max(Message.fetched_at)))
).scalar_one_or_none()
if new_wm is not None:
st.last_fetched_at = new_wm
total_hits = 0
scanned_rows = 0
for w in words:
conds = [Message.text.is_not(None), Message.text != ""]
conds += _text_match_cond(w.word, dialect)
stmt = sa.select(
Message.msg_id, Message.channel, Message.owner_account_id,
Message.talker_id, Message.room_id, Message.timestamp, Message.text,
).where(*conds)
rows = (await session.execute(stmt)).all()
scanned_rows += len(rows) # MATCH 预筛返回行数,按词累计(非去重消息数)
for i in range(0, len(rows), 500):
total_hits += await _scan_batch(session, w, rows[i:i + 500])
await session.flush() # 行已 .all() 物化,分片 flush 控挂起对象数
st.scanned += scanned_rows
st.hits += total_hits
st.last_run_at = dt.datetime.now(dt.UTC)
await session.flush()
logger.info("[sensitive-scan] full backfill done: words=%s hits=%s",
len(words), total_hits)
return {"words": len(words), "hits": total_hits}
@@ -0,0 +1,137 @@
"""词库服务——CRUD + 加词前命中量预览(防「一加就淹没」,spec P3)。"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Message, SensitiveWord
PREVIEW_WARN_THRESHOLD = 500 # 预览超此数警示收窄(前端弹提示)
async def list_words(session: AsyncSession) -> list[SensitiveWord]:
return (
(await session.execute(sa.select(SensitiveWord).order_by(SensitiveWord.id)))
.scalars().all()
)
async def create_word(
session: AsyncSession, *, word: str, category: str | None = None,
min_len: int = 2, exclude_words: list[str] | None = None,
) -> SensitiveWord:
word = (word or "").strip()
if not word:
raise ValueError("词不能为空")
dup = (
await session.execute(
sa.select(SensitiveWord).where(SensitiveWord.word == word)
)
).scalar_one_or_none()
if dup is not None:
raise ValueError(f"词已存在: id={dup.id}") # 词是用户输入,回显 id 可
obj = SensitiveWord(word=word, category=category, min_len=min_len,
exclude_words=exclude_words)
session.add(obj)
await session.flush()
return obj
async def update_word(session: AsyncSession, word_id: int, **fields) -> SensitiveWord:
obj = await session.get(SensitiveWord, word_id)
if obj is None:
raise LookupError("word not found")
for k in ("enabled", "category", "min_len", "exclude_words"):
if k in fields and fields[k] is not None:
setattr(obj, k, fields[k])
await session.flush()
return obj
async def delete_word(session: AsyncSession, word_id: int) -> None:
"""删词 = 级联删命中(SQLite FK 不强制,显式删;UI 二次确认)。"""
obj = await session.get(SensitiveWord, word_id)
if obj is None:
raise LookupError("word not found")
from app.db.models import SensitiveHit
await session.execute(
sa.delete(SensitiveHit).where(SensitiveHit.word_id == word_id)
)
await session.delete(obj)
await session.flush()
async def preview_count(
session: AsyncSession, word: str, exclude_words: list[str] | None = None
) -> int:
"""加词前 COUNT——MySQL 走 ngram MATCH,其他方言 LIKE(测试)。
口径注:MySQL MATCH 路径不扣排除词(预览=上限,排除词在扫描时精确
生效);LIKE 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。
单字词在 MySQL 拒预览(ValueError)——ngram 对单字无用,落 LIKE 前导
通配全扫(86 万行实测 ~16s,b41e8fa0 迁移记录),添加后从命中列表观察。
"""
word = (word or "").strip()
if not word:
return 0
dialect = session.bind.dialect.name if session.bind else ""
if len(word) < 2 and dialect == "mysql":
raise ValueError("单字词不做预览(MySQL 全扫);添加后从命中列表观察")
conds = [Message.text.is_not(None), Message.text != ""]
if dialect == "mysql":
# 短语净化:剥 " 与 * —— BOOLEAN MODE 里裸引号/星号是语法符,
# 混入会 1064 语法错;剥后包引号做整词短语匹配。
phrase = word.replace('"', "").replace("*", "")
conds.append(
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{phrase}"')
)
stmt = sa.select(sa.func.count()).select_from(Message).where(*conds)
n = (await session.execute(stmt)).scalar_one()
else:
escaped = word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{escaped}%"
stmt = sa.select(Message.msg_id, Message.text).where(
Message.text.is_not(None), Message.text.like(like, escape="\\")
)
rows = (await session.execute(stmt)).all()
n = sum(1 for _mid, t in rows if not _hit_exclude(t, word, exclude_words))
return int(n)
def _hit_exclude(text: str | None, word: str, exclude_words: list[str] | None) -> bool:
"""命中行含任一排除词 → 视为误报跳过。"""
if not exclude_words or not text:
return False
return any(ex in text for ex in exclude_words)
# 内置起步词库(spec §3.6):幂等补种(CLI sensitive-scan 首跑时调),上线后
# 管理员在词库页自补行业词。
STARTER_WORDS: list[tuple[str, str]] = [
("私单", "飞单"),
("飞单", "飞单"),
("加私人微信", "私联"),
("红包", "资金"),
("转账", "资金"),
("离职", "人员风险"),
]
async def ensure_starter_words(session: AsyncSession) -> int:
"""缺则补(按 word 去重幂等),返回新种入数。"""
existing = set(
(
await session.execute(sa.select(SensitiveWord.word))
).scalars().all()
)
n = 0
for word, category in STARTER_WORDS:
if word in existing:
continue
session.add(SensitiveWord(word=word, category=category))
n += 1
if n:
await session.flush()
return n
+3
View File
@@ -21,6 +21,7 @@ Includes:
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
* ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`).
* ``GET /api/staff-stats`` (see :mod:`app.api.routers.staff_stats`).
* ``GET /api/analytics/health`` (see :mod:`app.api.routers.analytics`).
CORS is configured to read explicit allowed origins from ``Settings.cors_origins``
(comma-separated ``CORS_ORIGINS`` env var). Empty (default) falls back to the dev
@@ -42,6 +43,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import require_auth
from app.api.routers import (
analytics,
calls,
customer,
export,
@@ -98,6 +100,7 @@ app.include_router(search.router)
app.include_router(sessions.router)
app.include_router(export.router)
app.include_router(voice_clips.router)
app.include_router(analytics.router)
@app.get("/api/health")
+217
View File
@@ -0,0 +1,217 @@
"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。"""
from __future__ import annotations
import datetime as dt
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.analytics import metrics as metrics_svc
from app.analytics.sensitive import hits as hits_svc
from app.analytics.sensitive import words as words_svc
from app.api.deps import get_session
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()]
# 看板 v2(2026-09-07):overview 默认全平台——"all" 在 router 层翻译成
# metrics 的 channel=None,不把哨兵字符串传进 SQL
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
# C1(批2):custom=自定义日期范围,配合 start/end yyyymmdd
ScopeParam = Annotated[
Literal["today", "week", "month", "last30", "custom"], Query()
]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
@router.get("/health")
async def analytics_health(session: SessionDep) -> dict[str, Any]:
"""E0-lite:数据截至 + 实体新鲜度。"""
as_of = await health_svc.data_as_of(session)
return {"data_as_of": as_of, "entities": await health_svc.entity_freshness(session)}
@router.get("/overview")
async def analytics_overview(
session: SessionDep,
channel: AllChannelParam = "all",
scope: ScopeParam = "today",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。
channel=all(默认)=全平台口径:卡片合计+by_channel 拆分+trend_by_channel
双渠道趋势序列;单渠道行为不变。
cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int
与 entities[].last_seen 为 ISO 字符串的双格式)。
scope=custom(C1):start/end=yyyymmdd(缺一/日历非法/Start>End/跨度
>366 天皆 422);环比=等长紧邻前窗滚动(不做同期对齐)。
"""
s = e = None
if scope == "custom":
if start is None or end is None:
raise HTTPException(422, "scope=custom requires start and end")
try:
sd = dt.date(start // 10000, (start // 100) % 100, start % 100)
ed = dt.date(end // 10000, (end // 100) % 100, end % 100)
except ValueError as exc: # 如 20260230——Query 只拦范围不拦日历
raise HTTPException(422, "invalid start/end calendar date") from exc
if sd > ed:
raise HTTPException(422, "start must be <= end")
if (ed - sd).days > 365: # 含端点共 366 天
raise HTTPException(422, "custom span must be <= 366 days")
s, e = start, end
return await metrics_svc.overview(
session, channel=None if channel == "all" else channel, scope=scope,
start=s, end=e,
)
@router.get("/churn")
async def churn_list(
session: SessionDep,
channel: ChannelParam = "wechat",
type: Literal["real", "inactive", "handover"] = "real",
tier: int = Query(30, ge=30, le=90),
min_msgs: int = Query(5, ge=1, le=100),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""A3 流失预警名单(三分法)。tier 只认 30/60/90。"""
if tier not in (30, 60, 90):
raise HTTPException(422, "tier must be 30/60/90")
return await metrics_svc.churn(
session, channel=channel, kind=type, tier=tier,
min_msgs=min_msgs, page=page, page_size=page_size,
)
@router.get("/unreplied")
async def unreplied_list(
session: SessionDep,
channel: ChannelParam = "wechat",
hours: int = Query(2, ge=1, le=168),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""B2 该回未回清单(spec §3.4)。hours=超时阈值(默认 2h)。"""
return await metrics_svc.unreplied(
session, channel=channel, hours=hours, page=page, page_size=page_size
)
class WordIn(BaseModel):
word: str
category: str | None = None
min_len: int = 2
exclude_words: list[str] | None = None
class WordPreviewIn(BaseModel):
word: str
exclude_words: list[str] | None = None
@router.get("/sensitive/words")
async def list_words(session: SessionDep) -> list[dict]:
"""C1 词库清单(enabled=false 的词 scanner 跳过)。"""
return [
{"id": w.id, "word": w.word, "category": w.category, "enabled": w.enabled,
"min_len": w.min_len, "exclude_words": w.exclude_words or []}
for w in await words_svc.list_words(session)
]
@router.post("/sensitive/words")
async def create_word(session: SessionDep, body: WordIn) -> dict:
"""加词(重复 409)。词内容是敏感数据,错误信息只回 id 不回词。"""
try:
w = await words_svc.create_word(
session, word=body.word, category=body.category,
min_len=body.min_len, exclude_words=body.exclude_words,
)
await session.commit()
return {"id": w.id, "word": w.word, "enabled": w.enabled}
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
except sa.exc.IntegrityError as exc:
# 并发撞 word UNIQUE(两请求同词,预检都未见)→ 409,不裸 500。
raise HTTPException(409, "词已存在") from exc
@router.post("/sensitive/words/preview")
async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict:
"""加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。
MySQL 单字词拒扫(400)——LIKE 前导通配全扫 ~16s,加词后从命中列表观察。
"""
try:
n = await words_svc.preview_count(session, body.word, body.exclude_words)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"count": n, "warn": n >= words_svc.PREVIEW_WARN_THRESHOLD}
@router.patch("/sensitive/words/{word_id}")
async def update_word(session: SessionDep, word_id: int, body: dict) -> dict:
"""改词(启停/分类/排除词);字段 None=不改。"""
try:
w = await words_svc.update_word(session, word_id, **body)
await session.commit()
return {"id": w.id, "enabled": w.enabled, "category": w.category}
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
@router.delete("/sensitive/words/{word_id}")
async def delete_word(session: SessionDep, word_id: int) -> dict:
"""删词=级联删命中(UI 二次确认)。"""
try:
await words_svc.delete_word(session, word_id)
await session.commit()
return {"ok": True}
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
class DisposeIn(BaseModel):
# 巨 IN 列表会撞 MySQL max_allowed_packet 裸 500 → pydantic 层拦成干净 422
ids: list[int] = Field(max_length=1000)
disposition: str
DispositionParam = Literal["pending", "confirmed", "false_positive", "ignored"]
@router.get("/sensitive/hits")
async def list_hits(
session: SessionDep,
disposition: DispositionParam | None = None,
word_id: int | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict:
"""C1 命中列表——不传 disposition 默认只回 pending+confirmed(治理闭环:
处置完即出队列);word_id 供词详情下钻。disposition 拼错 422 不静默空列表。"""
return await hits_svc.list_hits(
session, disposition=disposition, word_id=word_id,
page=page, page_size=page_size,
)
@router.patch("/sensitive/hits")
async def dispose_hits(session: SessionDep, body: DisposeIn) -> dict:
"""批量处置(pending 合法——重开误报);UPDATE 不写日志(词/snippet 红线)。"""
try:
n = await hits_svc.dispose(session, body.ids, body.disposition)
await session.commit()
return {"updated": n}
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
+37 -9
View File
@@ -12,10 +12,20 @@ Read-only query over the mirrored ``message`` table. Filters:
see ``_MSG_TYPE_FILTER``). Unknown values are rejected with 422.
* ``q`` — substring match on ``text`` (``%``/``_`` escaped
as literals).
* ``order`` — ``asc`` (default, oldest-first timeline) /
``desc`` (newest-first; 聊天窗首屏「取最新一页」用它,反向索引扫描恒
O(page_size),避开 ASC 深 OFFSET 逐行回表——2026-09-07 实测 1 万条对话
末页 2.5s、4 万条群 10.7s 撞前端 15s 超时)。
* ``before_ts``/``before_id`` — keyset 游标(成对,可选):只取严格早于
``(before_ts, before_id)`` 的消息(排序列 (timestamp, msg_id) 全序,
平手按 msg_id 决胜)。「加载更多」上翻历史用它,每页恒 O(page_size);
两参必须成对,单传任一 422。
Ordered by ``timestamp asc`` so the timeline renders oldest-first. The index
``ix_message_channel_owner_talker_time`` covers the hot path
``(channel, owner_account_id, talker_id, timestamp)``.
Ordered by ``(timestamp, msg_id)`` in the requested direction. The index
``ix_message_channel_owner_talker_time`` covers the 1:1 hot path
``(channel, owner_account_id, talker_id, timestamp)``; group queries filter
``channel + room_id`` without owner(群聊 owner 错位,见 ChatMain queryFor),
served by ``ix_message_channel_room_time``.
"""
from __future__ import annotations
@@ -23,7 +33,7 @@ from __future__ import annotations
from typing import Annotated, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
@@ -70,8 +80,16 @@ async def list_messages(
end: Annotated[int | None, Query(ge=0)] = None,
msg_type: Annotated[MsgTypeParam | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
order: Annotated[Literal["asc", "desc"], Query()] = "asc",
before_ts: Annotated[int | None, Query(ge=0)] = None,
before_id: Annotated[str | None, Query()] = None,
) -> Page[MessageOut]:
"""List messages for a timeline (ordered by ``timestamp asc``)."""
"""List messages for a timeline (ordered by ``(timestamp, msg_id)``)."""
if (before_ts is None) != (before_id is None):
raise HTTPException(
status_code=422, detail="before_ts 与 before_id 必须成对使用"
)
conditions = []
if channel is not None:
conditions.append(Message.channel == channel)
@@ -91,20 +109,30 @@ async def list_messages(
# LIKE 通配符转义: %/_ 按字面匹配
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(Message.text.like(f"%{escaped}%", escape="\\"))
if before_ts is not None:
# keyset 游标:严格早于 (before_ts, before_id) 的全序前缀,平手按
# msg_id 决胜——与 order_by 同一全序,翻页无缝
conditions.append(
sa.or_(
Message.timestamp < before_ts,
sa.and_(Message.timestamp == before_ts, Message.msg_id < before_id),
)
)
total_stmt = sa.select(sa.func.count()).select_from(Message)
if conditions:
total_stmt = total_stmt.where(*conditions)
total = (await session.execute(total_stmt)).scalar_one()
ts_order = (
Message.timestamp.desc() if order == "desc" else Message.timestamp.asc()
)
id_order = Message.msg_id.desc() if order == "desc" else Message.msg_id.asc()
list_stmt = sa.select(Message)
if conditions:
list_stmt = list_stmt.where(*conditions)
list_stmt = (
list_stmt.order_by(
sa.nullslast(Message.timestamp.asc()),
Message.msg_id.asc(),
)
list_stmt.order_by(sa.nullslast(ts_order), id_order)
.limit(page_size)
.offset((page - 1) * page_size)
)
+183
View File
@@ -256,6 +256,9 @@ class Customer(Base):
"owner_account_id",
"friend_id",
),
# /api/analytics/health MAX(updated_at) 新鲜度探针;无索引时全表扫。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP(c3d4e5f6a7b8)。
Index("ix_customer_updated_at", "updated_at"),
)
@@ -314,6 +317,8 @@ class ChatGroup(Base):
"owner_account_id",
"room_id",
),
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
Index("ix_chat_group_updated_at", "updated_at"),
)
@@ -429,6 +434,19 @@ class Message(Base):
"room_id",
"timestamp",
),
# 群聊消息窗实际查询形如 ``channel=? AND room_id=? ORDER BY timestamp``
# (不带 owner——chat_group 按 room 去重后 owner 与消息归属错位,见
# ChatMain queryFor)。上面的索引被 owner 挡住前缀用不上,优化器退化到
# room 单列索引 + filter + filesort:2026-09-07 服务器实测 4 万条群
# count 4.7s / 首页 5.1s / 末页 10.7s,串行撞前端 15s 超时
# (ECONNABORTED)。此索引让群查询与单聊同享 seek+排序直达。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_channel_room_time",
"channel",
"room_id",
"timestamp",
),
# 今日收发聚合热路径:channel= + owner IN + timestamp>= 定位当天消息
# (customer 列表行内 send_today/receive_today)。上面两个索引把
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
@@ -441,6 +459,13 @@ class Message(Base):
"owner_account_id",
"timestamp",
),
# C1 敏感词增量扫描水位:MAX(fetched_at) / fetched_at > 水位 定位新入库
# 消息(按消息 timestamp 做水位会永久漏扫增量拉回的历史消息,spec §3.6)。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_fetched_at",
"fetched_at",
),
)
@@ -473,6 +498,9 @@ class Media(Base):
__table_args__ = (
Index("ix_media_type_channel", "media_type", "channel"),
# /api/analytics/health MAX(updated_at) 新鲜度探针——media 5 万宽行
# (多个 TEXT/JSON 列)在 128MB buffer pool 下最伤(c3d4e5f6a7b8)。
Index("ix_media_updated_at", "updated_at"),
)
@@ -524,6 +552,8 @@ class PhoneCall(Base):
__table_args__ = (
Index("ix_phone_call_user_time", "user_id", "start_call_time"),
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
Index("ix_phone_call_updated_at", "updated_at"),
)
@@ -642,6 +672,9 @@ class MomentsPost(Base):
fetched_at: Mapped[dt.datetime] = _fetched_at()
updated_at: Mapped[dt.datetime] = _updated_at()
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
__table_args__ = (Index("ix_moments_post_updated_at", "updated_at"),)
# ===========================================================================
# 5. Operational / bookkeeping tables (local; no raw_json/version)
@@ -756,6 +789,10 @@ class MsgDailyStats(Base):
- ``send_cnt``/``recv_cnt`` 仅计 is_send 真/假;NULL 两边都不计(对齐
旧聚合的 case 语义)。
- ``last_ts``/``last_is_send`` 为该日最后一条消息的时间与方向。
- 首响/回复率三列(2026-09-07 批1)按「消息串」口径,见
:func:`app.storage.summary._thread_stats`:串=客户连续消息不被员工
消息打断的段(is_send=NULL 不参与串判定);日切近似——日末未闭合
串计 threads 不计 answered,跨夜的串在次日段重新起算。
"""
__tablename__ = "msg_daily_stats"
@@ -776,10 +813,152 @@ class MsgDailyStats(Base):
# 当日最后一条消息(ms);当天无有效方向计数的消息也存在(is_send=NULL)
last_ts: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
last_is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# 当日客户串数(串=客户连续消息不被员工消息打断的段;NULL 方向不参与)
cust_threads: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 被员工回复的串数 ≡ 首响样本数 fr_cnt(fr_cnt=threads_answered 不单列)
threads_answered: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 首响样本时长和(ms):展示用首响时长 = fr_sum_ms / threads_answered
fr_sum_ms: Mapped[int] = mapped_column(
sa.BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
updated_at: Mapped[dt.datetime] = _updated_at()
class StatDailyStaff(Base):
"""员工×日 汇总(stat_daily_staff)— msg_daily_stats 按账号→员工映射的 rollup。
数据分析板块 v1 地基(2026-09-04 spec §3.1):A1 员工指标直读;A2 绩效
(二批)零基建直读。维护见 app/storage/staff_summary.py——4h 调度刷
当日+滚动 3 天 + 夜间全量自愈,**不挂写钩子**(rollup-of-rollup 无需
实时,滞后语义与「数据截至」角标一致)。映射不上的账号 user_id 存
owner_account_id 原值(读侧标注未映射)。day 口径同 msg_daily_stats
(Asia/Shanghai yyyymmdd,Python 侧计算)。
"""
__tablename__ = "stat_daily_staff"
day: Mapped[int] = mapped_column(Integer, primary_key=True)
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
msg_sent: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
msg_recv: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 当日活跃去重数(账号合并到员工后跨账号去重 peer)
active_friends: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
active_rooms: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
# 首响/回复率三列(2026-09-07 批1):msg_daily_stats 同义列按账号→员工
# 累加。回复率=Σanswered/Σthreads;首响时长=Σfr_sum_ms/Σanswered。
cust_threads: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
threads_answered: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
fr_sum_ms: Mapped[int] = mapped_column(
sa.BigInteger, nullable=False, default=0, server_default=sa.text("0")
)
updated_at: Mapped[dt.datetime] = _updated_at()
class SensitiveWord(Base):
"""敏感词库(C1)——词库内容是高敏数据,日志禁止输出词内容。"""
__tablename__ = "sensitive_word"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
word: Mapped[str] = mapped_column(String(64), unique=True)
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=sa.text("1")
)
# 短于此长度不入库(防单字误报;单字词走 LIKE 回退扫描)
min_len: Mapped[int] = mapped_column(
Integer, nullable=False, default=2, server_default=sa.text("2")
)
# JSON 数组:命中行文本含任一排除词则跳过(误报抑制)
exclude_words: Mapped[list[Any] | dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
# created_at 不用 _updated_at():无 onupdate,首次落库后不被静默重写
# (T8 改词/T10 处置命中只该动 updated_at/disposition)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
updated_at: Mapped[dt.datetime] = _updated_at()
class SensitiveHit(Base):
"""敏感词命中——冗余 channel/owner/talker/room 跳转字段(不回查 message)。
UNIQUE(word_id, msg_id) 防重扫重复膨胀;disposition ∈
pending/confirmed/false_positive/ignored。
"""
__tablename__ = "sensitive_hit"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
word_id: Mapped[int] = mapped_column(
sa.ForeignKey("sensitive_word.id", ondelete="CASCADE"), nullable=False
)
msg_id: Mapped[str] = mapped_column(String(128), nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
owner_account_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
talker_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
room_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
ts: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# 命中词前后文片段(不含全文,控敏)
snippet: Mapped[str | None] = mapped_column(String(255), nullable=True)
disposition: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", server_default="pending"
)
# created_at 不用 _updated_at():无 onupdate,首次落库后不被静默重写
# (T8 改词/T10 处置命中只该动 updated_at/disposition)
created_at: Mapped[dt.datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.now()
)
__table_args__ = (
sa.UniqueConstraint("word_id", "msg_id", name="uq_sensitive_hit_word_msg"),
sa.Index("ix_sensitive_hit_disp_ts", "disposition", "ts"),
)
class SensitiveScanState(Base):
"""敏感词扫描水位——entity v1 只有 'message_text'。
水位存 message.fetched_at(入库时间,DateTime)而非消息 timestamp:
增量补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,按
timestamp 做水位会永久漏扫(spec §3.6 P2)。
"""
__tablename__ = "sensitive_scan_state"
entity: Mapped[str] = mapped_column(String(32), primary_key=True)
last_fetched_at: Mapped[dt.datetime | None] = mapped_column(
sa.DateTime, nullable=True
)
last_run_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
scanned: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
hits: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=sa.text("0")
)
updated_at: Mapped[dt.datetime] = _updated_at()
__all__ = [
"ChatGroup",
"Customer",
@@ -790,8 +969,12 @@ __all__ = [
"MomentsPost",
"MsgDailyStats",
"PhoneCall",
"SensitiveHit",
"SensitiveScanState",
"SensitiveWord",
"Sms",
"Staff",
"StatDailyStaff",
"SyncRun",
"SyncState",
"VoiceClip",
+161
View File
@@ -0,0 +1,161 @@
"""stat_daily_staff(员工×日)维护层——rollup-of-rollup,不挂写钩子。
刷新策略(spec §3.1):4h 调度刷当日+滚动 3 天(staff_summary.rolling_days),
夜间 full_sync 全量自愈(recompute_all),部署回填 CLI 同款。空表时读端点
回退直接聚合 msg_daily_stats(毫秒级)——部署即用,沿用 sessions 的
has_rows 回退模式。
"""
from __future__ import annotations
import datetime as dt
import logging
from collections.abc import Iterable
from zoneinfo import ZoneInfo
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import MsgDailyStats, StatDailyStaff, WechatAccount, WhatsappAccount
logger = logging.getLogger(__name__)
_SH = ZoneInfo("Asia/Shanghai")
async def has_rows(session: AsyncSession) -> bool:
"""汇总表是否已有数据(读端点空表守卫;表不存在与空表同视)。"""
try:
return bool(
(
await session.execute(
sa.select(sa.func.count()).select_from(StatDailyStaff)
)
).scalar_one()
)
except (sa.exc.OperationalError, sa.exc.ProgrammingError):
return False
async def account_map(session: AsyncSession, channel: str) -> dict[str, str]:
"""owner_account_id → user_id(按渠道选账号表;映射不上的不在 dict)。"""
if channel == "whatsapp":
stmt = sa.select(WhatsappAccount.uid, WhatsappAccount.user_id).where(
WhatsappAccount.user_id.is_not(None)
)
else:
stmt = sa.select(WechatAccount.wx_id, WechatAccount.user_id).where(
WechatAccount.user_id.is_not(None)
)
return {owner: uid for owner, uid in (await session.execute(stmt)).all() if uid}
def rolling_days(today: int, window: int = 3) -> list[int]:
"""当日 + 前 window-1 天的 day 键(4h job 的滚动刷新窗)。"""
d = _day_from_key(today)
return [_key_from_day(d - dt.timedelta(days=i)) for i in range(window - 1, -1, -1)]
def _day_from_key(day: int) -> dt.date:
return dt.date(day // 10000, (day // 100) % 100, day % 100)
def _key_from_day(d: dt.date) -> int:
return d.year * 10000 + d.month * 100 + d.day
async def recompute_days(
session: AsyncSession, days: Iterable[int], channels: tuple[str, ...] = ("wechat", "whatsapp")
) -> int:
"""重算给定 day×channel 的员工行:删旧插新,幂等。
返回**有数据行的 day×channel 组合数**(空渠道不计——与测试断言的
「天数」语义一致,seed 只有 wechat 日行时返回 1)。
单日日行仅数百~两千,拉回 Python 侧按 user_id 聚合——COUNT(DISTINCT
peer) 必须在「账号合并到员工」之后算(同人多账号 distinct peer 跨账号
去重),写 SQL 反而绕且踩 only_full_group_by。
"""
n = 0
# 映射按渠道取一次缓存(而非 day×channel 每轮重查)——全量重建时
# ~600 次重复小表全查收敛为每渠道 1 次,且整个重建用同一份映射快照。
maps = {ch: await account_map(session, ch) for ch in channels}
for day in sorted(set(days)):
for channel in channels:
amap = maps[channel]
rows = (
await session.execute(
sa.select(
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_kind,
MsgDailyStats.peer_id,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
# 首响/回复率三列(2026-09-07 批1):同义聚合累加
MsgDailyStats.cust_threads,
MsgDailyStats.threads_answered,
MsgDailyStats.fr_sum_ms,
).where(
MsgDailyStats.day == day, MsgDailyStats.channel == channel
)
)
).all()
await session.execute(
sa.delete(StatDailyStaff).where(
StatDailyStaff.day == day, StatDailyStaff.channel == channel
)
)
if not rows:
continue
agg: dict[str, dict] = {}
for owner, kind, peer, s, r, t, a_ns, fr in rows:
uid = amap.get(owner, owner) # 映射不上按 owner 原值(读侧标注)
a = agg.setdefault(
uid,
{
"sent": 0,
"recv": 0,
"friends": set(),
"rooms": set(),
"threads": 0,
"answered": 0,
"fr": 0,
},
)
a["sent"] += int(s or 0)
a["recv"] += int(r or 0)
a["threads"] += int(t or 0)
a["answered"] += int(a_ns or 0)
a["fr"] += int(fr or 0)
if kind == "friend": # 显式分支:未来新 kind 跳过,不误入 rooms
a["friends"].add(peer)
elif kind == "room":
a["rooms"].add(peer)
for uid, a in sorted(agg.items()):
session.add(
StatDailyStaff(
day=day,
channel=channel,
user_id=uid,
msg_sent=a["sent"],
msg_recv=a["recv"],
active_friends=len(a["friends"]),
active_rooms=len(a["rooms"]),
cust_threads=a["threads"],
threads_answered=a["answered"],
fr_sum_ms=a["fr"],
)
)
n += 1
return n
async def recompute_all(session: AsyncSession) -> int:
"""全量重建(夜间自愈 / 部署回填,幂等)。
一次清空保证 msg_daily_stats 中已消失的日期不留陈旧行(夜间
summary.recompute_all 清空重建 msg_daily_stats 后,本层同步自愈)。
"""
await session.execute(sa.delete(StatDailyStaff))
days = (
await session.execute(sa.select(MsgDailyStats.day).distinct())
).scalars().all()
return await recompute_days(session, list(days))
+59 -2
View File
@@ -14,6 +14,17 @@
计数语义与旧实时聚合完全一致:is_send=True 计 send、False 计 recv、
NULL 两边不计;timestamp 为 NULL 的消息不参与统计。
首响/回复率三列(2026-09-07 批1,A1+A2)——「消息串」口径(与 8-24
spec 对齐,定案):
- **串**:同会话内客户连续消息(is_send=False)不被员工消息(is_send=True)
打断的段;is_send=None(unknown)不参与串判定(跳过——不打断也不起串)。
- **首响样本**:每个被回串一个样本 fr_ms = 员工首条回复 ts − 串末条客户
消息 ts。fr_cnt ≡ threads_answered(被回串数=样本数),不单列。
- **日切近似**(v1):单日内计算,日末未闭合的串计 threads 不计 answered,
跨夜的串在次日段重新起算——8-24 spec 的 24h 窗口径的简化,读端拼均值
(fr_sum_ms / threads_answered)不受影响,仅跨夜串的归属日有偏差。
"""
from __future__ import annotations
@@ -113,6 +124,36 @@ def _peer_filter(kind: str, peer: str) -> list:
]
def _thread_stats(rows: list) -> tuple[int, int, int]:
"""消息串口径三值:(cust_threads, threads_answered, fr_sum_ms)。
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断
的段;is_send=None(unknown)不参与串判定——跳过,不打断也不起串。
- 每个被回串一个首响样本 fr_ms = 员工首条回复 ts − 串**末条**客户消息
ts;fr_sum_ms 为样本时长和(展示用首响时长 = fr_sum_ms/answered)。
- 日切近似 v1:日末未闭合的串计 threads 不计 answered(跨夜的串在次日
段重新起算——8-24 spec 的 24h 窗口径的简化)。
- rows 须先按 ts 升序(调用方 sorted;SQL 无 ORDER BY,乱序会错算串)。
"""
threads = answered = 0
fr_sum = 0
in_thread = False
thread_last_ts: int | None = None
for ts, is_send in rows:
if is_send is False:
if not in_thread:
in_thread = True
threads += 1
thread_last_ts = int(ts)
elif is_send is True:
if in_thread and thread_last_ts is not None:
answered += 1
fr_sum += int(ts) - thread_last_ts
in_thread = False
# is_send=None:跳过(不参与串判定)
return threads, answered, fr_sum
async def recompute_days(
session: AsyncSession,
keys: set[tuple[str, str, str, str, int]] | list[tuple[str, str, str, str, int]],
@@ -140,7 +181,10 @@ async def recompute_days(
send = recv = 0
last_ts: int | None = None
last_is_send: bool | None = None
for ts, is_send in rows:
# 串算法需要严格时序:先排序(SQL 无 ORDER BY,旧代码遍历中 last_ts
# 用 >= 兜乱序;排序后该兜底语义不变——max ts 与并列末条方向一致)。
ordered = sorted(rows, key=lambda r: r[0])
for ts, is_send in ordered:
if is_send is True:
send += 1
elif is_send is False:
@@ -148,6 +192,7 @@ async def recompute_days(
if last_ts is None or ts >= last_ts:
last_ts = int(ts)
last_is_send = is_send
threads, answered, fr_sum = _thread_stats(ordered)
await session.execute(
sa.delete(MsgDailyStats).where(
MsgDailyStats.channel == channel,
@@ -169,6 +214,9 @@ async def recompute_days(
recv_cnt=recv,
last_ts=last_ts,
last_is_send=last_is_send,
cust_threads=threads,
threads_answered=answered,
fr_sum_ms=fr_sum,
)
)
n += 1
@@ -224,7 +272,8 @@ async def recompute_all(session: AsyncSession) -> int:
send = recv = 0
last_ts = None
last_is_send = None
for ts, is_send in day_rows:
ordered = sorted(day_rows, key=lambda r: r[0]) # 串算法需严格时序
for ts, is_send in ordered:
if is_send is True:
send += 1
elif is_send is False:
@@ -232,6 +281,7 @@ async def recompute_all(session: AsyncSession) -> int:
if last_ts is None or ts >= last_ts:
last_ts = int(ts)
last_is_send = is_send
threads, answered, fr_sum = _thread_stats(ordered)
session.add(
MsgDailyStats(
channel=channel,
@@ -243,9 +293,16 @@ async def recompute_all(session: AsyncSession) -> int:
recv_cnt=recv,
last_ts=last_ts,
last_is_send=last_is_send,
cust_threads=threads,
threads_answered=answered,
fr_sum_ms=fr_sum,
)
)
total_pairs += 1
if total_pairs % 500 == 0:
await session.flush()
return total_pairs
# 公共别名(2026-09-04):analytics 层复用日界计算,避免各处复制时区逻辑。
day_bounds_ms = _day_bounds_ms
+77 -1
View File
@@ -1,6 +1,6 @@
"""Command-line entry point for running syncs + the scheduler (M6, Part B).
Three invocations::
Three invocations (plus analytics backfills, see below)::
# One-shot full sync (every entity) into the configured DB:
python -m app.sync.cli sync
@@ -11,6 +11,11 @@ Three invocations::
# Long-running scheduler (APScheduler; full sync daily + messages every 4h):
python -m app.sync.cli scheduler
# One-shot analytics backfills / self-heal (all idempotent):
python -m app.sync.cli daily-stats
python -m app.sync.cli staff-daily-stats
python -m app.sync.cli sensitive-scan --full
Auth + DB
---------
Auth comes from env / ``.env`` via the same helpers the smoke script uses
@@ -101,6 +106,36 @@ def _build_parser() -> argparse.ArgumentParser:
),
)
# ``staff-daily-stats`` ---------------------------------------------------
p_staff_stats = sub.add_parser(
"staff-daily-stats",
help="Rebuild stat_daily_staff (staff x day rollup).",
description=(
"Full rebuild of the staff daily rollup (stat_daily_staff) from "
"msg_daily_stats. Used as the nightly self-heal step in "
"scripts/full_sync_all.sh and as the one-off deploy backfill. "
"Idempotent."
),
)
# ``sensitive-scan`` ------------------------------------------------------
p_scan = sub.add_parser(
"sensitive-scan",
help="Sensitive-word scan (incremental; --full for backfill).",
description=(
"Incremental sensitive-word scan by fetched_at watermark. "
"First run on a populated DB MUST use --full to build the "
"watermark (run_incremental refuses no-watermark on big tables). "
"Adding a new word does NOT backfill history (watermark only "
"sees new rows) — rerun --full to scan history for it."
),
)
p_scan.add_argument(
"--full",
action="store_true",
help="Full backfill scan (first deploy / new-word history).",
)
# ``scheduler`` -----------------------------------------------------------
p_sched = sub.add_parser(
"scheduler",
@@ -302,6 +337,43 @@ async def _cmd_daily_stats(args: argparse.Namespace) -> int:
await engine.dispose()
async def _cmd_staff_daily_stats(args: argparse.Namespace) -> int:
"""全量重建 stat_daily_staff(夜间自愈 / 部署回填,幂等)。"""
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.storage.staff_summary import recompute_all
async with session_maker() as session:
days = await recompute_all(session)
await session.commit()
print(f"[staff-daily-stats] rebuilt {days} days")
return 0
finally:
await engine.dispose()
async def _cmd_sensitive_scan(args: argparse.Namespace) -> int:
"""敏感词扫描:默认增量(水位后);--full 全量回填(首跑/新词历史)。"""
settings = get_settings()
engine, session_maker = await _init_db(settings.db_url)
try:
from app.analytics.sensitive import scanner
from app.analytics.sensitive import words as words_svc
async with session_maker() as session:
seeded = await words_svc.ensure_starter_words(session) # 幂等补种起步词库
if args.full:
out = await scanner.run_full(session)
else:
out = await scanner.run_incremental(session)
await session.commit()
print(f"[sensitive-scan] seeded={seeded} {out}")
return 0
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns a process exit code."""
# Configure logging so the scheduler's JSON line + the orchestrator's
@@ -322,6 +394,10 @@ def main(argv: list[str] | None = None) -> int:
return asyncio.run(_cmd_scheduler(args))
if args.command == "daily-stats":
return asyncio.run(_cmd_daily_stats(args))
if args.command == "staff-daily-stats":
return asyncio.run(_cmd_staff_daily_stats(args))
if args.command == "sensitive-scan":
return asyncio.run(_cmd_sensitive_scan(args))
except KeyboardInterrupt:
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
return 0
+41
View File
@@ -28,6 +28,7 @@ import logging
import os
import shutil
import sys
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -535,6 +536,16 @@ class SyncScheduler:
coalesce=True,
max_instances=1,
)
# 4h 分析链:stat_daily_staff 滚动3天刷新 + 敏感词增量扫描(纯 DB,
# 无浏览器子进程;与消息增量同节奏,读滞后语义与「数据截至」一致)
sched.add_job(
self._analytics_tick,
IntervalTrigger(hours=self.message_interval_hours),
id="yunke_analytics_tick",
replace_existing=True,
coalesce=True,
max_instances=1,
)
sched.start()
self._scheduler = sched
logger.info(
@@ -662,6 +673,36 @@ class SyncScheduler:
logger.info(line)
print(line, flush=True)
async def _analytics_tick(self) -> None:
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级;
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch
(``time.time()*1000``)即无歧义——用 epoch 而非 naive datetime
只为读法直接,数学上两者经本地时区往返等价。
"""
try:
async with self._session_maker() as session:
from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session)
await session.commit()
logger.info(
"[analytics-tick] staff_days=%s scanned=%s hits=%s",
n, scan.get("scanned"), scan.get("hits"),
)
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed")
async def _page_subprocess(self, args: list[str]) -> int:
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。"""
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
@@ -0,0 +1,50 @@
"""5 表 updated_at 窄索引——/api/analytics/health MAX 消除全表扫
Revision ID: c3d4e5f6a7b8
Revises: f0a1b2c3d4e5
Create Date: 2026-09-04
health 端点每页面加载对 6 表各 MAX 一次;5 表 updated_at 无索引时
MySQL 全表扫(media 5万宽行在 128MB buffer pool 下最伤)。单列窄索引
各几 MB,MAX 变 O(1) 索引定位。幂等:_has_index 先查。SQLite/MySQL 通用。
模型侧 models.py 同步双声明,防 alembic autogenerate 反 DROP。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "f0a1b2c3d4e5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# (表名, 索引名) —— 与 models.py 各模型 __table_args__ 一字对应
_INDEXES: tuple[tuple[str, str], ...] = (
("customer", "ix_customer_updated_at"),
("chat_group", "ix_chat_group_updated_at"),
("phone_call", "ix_phone_call_updated_at"),
("media", "ix_media_updated_at"),
("moments_post", "ix_moments_post_updated_at"),
)
def _has_index(bind, table: str, index: str) -> bool:
insp = sa.inspect(bind)
if not insp.has_table(table):
return False
return index in {ix["name"] for ix in insp.get_indexes(table)}
def upgrade() -> None:
bind = op.get_bind()
for table, index in _INDEXES:
if not _has_index(bind, table, index):
op.create_index(index, table, ["updated_at"])
def downgrade() -> None:
bind = op.get_bind()
for table, index in _INDEXES:
if _has_index(bind, table, index):
op.drop_index(index, table_name=table)
@@ -0,0 +1,62 @@
"""首响时长+回复率聚合列(msg_daily_stats / stat_daily_staff 各 3 列)
Revision ID: d9e0f1a2b3c4
Revises: c3d4e5f6a7b8
Create Date: 2026-09-07
数据分析板块批1(A1+A2,2026-09-07):管理层要看「每个人的响应质量」。
两表各加 cust_threads / threads_answered / fr_sum_ms——消息串算法见
app/storage/summary.py(口径与 8-24 spec 对齐,日切近似 v1)。
fr_cnt ≡ threads_answered(被回串数=样本数),不单列。
幂等:sa.inspect 先查列存在则跳过(照 9b0d0c133035 惯例,SQLite/MySQL
通用);downgrade 对称删列。模型侧 models.py 同步双声明,防 alembic
autogenerate 反 DROP。NOT NULL + server_default 0:旧行/旧构造不传也不炸。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d9e0f1a2b3c4"
down_revision: str | Sequence[str] | None = "c3d4e5f6a7b8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_COLUMNS: tuple[tuple[str, str, sa.types.TypeEngine], ...] = (
("msg_daily_stats", "cust_threads", sa.Integer()),
("msg_daily_stats", "threads_answered", sa.Integer()),
("msg_daily_stats", "fr_sum_ms", sa.BigInteger()),
("stat_daily_staff", "cust_threads", sa.Integer()),
("stat_daily_staff", "threads_answered", sa.Integer()),
("stat_daily_staff", "fr_sum_ms", sa.BigInteger()),
)
def _has_column(bind, table: str, column: str) -> bool:
insp = sa.inspect(bind)
if not insp.has_table(table):
return False
return column in {c["name"] for c in insp.get_columns(table)}
def upgrade() -> None:
"""Upgrade schema."""
bind = op.get_bind()
for table, column, type_ in _COLUMNS:
if _has_column(bind, table, column):
continue # 幂等:已加过则跳过
op.add_column(
table,
sa.Column(column, type_, nullable=False, server_default=sa.text("0")),
)
def downgrade() -> None:
"""Downgrade schema."""
bind = op.get_bind()
for table, column, _ in _COLUMNS:
if _has_column(bind, table, column):
op.drop_column(table, column)
@@ -0,0 +1,66 @@
"""message 补 (channel, room_id, timestamp) 索引(群聊消息窗超时修复)
Revision ID: e6f7a8b9c0d1
Revises: d9e0f1a2b3c4
Create Date: 2026-09-07
内容
----
群聊消息窗(/api/messages?channel=&room_id=)**刻意不带 owner**——chat_group
按 room_id 去重后表的 owner 被最后写入的员工覆盖,与消息实际归属错位,带上
会查空(2026-08-22 实测)。于是 ix_message_channel_owner_room_time 的
owner 前缀用不上,优化器退化到 room 单列索引(ix_message_room)取全行再
filter channel + filesort:
- 16,290 条群: count 4.7s / 首页 5.1s / ASC 末页 10.7s(EXPLAIN ANALYZE
实测,Sort row IDs 全量 16k 行、128MB buffer pool 下逐行回表磁盘随机读);
- 真凶对话 53132846289@chatroom 40,863 条(count+首页+末页串行 ~40s),
前端 axios 15s 超时 → 聊天记录加载失败 ECONNABORTED(2026-09-07 用户报)。
本迁移补 (channel, room_id, timestamp) 三列索引:群查询与单聊同享
seek + 排序直达 + 覆盖 count。模型侧 models.py 同步双声明,防 alembic
autogenerate 反 DROP。窄列 BTREE,~94 万行在线建索引预计几十秒
(参照 c53f90b1e6a4 实测 86 万行 16s)。
配套:同日 /api/messages 加 order=desc + before_ts/before_id 游标参数,
聊天窗首屏「DESC 取最新一页 + 反转」、上翻游标,避开 ASC 深 OFFSET
逐行回表(单聊 1 万条末页实测 2.5s → DESC LIMIT 15ms)。
幂等:``sa.inspect`` 先查同名索引,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e6f7a8b9c0d1'
down_revision: str | Sequence[str] | None = 'd9e0f1a2b3c4'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = 'ix_message_channel_room_time'
def _has_index(table: str, index: str) -> bool:
insp = sa.inspect(op.get_bind())
return index in {i['name'] for i in insp.get_indexes(table)}
def upgrade() -> None:
"""Upgrade schema."""
if _has_index('message', _INDEX_NAME):
return # 已建过(或先手工建过),幂等跳过
op.create_index(
_INDEX_NAME,
'message',
['channel', 'room_id', 'timestamp'],
)
def downgrade() -> None:
"""Downgrade schema."""
if not _has_index('message', _INDEX_NAME):
return
op.drop_index(_INDEX_NAME, table_name='message')
@@ -0,0 +1,103 @@
"""数据分析板块 v1:stat_daily_staff + 敏感词三表 + message.fetched_at 索引
Revision ID: f0a1b2c3d4e5
Revises: a7d3e9f1c2b4
Create Date: 2026-09-04
spec: docs/superpowers/specs/2026-09-04-yunke-analytics-module-design.md。
fetched_at 索引服务 C1 增量水位(MAX(fetched_at) / fetched_at > 水位)。
幂等:sa.inspect 先查,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: str | Sequence[str] | None = "a7d3e9f1c2b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _has_index(bind, table: str, index: str) -> bool:
insp = sa.inspect(bind)
if not insp.has_table(table):
return False
return index in {ix["name"] for ix in insp.get_indexes(table)}
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if not insp.has_table("stat_daily_staff"):
op.create_table(
"stat_daily_staff",
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("user_id", sa.String(length=64), primary_key=True),
sa.Column("msg_sent", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("msg_recv", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_friends", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("active_rooms", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
if not insp.has_table("sensitive_word"):
op.create_table(
"sensitive_word",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("word", sa.String(length=64), nullable=False, unique=True),
sa.Column("category", sa.String(length=32), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("min_len", sa.Integer(), nullable=False, server_default=sa.text("2")),
sa.Column("exclude_words", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
if not insp.has_table("sensitive_hit"):
op.create_table(
"sensitive_hit",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"word_id",
sa.Integer(),
sa.ForeignKey("sensitive_word.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("msg_id", sa.String(length=128), nullable=False),
sa.Column("channel", sa.String(length=16), nullable=False),
sa.Column("owner_account_id", sa.String(length=128), nullable=True),
sa.Column("talker_id", sa.String(length=128), nullable=True),
sa.Column("room_id", sa.String(length=128), nullable=True),
sa.Column("ts", sa.BigInteger(), nullable=True),
sa.Column("snippet", sa.String(length=255), nullable=True),
sa.Column("disposition", sa.String(length=16), nullable=False, server_default="pending"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("word_id", "msg_id", name="uq_sensitive_hit_word_msg"),
)
# 索引守卫独立于建表:MySQL DDL 逐句自动提交,进程死在 create_table 与
# create_index 之间的话,重跑会因表已存在而永远跳过索引。
if not _has_index(bind, "sensitive_hit", "ix_sensitive_hit_disp_ts"):
op.create_index("ix_sensitive_hit_disp_ts", "sensitive_hit", ["disposition", "ts"])
if not insp.has_table("sensitive_scan_state"):
op.create_table(
"sensitive_scan_state",
sa.Column("entity", sa.String(length=32), primary_key=True),
sa.Column("last_fetched_at", sa.DateTime(), nullable=True),
sa.Column("last_run_at", sa.DateTime(), nullable=True),
sa.Column("scanned", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("hits", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
if not _has_index(bind, "message", "ix_message_fetched_at"):
op.create_index("ix_message_fetched_at", "message", ["fetched_at"])
def downgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if _has_index(bind, "message", "ix_message_fetched_at"):
op.drop_index("ix_message_fetched_at", table_name="message")
for table in ("sensitive_scan_state", "sensitive_hit", "sensitive_word", "stat_daily_staff"):
if insp.has_table(table):
op.drop_table(table)
+5 -1
View File
@@ -47,8 +47,12 @@ echo "$(date) --- 10/10 Media download (audio+images) ---"
$PY scripts/scrape_via_page.py --entity media
# 11:会话日汇总全量重建(夜间自愈:绕过 upsert 钩子的写入由它纠正;
# sessions/staff-chat-stats 读路径依赖此表,2026-09-03)
echo "$(date) --- 11/11 Daily-stats rebuild ---"
echo "$(date) --- 11/12 Daily-stats rebuild ---"
$PY -m app.sync.cli daily-stats
# 12:员工×日汇总全量重建(rollup 层随 msg_daily_stats 重建同步自愈;
# A1 员工指标读路径依赖此表,2026-09-04)
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
$PY -m app.sync.cli staff-daily-stats
echo "=============================================="
echo "$(date) === FULL RE-SYNC COMPLETE ==="
+197
View File
@@ -0,0 +1,197 @@
"""/api/analytics/health + /overview:E0-lite 与 A1 冒烟。"""
from __future__ import annotations
import datetime as dt
from app.analytics import metrics
from app.db.models import Customer, MsgDailyStats, StatDailyStaff, WechatAccount
# naive:updated_at 列是无时区 DateTime;fromisoformat 构造不触发 DTZ 检查
_SEEN = "2026-09-04T12:00:00"
# overview 的 today_key 是真实今天——固定 day 键的 seed 必须 monkeypatch
_TODAY = 20260904
async def test_health_empty(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.get("/api/analytics/health")
assert r.status_code == 200
body = r.json()
assert body["data_as_of"] is None
assert len(body["entities"]) == 6
assert all(e["last_seen"] is None for e in body["entities"])
async def test_health_with_stats(make_client):
async def seed(session):
session.add(
MsgDailyStats(
channel="wechat",
owner_account_id="wx1",
peer_kind="friend",
peer_id="t1",
day=20260904,
send_cnt=1,
recv_cnt=2,
last_ts=1756900000000,
last_is_send=False,
)
)
session.add(
Customer(
friend_id="f1",
channel="wechat",
updated_at=dt.datetime.fromisoformat(_SEEN),
)
)
await session.commit()
client = await make_client(seed)
r = await client.get("/api/analytics/health")
assert r.status_code == 200
assert r.json()["data_as_of"] == 1756900000000
entities = {e["table"]: e["last_seen"] for e in r.json()["entities"]}
# 前端契约:last_seen 必须是 ISO 字符串(fromisoformat 可解析)
assert dt.datetime.fromisoformat(entities["customer"]) == dt.datetime.fromisoformat(
_SEEN
)
async def test_overview_endpoint(make_client, monkeypatch):
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=_TODAY,
send_cnt=4, recv_cnt=1, last_ts=1788500000000,
last_is_send=True))
await session.commit()
client = await make_client(seed)
r = await client.get(
"/api/analytics/overview", params={"channel": "wechat", "scope": "today"}
)
assert r.status_code == 200
body = r.json()
assert body["cards"]["msg_total"] == 5
assert isinstance(body["trend"], list) and body["trend"]
# scope 非法 → 422
r2 = await client.get(
"/api/analytics/overview", params={"channel": "wechat", "scope": "year"}
)
assert r2.status_code == 422
# channel 非法 → 422(同一 Literal 校验路径)
r3 = await client.get(
"/api/analytics/overview", params={"channel": "sms", "scope": "today"}
)
assert r3.status_code == 422
async def test_overview_unmapped_accounts(make_client, monkeypatch):
"""控制器追加:cards 带 unmapped_accounts 计数(spec §8「未映射账号按账号计并标注」的标注通道)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
# wx2 无账号表记录 → 未映射
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=_TODAY,
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
last_is_send=True))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="t2", day=_TODAY,
send_cnt=1, recv_cnt=0, last_ts=1788501000000,
last_is_send=True))
await session.commit()
client = await make_client(seed)
r = await client.get(
"/api/analytics/overview", params={"channel": "wechat", "scope": "today"}
)
body = r.json()
assert body["cards"]["active_staff"] == 2 # u1 + wx2(按账号计)
assert body["cards"]["unmapped_accounts"] == 1 # wx2 标注
async def test_overview_unmapped_rollup(make_client, monkeypatch):
"""rollup 路径未映射口径与回退路径一致:当窗 user_id 不在映射值集 → 未映射。
recompute 以 amap.get(owner, owner) 落表,映射不上的 owner 以原值进
stat_daily_staff(user_id=wx2);account_map.values() 只含在册员工 user_id,
wx2 落在值集之外 → 被识别并标注。
"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
session.add(StatDailyStaff(day=_TODAY, channel="wechat", user_id="u1",
msg_sent=2, msg_recv=0,
active_friends=1, active_rooms=0))
session.add(StatDailyStaff(day=_TODAY, channel="wechat", user_id="wx2",
msg_sent=1, msg_recv=0,
active_friends=1, active_rooms=0))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=_TODAY,
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
last_is_send=True))
await session.commit()
client = await make_client(seed)
r = await client.get(
"/api/analytics/overview", params={"channel": "wechat", "scope": "today"}
)
body = r.json()
assert body["cards"]["active_staff"] == 2
assert body["cards"]["unmapped_accounts"] == 1
async def test_overview_custom_scope_api(make_client, monkeypatch):
"""C1 自定义日期:正常窗 200 + customer_assets 恒在;缺参/start>end/
跨度超 366 天/非法日历 全 422;闰年整 366 天放行。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260815,
send_cnt=10, recv_cnt=0, last_ts=1786000000000,
last_is_send=True))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/analytics/overview", params={
"channel": "wechat", "scope": "custom",
"start": 20260801, "end": 20260827,
})
assert r.status_code == 200
body = r.json()
assert body["scope"] == "custom"
assert len(body["days"]) == 27
assert body["cards"]["msg_total"] == 10
# B1 客户资产总览恒在(全库快照,不随 scope)
assert body["customer_assets"]["total"] >= 0
# 缺 start/end → 422(不让静默落到空窗)
r2 = await client.get("/api/analytics/overview",
params={"scope": "custom"})
assert r2.status_code == 422
# start > end → 422
r3 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20260827, "end": 20260801})
assert r3.status_code == 422
# 跨度 367 天 → 422(2024-01-01..2025-01-01 含端点 367 天)
r4 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20240101, "end": 20250101})
assert r4.status_code == 422
# 非法日历(2 月 30)→ 422——Query ge/le 只拦数值范围不拦日历
r5 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20260230, "end": 20260301})
assert r5.status_code == 422
# 闰年整年 366 天(含端点)→ 放行
r6 = await client.get("/api/analytics/overview", params={
"scope": "custom", "start": 20240101, "end": 20241231})
assert r6.status_code == 200
assert len(r6.json()["days"]) == 366
@@ -0,0 +1,342 @@
"""A3 三分法流失 + B2 该回未回。"""
from __future__ import annotations
import datetime as dt
import pytest
from app.analytics import metrics
from app.db.models import Customer, Message, MsgDailyStats, PhoneCall, Staff
_NOW = 1756950000000 # 相对时间基准(绝对日期无关紧要,断言全部相对此值自洽)
def _cust(friend_id, *, last_chat=None, user_id="u1", delete=0, block=None):
return Customer(friend_id=friend_id, channel="wechat",
owner_account_id="wx1", user_id=user_id,
last_chat_time=last_chat, delete_flag=delete, is_block=block)
async def _seed_churn(session):
now = _NOW
day_ms = 86_400_000
session.add(Staff(user_id="u9", name="离职的", is_on_job=False))
session.add(Staff(user_id="u1", name="在职的", is_on_job=True))
# 真流失:45 天没聊 + 历史消息 ≥5
session.add(_cust("c_real", last_chat=now - 45 * day_ms))
# 历史消息不足 5 → 不算真流失
session.add(_cust("c_few", last_chat=now - 45 * day_ms))
# 只沉默 10 天 → 不入 30 档
session.add(_cust("c_recent", last_chat=now - 10 * day_ms))
# 从未激活
session.add(_cust("c_null", last_chat=None))
# 待交接:归属离职员工
session.add(_cust("c_hand", user_id="u9", last_chat=now - 3 * day_ms))
# 已删好友 → 全部剔除
session.add(_cust("c_del", last_chat=now - 45 * day_ms, delete=1))
# 历史消息量:real=6(≥5),few=2
for peer, n in (("c_real", 6), ("c_few", 2)):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=peer, day=20260601,
send_cnt=n, recv_cnt=0, last_ts=now - 50 * day_ms,
last_is_send=True))
await session.commit()
async def test_churn_real_tier30(make_client, monkeypatch):
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
await _seed_churn(session)
page = await metrics.churn(session, channel="wechat", kind="real",
tier=30, min_msgs=5, page=1, page_size=20)
ids = [r["friend_id"] for r in page["items"]]
assert "c_real" in ids and "c_few" not in ids
assert "c_recent" not in ids and "c_del" not in ids
real = next(r for r in page["items"] if r["friend_id"] == "c_real")
assert real["hist_msgs"] == 6 and real["active_days"] == 1
await make_client(seed)
async def test_churn_inactive_and_handover(make_client, monkeypatch):
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
await _seed_churn(session)
p1 = await metrics.churn(session, channel="wechat", kind="inactive",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in p1["items"]} == {"c_null"}
p2 = await metrics.churn(session, channel="wechat", kind="handover",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in p2["items"]} == {"c_hand"}
await make_client(seed)
async def test_churn_endpoint_422(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.get("/api/analytics/churn", params={"tier": 45})
assert r.status_code == 422
r2 = await client.get("/api/analytics/churn", params={"channel": "sms"})
assert r2.status_code == 422
async def test_churn_tier_membership_and_min_msgs_boundary(make_client, monkeypatch):
"""tier60/90 会员资格([tier, next) 区间)+ min_msgs 恰等阈值的包含边界。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
day_ms = 86_400_000
async def seed(session):
# 沉默 75 天:∈[60,90) → tier60 含、tier30 不含
session.add(_cust("c75", last_chat=_NOW - 75 * day_ms))
# 沉默 100 天:≥90 → tier90 含、tier60 不含
session.add(_cust("c100", last_chat=_NOW - 100 * day_ms))
# 历史消息恰好 5 条(=min_msgs,含边界)+ 沉默 45 天 → 入 tier30
session.add(_cust("c5", last_chat=_NOW - 45 * day_ms))
for peer, n in (("c75", 6), ("c100", 6), ("c5", 5)):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=peer, day=20260601,
send_cnt=n, recv_cnt=0,
last_ts=_NOW - 110 * day_ms,
last_is_send=True))
await session.commit()
t30 = await metrics.churn(session, channel="wechat", kind="real",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in t30["items"]} == {"c5"}
t60 = await metrics.churn(session, channel="wechat", kind="real",
tier=60, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in t60["items"]} == {"c75"}
t90 = await metrics.churn(session, channel="wechat", kind="real",
tier=90, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in t90["items"]} == {"c100"}
await make_client(seed)
async def test_churn_handover_excludes_null_user(make_client, monkeypatch):
"""user_id NULL(未映射归属)的客户不进待交接名单。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
session.add(Staff(user_id="u9", name="离职的", is_on_job=False))
session.add(_cust("c_hand", user_id="u9"))
session.add(_cust("c_nouser", user_id=None))
await session.commit()
p = await metrics.churn(session, channel="wechat", kind="handover",
tier=30, min_msgs=5, page=1, page_size=20)
assert {r["friend_id"] for r in p["items"]} == {"c_hand"}
await make_client(seed)
def test_next_tier_bad_tier_raises_value_error():
"""非法 tier 报 ValueError(与 churn 的 kind 分支报错类型一致)。"""
with pytest.raises(ValueError):
metrics._next_tier(45)
assert metrics._next_tier(90) == 10_000 # 90 档=≥90 的开区间上限
async def _seed_unreplied(session):
now = _NOW
h3 = 3 * 3600 * 1000
# 该回未回:t1 客户最后一条(is_send=False,text)3h 前
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id="t1m", channel="wechat", owner_account_id="wx1",
talker_id="t1", timestamp=now - h3, is_send=False,
type="text", text="在吗,方案发我一下",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
# 员工已回 → 不在清单
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t2", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=True))
# 客户最后一条是 unknown 类型 → 无效,不出清单
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t3", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id="t3m", channel="wechat", owner_account_id="wx1",
talker_id="t3", timestamp=now - h3, is_send=False,
type="unknown", text=None,
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
# 尾部 0.5h < 2h 阈值 → 不出
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t4", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - 1800_000,
last_is_send=False))
session.add(Message(msg_id="t4m", channel="wechat", owner_account_id="wx1",
talker_id="t4", timestamp=now - 1800_000, is_send=False,
type="text", text="刚发的",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
# 通话豁免:最后消息 5h 前没回,但 4h 前员工打过电话 → 视为已回应
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t5", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - 5 * 3600 * 1000,
last_is_send=False))
session.add(Message(msg_id="t5m", channel="wechat", owner_account_id="wx1",
talker_id="t5", timestamp=now - 5 * 3600 * 1000,
is_send=False, type="text", text="hello",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
session.add(_cust("t5", last_chat=now, user_id="u1"))
(await session.get(Customer, "t5")).phone = "13800000005"
session.add(PhoneCall(call_id="pc1", channel="voice_call",
user_id="u1", call_number="13800000005",
start_call_time=now - 4 * 3600 * 1000))
# 已删好友 → 剔除
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t6", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id="t6m", channel="wechat", owner_account_id="wx1",
talker_id="t6", timestamp=now - h3, is_send=False,
type="text", text="已删我的",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
session.add(_cust("t6", last_chat=now - h3, delete=1))
await session.commit()
async def test_unreplied(make_client, monkeypatch):
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
await _seed_unreplied(session)
out = await metrics.unreplied(session, channel="wechat", hours=2,
page=1, page_size=20)
ids = [r["peer_id"] for r in out["items"]]
assert ids == ["t1"] # t2已回/t3无效类型/t4未超时/t5通话豁免/t6已删
row = out["items"][0]
assert row["wait_ms"] >= 3 * 3600 * 1000
assert "方案" in row["preview"]
await make_client(seed)
async def test_unreplied_call_not_by_owner_still_exempts(make_client, monkeypatch):
"""通话豁免语义=『该客户被回应过』——不强制通话是同一员工打的(spec:尾部=max(最后消息,最后通话))。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
now = _NOW
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t7", day=20260904,
send_cnt=1, recv_cnt=1, last_ts=now - 6 * 3600 * 1000,
last_is_send=False))
session.add(Message(msg_id="t7m", channel="wechat", owner_account_id="wx1",
talker_id="t7", timestamp=now - 6 * 3600 * 1000,
is_send=False, type="text", text="hi",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
session.add(_cust("t7", last_chat=now))
(await session.get(Customer, "t7")).phone = "13900000007"
# 通话是别的员工(user_id=u_other)打的,晚于最后消息
session.add(PhoneCall(call_id="pc2", channel="voice_call",
user_id="u_other", call_number="13900000007",
start_call_time=now - 2 * 3600 * 1000))
out = await metrics.unreplied(session, channel="wechat", hours=2,
page=1, page_size=20)
assert all(r["peer_id"] != "t7" for r in out["items"])
await make_client(seed)
async def test_unreplied_latest_day_row_wins(make_client, monkeypatch):
"""资格判定看「最新日行」:更早的未回日不能复活。
前日客户未回(已远超 2h),但今日员工回了(最新日行 last_is_send=True,
会话尾部=员工)→ 不在清单。若候选过滤先于 row_number(在窗口子查询内
过滤),旧日行会被复活成候选——此测试钉死正确语义。
"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
now = _NOW
h3 = 3 * 3600 * 1000
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t8", day=20260903,
send_cnt=0, recv_cnt=3, last_ts=now - 30 * 3600 * 1000,
last_is_send=False))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t8", day=20260904,
send_cnt=5, recv_cnt=0, last_ts=now - h3,
last_is_send=True))
session.add(Message(msg_id="t8m", channel="wechat", owner_account_id="wx1",
talker_id="t8", timestamp=now - h3, is_send=True,
type="text", text="已经回了",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
out = await metrics.unreplied(session, channel="wechat", hours=2,
page=1, page_size=20)
assert all(r["peer_id"] != "t8" for r in out["items"])
await make_client(seed)
async def test_unreplied_endpoint_422(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.get("/api/analytics/unreplied", params={"hours": 0})
assert r.status_code == 422
r2 = await client.get("/api/analytics/unreplied", params={"channel": "sms"})
assert r2.status_code == 422
async def test_unreplied_stats_message_drift_staff_tail_excluded(
make_client, monkeypatch
):
"""stats/message 漂移守卫:统计行客户尾向超时,但 message 真实尾部是员工回复 → 不在清单。
写钩子吞重算失败、夜间重建兜底前 stats 可能滞后(一等状态);决赛圈以
message 实尾为准——is_send=True=员工尾部=定义上非待回复(is_send=None
保留=与统计同向保守)。
"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
now = _NOW
h3 = 3 * 3600 * 1000
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t9", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
# 真实尾部:员工 1h 前的回复(stats 行漂移,仍停在客户尾向 3h 前)
session.add(Message(msg_id="t9m", channel="wechat", owner_account_id="wx1",
talker_id="t9", timestamp=now - 3600 * 1000,
is_send=True, type="text", text="已经回了哈",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
out = await metrics.unreplied(session, channel="wechat", hours=2,
page=1, page_size=20)
assert all(r["peer_id"] != "t9" for r in out["items"])
await make_client(seed)
async def test_unreplied_large_candidate_branch(make_client, monkeypatch):
"""候选 > _FINALIZE_ALL_MAX:按候选分页,total=候选数;决赛圈过滤后本页可少几行。
501 个候选只给 bulk000 一条 message 行 → 第 1 页 items 仅 1 行(而非
page_size 100),锁定「total=候选数、items 可能少几行」的文档口径;
同时证明大候选分支的决赛圈点查真实执行(有效行保留)。
"""
monkeypatch.setattr(metrics, "_now_ms", lambda: _NOW)
async def seed(session):
now = _NOW
h3 = 3 * 3600 * 1000
for i in range(metrics._FINALIZE_ALL_MAX + 1):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id=f"bulk{i:03d}",
day=20260904, send_cnt=5, recv_cnt=3,
last_ts=now - h3, last_is_send=False))
# ts 全等 → 按 peer asc 决胜,bulk000 排第 1 页首行
session.add(Message(msg_id="b0m", channel="wechat", owner_account_id="wx1",
talker_id="bulk000", timestamp=now - h3,
is_send=False, type="text", text="在吗",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
await session.commit()
out = await metrics.unreplied(session, channel="wechat", hours=2,
page=1, page_size=100)
assert out["total"] == metrics._FINALIZE_ALL_MAX + 1 # total=候选数
ids = [r["peer_id"] for r in out["items"]]
assert ids == ["bulk000"] # 无 message 行的候选决赛圈全滤=口径内少几行
await make_client(seed)
@@ -0,0 +1,40 @@
"""新表建模冒烟:create_all 建表 + 唯一约束生效(迁移幂等由 alembic 链保证)。"""
from __future__ import annotations
import pytest
from sqlalchemy.exc import IntegrityError
from app.db.models import SensitiveHit, SensitiveWord
async def test_sensitive_word_unique(make_client):
async def seed(session):
session.add(SensitiveWord(word="私单", category="飞单"))
await session.flush()
# 同词二插必须撞 UNIQUE(word)
session.add(SensitiveWord(word="私单"))
with pytest.raises(IntegrityError):
await session.flush()
client = await make_client(seed)
r = await client.get("/api/health")
assert r.status_code == 200 # app boots with new models
async def test_hit_unique_word_msg(make_client):
async def seed(session):
w = SensitiveWord(word="私单")
session.add(w)
await session.flush()
session.add(
SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat")
)
session.add(
SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat")
)
with pytest.raises(IntegrityError):
await session.flush()
client = await make_client(seed)
r = await client.get("/api/health")
assert r.status_code == 200 # app boots with new models
@@ -0,0 +1,579 @@
"""A1 总览指标:卡片/首沟通口径/环比同期对齐(仅周月)/异常警示。"""
from __future__ import annotations
import datetime as dt
import pytest
from app.analytics import metrics
from app.db.models import (
Customer,
Message,
MsgDailyStats,
PhoneCall,
SensitiveHit,
SensitiveWord,
Staff,
StatDailyStaff,
WechatAccount,
)
_TODAY = 20260904 # 测试统一用固定 day 键,monkeypatch today_key
async def _seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
# 昨日(20260903):u1 90 条;前 7 日均值(20260827-0902):每天 200 → 偏离 -55%>50% 触发
# (阈值是严格 >0.5——正好 -50% 不触发,seed 用 90)
for day, sent in [(20260827, 200), (20260828, 200), (20260829, 200),
(20260830, 200), (20260831, 200), (20260901, 200),
(20260902, 200), (20260903, 90)]:
session.add(StatDailyStaff(day=day, channel="wechat", user_id="u1",
msg_sent=sent, msg_recv=0,
active_friends=5, active_rooms=1))
# 当日 rollup 行(active_staff 卡片读 stat_daily_staff,必须有当日行)
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
msg_sent=15, msg_recv=7,
active_friends=1, active_rooms=1))
# 当日 + 首沟通:t1 当日首现(首沟通=1),t2 早在 20260827 就有(不算)
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
last_is_send=True))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t2", day=20260827,
send_cnt=3, recv_cnt=0, last_ts=1787800000000,
last_is_send=True))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="room", peer_id="r1", day=20260904,
send_cnt=7, recv_cnt=0, last_ts=1788500000000,
last_is_send=True))
# 通话时间戳必须是 2026-09-04 当日(1756950000000 是 2025 年——坑)
session.add(PhoneCall(call_id="c1", channel="voice_call",
start_call_time=1788500000000, call_duration=60))
await session.commit()
async def test_overview_today(make_client, monkeypatch):
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
await _seed(session)
out = await metrics.overview(session, channel="wechat", scope="today")
assert out["cards"]["msg_total"] == 22 # (10+5)+(7+0)
assert out["cards"]["active_customers"] == 1 # peer_kind=friend 去重,群不计
assert out["cards"]["active_staff"] == 1
assert out["cards"]["call_cnt"] == 1
assert out["cards"]["first_contact"] == 1 # t1 全史 MIN(day)=今日;t2 不是
assert "msg_total_delta" not in out["cards"] # 今日无环比
assert "prev_msg_total" not in out["cards"]
await make_client(seed)
async def test_anomaly_yesterday_vs_prev7(make_client, monkeypatch):
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
await _seed(session)
a = await metrics.anomaly(session, channel="wechat")
assert a is not None
assert a["total_delta_pct"] <= -0.5 # 90 vs 均值 200 = -55%(严格 >50% 触发)
staff = {s["user_id"]: s for s in a["staff"]}
assert staff["u1"]["yesterday"] == 90
assert staff["u1"]["prev7_mean"] == 200.0
await make_client(seed)
async def test_overview_fallback_when_rollup_empty(make_client, monkeypatch):
"""stat_daily_staff 空表 → 员工卡片回退实时聚合 msg_daily_stats(部署即用)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=2, recv_cnt=1, last_ts=1788500000000,
last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="today")
assert out["cards"]["active_staff"] == 1
assert out["cards"]["msg_total"] == 3
await make_client(seed)
async def test_anomaly_fallback_multi_account(make_client, monkeypatch):
"""回退路径同人多账号累加:wx1/wx2 同映射 u1,昨日量必须相加而非覆盖。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
session.add(WechatAccount(wx_id="wx2", user_id="u1"))
# 不 seed StatDailyStaff → 走 msg_daily_stats 回退路径。
# 昨日(20260903)各 100(合计 200);prev7 每天各 50(日均 100)→ 偏离 +100% 触发
for day in [20260827, 20260828, 20260829, 20260830,
20260831, 20260901, 20260902, 20260903]:
sent = 100 if day == 20260903 else 50
for wx in ("wx1", "wx2"):
session.add(MsgDailyStats(channel="wechat", owner_account_id=wx,
peer_kind="friend", peer_id="t1", day=day,
send_cnt=sent, recv_cnt=0,
last_ts=1788500000000,
last_is_send=True))
await session.commit()
a = await metrics.anomaly(session, channel="wechat")
assert a is not None
staff = {s["user_id"]: s for s in a["staff"]}
# 修复前:后写账号覆盖前写,u1 昨日只剩 100;修复后两账号累加 = 200
assert staff["u1"]["yesterday"] == 200
assert staff["u1"]["prev7_mean"] == 100.0
await make_client(seed)
def test_prev_scope_days_aligned():
"""环比窗=同期对齐(非滚动紧邻):周各日-7天;月=上月同日短月钳月末。"""
week = metrics.scope_day_keys("week", 20260902) # 周三:本周一..三
assert week == [20260831, 20260901, 20260902]
# 上周周一..上周三(各日-7)——不是跨周末的滚动紧邻窗
assert metrics._prev_scope_days("week", week) == [20260824, 20260825, 20260826]
month = metrics.scope_day_keys("month", 20260902)
assert month == [20260901, 20260902]
assert metrics._prev_scope_days("month", month) == [20260801, 20260802]
# 月末短月钳制:2026 非闰年 3/31→2/28;闰年 2024 3/31→2/29;跨年 1/1→上年 12/1
assert metrics._prev_scope_days("month", [20260331]) == [20260228]
assert metrics._prev_scope_days("month", [20240331]) == [20240229]
assert metrics._prev_scope_days("month", [20260101]) == [20251201]
async def test_overview_trend_days_validation(make_client, monkeypatch):
"""trend_days 越界(1..90 外)直接 ValueError,不静默出奇怪窗口。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
with pytest.raises(ValueError, match="trend_days"):
await metrics.overview(session, channel="wechat", scope="today",
trend_days=0)
with pytest.raises(ValueError, match="trend_days"):
await metrics.overview(session, channel="wechat", scope="today",
trend_days=91)
await make_client(seed)
async def test_week_delta_none_when_prev_week_empty(make_client, monkeypatch):
"""上周边无数据(prev_total=0)→ delta=None,不除零不误报。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=5, recv_cnt=0, last_ts=1788500000000,
last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="week")
assert out["cards"]["prev_msg_total"] == 0
assert out["cards"]["msg_total_delta"] is None
await make_client(seed)
async def test_anomaly_none_when_no_prev_data(make_client, monkeypatch):
"""全员无前 7 日数据(totals_p<=0)→ 异常警示 None(无基数可算)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(StatDailyStaff(day=20260903, channel="wechat", user_id="u1",
msg_sent=100, msg_recv=0,
active_friends=1, active_rooms=0))
await session.commit()
assert await metrics.anomaly(session, channel="wechat") is None
await make_client(seed)
async def test_anomaly_new_staff_detail_and_sort(make_client, monkeypatch):
"""新员工(前7日无量、昨日>0)进明细带 new 标记;明细按昨日量降序。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
for day, sent in [(20260827, 100), (20260828, 100), (20260829, 100),
(20260830, 100), (20260831, 100), (20260901, 100),
(20260902, 100), (20260903, 250)]:
session.add(StatDailyStaff(day=day, channel="wechat", user_id="u1",
msg_sent=sent, msg_recv=0,
active_friends=1, active_rooms=0))
# u2 昨日新出现(前 7 日无量)——修复前从明细里消失
session.add(StatDailyStaff(day=20260903, channel="wechat", user_id="u2",
msg_sent=80, msg_recv=0,
active_friends=1, active_rooms=0))
await session.commit()
a = await metrics.anomaly(session, channel="wechat")
assert a is not None
assert [s["user_id"] for s in a["staff"]] == ["u1", "u2"] # 降序:250 > 80
staff = {s["user_id"]: s for s in a["staff"]}
assert staff["u2"]["new"] is True
assert staff["u2"]["yesterday"] == 80
assert "new" not in staff["u1"] # 老员工不带标记
await make_client(seed)
async def test_staff_ranking(make_client, monkeypatch):
"""员工活跃排行:rollup 供 msg_total;active_* 统一走 msg_daily_stats 去重。
_TODAY=20260904 周五 → 本周 [0831..0904],上周 [0824..0828]。
rollup 的 active_friends 故意 seed 999——若实现误用 rollup 聚合(跨日
SUM/MAX 皆错)会立刻暴露;正确值=窗内 distinct peer(u1: f1+f2)。
"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
week = [20260831, 20260901, 20260902, 20260903, 20260904]
prev_week = [20260824, 20260825, 20260826, 20260827, 20260828]
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u2", user_name="李四"))
session.add(Staff(user_id="u1", name="张三"))
session.add(Staff(user_id="u2", name="李四"))
# rollup:u1 本周 100(5×20),u2 50(5×10);active_* 999=诱饵(不得被采用)
for d in week:
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
msg_sent=20, msg_recv=0,
active_friends=999, active_rooms=999))
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u2",
msg_sent=10, msg_recv=0,
active_friends=1, active_rooms=0))
# u3 仅存在于 rollup(无账号映射/无 staff 行)→ 未映射,名字回退 user_id
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u3",
msg_sent=10, msg_recv=0,
active_friends=0, active_rooms=0))
# 上周 rollup:u1 80(5×16) → msg_delta=(100-80)/80=0.25;u2 无前窗→None
for d in prev_week:
session.add(StatDailyStaff(day=d, channel="wechat", user_id="u1",
msg_sent=16, msg_recv=0,
active_friends=1, active_rooms=0))
# 活跃指标真源:wx1→u1 两 friend(f1/f2 不同日)+一 room;wx2→u2 一 friend
for day, kind, peer, owner in [
(20260831, "friend", "f1", "wx1"), (20260904, "friend", "f2", "wx1"),
(20260902, "room", "r1", "wx1"), (20260903, "friend", "f1", "wx2"),
]:
session.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind=kind, peer_id=peer, day=day,
send_cnt=1, recv_cnt=0,
last_ts=1788500000000, last_is_send=True))
# 通话:u1 一通落窗(1788500000000=2026-09-04 当日);u2 的在 2025 年不计
session.add(PhoneCall(call_id="cr1", channel="voice_call", user_id="u1",
start_call_time=1788500000000, call_duration=60))
session.add(PhoneCall(call_id="cr2", channel="voice_call", user_id="u2",
start_call_time=1756950000000, call_duration=60))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
assert [r["user_id"] for r in out] == ["u1", "u2", "u3"] # msg_total 降序
r1, r2, r3 = out
assert r1["name"] == "张三" and r1["unmapped"] is False
assert r1["msg_total"] == 100 # rollup 聚合
assert r1["active_friends"] == 2 # distinct peer,非 rollup 的 999
assert r1["active_rooms"] == 1
assert r1["call_cnt"] == 1
assert r1["msg_delta"] == 0.25 # (100-80)/80
assert r2["msg_total"] == 50 and r2["active_friends"] == 1
assert r2["active_rooms"] == 0 and r2["call_cnt"] == 0 # 窗外通话不计
assert r2["msg_delta"] is None # 前窗无数据
assert r3["name"] == "u3" and r3["unmapped"] is True
assert r3["msg_total"] == 10 and r3["active_friends"] == 0
await make_client(seed)
async def test_staff_ranking_fallback_when_rollup_empty(make_client, monkeypatch):
"""stat_daily_staff 空表 → msg_total/首响三列回退 msg_daily_stats 聚合(部署即用)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=10, recv_cnt=5, last_ts=1788500000000,
last_is_send=True,
cust_threads=2, threads_answered=1,
fr_sum_ms=60000))
# wx9 无账号行 → 映射不上按 owner 原值计入并标未映射
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id="t2", day=20260904,
send_cnt=2, recv_cnt=0, last_ts=1788500000000,
last_is_send=True,
cust_threads=3, threads_answered=0,
fr_sum_ms=0))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="today")
assert [r["user_id"] for r in out] == ["u1", "wx9"]
assert out[0]["msg_total"] == 15 and out[0]["active_friends"] == 1
assert out[0]["unmapped"] is False
assert out[0]["msg_delta"] is None # 今日 scope 无环比
# 回退路径首响三列:60000/1=60000;回复率 1/2
assert out[0]["fr_avg_ms"] == 60000
assert out[0]["reply_rate"] == 0.5
assert out[1]["msg_total"] == 2 and out[1]["unmapped"] is True
assert out[1]["name"] == "wx9"
# threads>0 answered=0 → 回复率 0.0(有串无回);首响 None(无样本)
assert out[1]["reply_rate"] == 0.0
assert out[1]["fr_avg_ms"] is None
await make_client(seed)
async def test_action_counts(make_client, monkeypatch):
"""预警行动入口:unreplied 走真实路径,churn/sensitive 各计 pending/真流失。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
async def seed(session):
now = 1756950000000
h3 = 3 * 3600 * 1000
# 该回未回 1 条:客户尾向 3h 前 + 有效 text 消息
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id="t1m", channel="wechat", owner_account_id="wx1",
talker_id="t1", timestamp=now - h3, is_send=False,
type="text", text="在吗,方案发我一下",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
# 真流失 1 条:沉默 45 天 + 历史 ≥5
day_ms = 86_400_000
session.add(Customer(friend_id="c_real", channel="wechat",
owner_account_id="wx1", user_id="u1",
last_chat_time=now - 45 * day_ms, delete_flag=0))
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="c_real", day=20260601,
send_cnt=6, recv_cnt=0, last_ts=now - 50 * day_ms,
last_is_send=True))
# 敏感词:1 pending + 1 confirmed(只计 pending)
w = SensitiveWord(word="测试词")
session.add(w)
await session.flush()
session.add(SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="m2", channel="wechat",
disposition="confirmed"))
await session.commit()
out = await metrics.action_counts(session, channel="wechat")
assert out == {"unreplied_2h": 1, "churn_real_30d": 1,
"sensitive_pending": 1}
await make_client(seed)
async def test_overview_all_channel(make_client, monkeypatch):
"""看板 v2 全平台口径(channel=None):卡片=两渠道合计、by_channel 拆分、
trend=合计、trend_by_channel 双序列(仅 all 时出现;单渠道不加键)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
await _seed(session) # wechat 当日:t1 friend 15 + r1 room 7 = 22
# whatsapp 当日:w1 friend 6(全史首现=今日,首沟通落窗)
session.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa1",
peer_kind="friend", peer_id="w1", day=20260904,
send_cnt=4, recv_cnt=2, last_ts=1788500000000,
last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel=None, scope="today")
assert out["channel"] == "all"
# 合计卡片(peer 跨渠道不碰撞:活跃客户/首沟通直接相加)
assert out["cards"]["msg_total"] == 28 # 22 + 6
assert out["cards"]["active_customers"] == 2 # t1 + w1
assert out["cards"]["first_contact"] == 2
# 拆分小字数据源
assert out["by_channel"]["wechat"] == {
"msg_total": 22, "active_customers": 1, "first_contact": 1}
assert out["by_channel"]["whatsapp"] == {
"msg_total": 6, "active_customers": 1, "first_contact": 1}
# 趋势:合计单系列保持可用 + 双渠道序列(30 天窗末日有值)
assert len(out["trend"]) == 30
assert out["trend"][-1]["msg"] == 28
assert out["trend"][-1]["active_customers"] == 2
tbc = out["trend_by_channel"]
assert len(tbc["wechat"]) == 30 and len(tbc["whatsapp"]) == 30
assert tbc["wechat"][-1]["msg"] == 22
assert tbc["whatsapp"][-1]["msg"] == 6
# 通话量无渠道语义不拆:渠道序列 call_cnt 恒 0,总量走 trend
assert tbc["wechat"][-1]["call_cnt"] == 0
assert out["trend"][-1]["call_cnt"] == 1
# 单渠道:不加 by_channel/trend_by_channel 键(既有行为不破坏)
single = await metrics.overview(session, channel="wechat", scope="today")
assert single["cards"]["msg_total"] == 22
assert "by_channel" not in single
assert "trend_by_channel" not in single
await make_client(seed)
async def test_action_counts_all(make_client, monkeypatch):
"""all 口径行动计数:两渠道各一条未回 → unreplied_2h=2;sensitive_pending
同样跨渠道合计(只计 pending)。"""
monkeypatch.setattr(metrics, "_now_ms", lambda: 1756950000000)
async def seed(session):
now = 1756950000000
h3 = 3 * 3600 * 1000
for i, (ch, owner, peer) in enumerate(
[("wechat", "wx1", "t1"), ("whatsapp", "wa1", "w1")]
):
session.add(MsgDailyStats(channel=ch, owner_account_id=owner,
peer_kind="friend", peer_id=peer, day=20260904,
send_cnt=5, recv_cnt=3, last_ts=now - h3,
last_is_send=False))
session.add(Message(msg_id=f"m{i}", channel=ch, owner_account_id=owner,
talker_id=peer, timestamp=now - h3, is_send=False,
type="text", text="在吗",
fetched_at=dt.datetime(2026, 9, 4, 12, 0)))
w = SensitiveWord(word="测试词")
session.add(w)
await session.flush()
session.add(SensitiveHit(word_id=w.id, msg_id="s1", channel="wechat",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="s2", channel="whatsapp",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="s3", channel="whatsapp",
disposition="confirmed"))
await session.commit()
out = await metrics.action_counts(session, channel=None)
assert out == {"unreplied_2h": 2, "churn_real_30d": 0,
"sensitive_pending": 2}
await make_client(seed)
# ── 批2(2026-09-07):排行五新列 / B1 客户资产 / C1 自定义日期 ──────────────
async def test_staff_ranking_batch2_columns(make_client, monkeypatch):
"""排行五新列:首响均值+回复率(rollup 三列)、首沟通归属、通话时长+
接通率、删粉/拉黑(全库存量按 user_id 归属,不随窗)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u2", user_name="李四"))
session.add(Staff(user_id="u1", name="张三"))
session.add(Staff(user_id="u2", name="李四"))
# rollup 当窗行:u1 threads=10 answered=4 fr=1_200_000(→300000ms/0.4);
# u2 三列缺省 0 → 两率皆 None
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u1",
msg_sent=20, msg_recv=0, active_friends=1,
active_rooms=0, cust_threads=10,
threads_answered=4, fr_sum_ms=1_200_000))
session.add(StatDailyStaff(day=20260904, channel="wechat", user_id="u2",
msg_sent=5, msg_recv=0, active_friends=1,
active_rooms=0))
# 首沟通:f1 全史 MIN=20260831 落窗(计);f2 MIN=20260801 窗外(不计);
# r1 是 room 不计;f3 归 wx2→u2(计)
for day, kind, peer, owner in [
(20260831, "friend", "f1", "wx1"),
(20260902, "friend", "f1", "wx1"), # 同 peer 次日不影响 MIN
(20260801, "friend", "f2", "wx1"),
(20260903, "room", "r1", "wx1"),
(20260903, "friend", "f3", "wx2"),
]:
session.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
peer_kind=kind, peer_id=peer, day=day,
send_cnt=1, recv_cnt=0,
last_ts=1788500000000, last_is_send=True))
# 通话:u1 三通落窗(60/0/120s → 180s、2/3 接通);u2 通话在 2025 年
for cid, uid, ts, dur in [
("c1", "u1", 1788500000000, 60),
("c2", "u1", 1788500001000, 0),
("c3", "u1", 1788500002000, 120),
("c4", "u2", 1756950000000, 60),
]:
session.add(PhoneCall(call_id=cid, channel="voice_call", user_id=uid,
start_call_time=ts, call_duration=dur))
# 客户归属存量:u1 删 1 拉黑 1;u2 双属性各 1;无归属(user_id NULL)跳过
session.add(Customer(friend_id="d1", channel="wechat", user_id="u1",
delete_flag=1))
session.add(Customer(friend_id="b1", channel="wechat", user_id="u1",
is_block=True))
session.add(Customer(friend_id="x1", channel="wechat", user_id="u2",
delete_flag=1, is_block=True))
session.add(Customer(friend_id="n1", channel="wechat", user_id=None))
await session.commit()
out = await metrics.staff_ranking(session, channel="wechat", scope="week")
by = {r["user_id"]: r for r in out}
r1, r2 = by["u1"], by["u2"]
assert r1["fr_avg_ms"] == 300000 # 1_200_000 / 4
assert r1["reply_rate"] == 0.4
assert r1["first_contact_cnt"] == 1 # f1;f2 窗外、r1 群不计
assert r1["call_seconds"] == 180
assert r1["answer_rate"] == round(2 / 3, 4)
assert r1["deleted_friends"] == 1 and r1["blocked_friends"] == 1
assert r2["fr_avg_ms"] is None and r2["reply_rate"] is None
assert r2["first_contact_cnt"] == 1 # f3
assert r2["call_seconds"] == 0 and r2["answer_rate"] is None # 窗外通话
assert r2["deleted_friends"] == 1 and r2["blocked_friends"] == 1
await make_client(seed)
async def test_customer_assets_layers(make_client, monkeypatch):
"""B1:剔除已删后按 last_chat_time 分层(从未激活单列 never);渠道计数;
已删单列。全库口径——channel=wechat 也不滤 whatsapp 客户。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
monkeypatch.setattr(metrics, "_now_ms", lambda: 1788550000000)
async def seed(session):
now = 1788550000000
day_ms = 86_400_000
rows = [
("a1", "wechat", now - 5 * day_ms, 0), # active
("a2", "whatsapp", now - 29 * day_ms, 0), # active(边界内)
("s1", "whatsapp", now - 45 * day_ms, 0), # silent
("p1", "wechat", now - 91 * day_ms, 0), # sleeping
("n1", "whatsapp", None, 0), # never
("n2", "wechat", None, None), # never(delete_flag NULL 同未删)
]
for fid, ch, lt, df in rows:
session.add(Customer(friend_id=fid, channel=ch, last_chat_time=lt,
delete_flag=df))
# 已删不进分层、单列(deleted)
session.add(Customer(friend_id="d1", channel="wechat",
last_chat_time=now - 1 * day_ms, delete_flag=1))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="today")
assert out["customer_assets"] == {
"total": 6, "active30": 2, "silent30_90": 1, "sleeping90p": 1,
"never": 2, "wechat": 3, "whatsapp": 3, "deleted": 1,
}
await make_client(seed)
async def test_overview_custom_scope(make_client, monkeypatch):
"""C1:custom days=[start..end](含端点);环比=等长紧邻前窗滚动(各日 -N);
metrics 直调的参数防御(缺参/逆序/超 366 天)。"""
monkeypatch.setattr(metrics, "today_key", lambda: _TODAY)
async def seed(session):
# 窗内 20260801..20260827:t1 10 条;紧邻前窗 20260705..20260731:t1 20 条
for day, sent in [(20260815, 10), (20260710, 20)]:
session.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=day,
send_cnt=sent, recv_cnt=0,
last_ts=1786000000000, last_is_send=True))
await session.commit()
out = await metrics.overview(session, channel="wechat", scope="custom",
start=20260801, end=20260827)
assert out["days"][0] == 20260801 and out["days"][-1] == 20260827
assert len(out["days"]) == 27
assert out["cards"]["msg_total"] == 10
assert out["cards"]["prev_msg_total"] == 20 # 紧邻前窗命中 07-10 行
assert out["cards"]["msg_total_delta"] == -0.5
# 排行吃同一窗(回退路径):wx1 未映射按原值出列
assert out["staff_ranking"][0]["user_id"] == "wx1"
assert out["staff_ranking"][0]["msg_total"] == 10
# 紧邻前窗逐日 -27:08-01→07-05、08-27→07-31
prev = metrics._prev_scope_days("custom", out["days"])
assert (len(prev), prev[0], prev[-1]) == (27, 20260705, 20260731)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom")
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20260827, end=20260801)
with pytest.raises(ValueError):
await metrics.overview(session, channel="wechat", scope="custom",
start=20240101, end=20250101) # 367 天
await make_client(seed)
+159
View File
@@ -476,3 +476,162 @@ async def test_msg_type_and_q_combine(make_client):
assert r.status_code == 200
body = r.json()
assert [it["msg_id"] for it in body["items"]] == ["ty_transfer"]
# ---------------------------------------------------------------------------
# order=desc + before_ts/before_id 游标(2026-09-07 深分页超时修复:
# 大对话 ASC 末页 OFFSET 数万行逐条回表 2.5s+,群聊 filesoort 10s+ 撞前端
# 15s 超时 ECONNABORTED。DESC 首页+游标上翻每页恒 O(page_size))
# ---------------------------------------------------------------------------
async def _seed_desc(session: AsyncSession) -> None:
"""t_a 会话 5 条,含两组同 timestamp 平手——验证 (timestamp, msg_id) 全序。
ASC 全序 = a1,a2,a3,a4,a5;DESC 全序 = a5,a4,a3,a2,a1。
"""
rows = [("a1", 1000), ("a2", 1000), ("a3", 2000), ("a4", 3000), ("a5", 3000)]
session.add_all(
[
Message(
msg_id=mid,
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
type="text",
type_raw=1,
is_send=True,
timestamp=ts,
)
for mid, ts in rows
]
)
await session.commit()
async def test_order_desc_returns_newest_first(make_client):
"""order=desc → 按 (timestamp, msg_id) 降序,平手时 msg_id 大者在前。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages", params={"talker_id": "t_a", "order": "desc"}
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 5
assert [it["msg_id"] for it in body["items"]] == ["a5", "a4", "a3", "a2", "a1"]
async def test_order_desc_pagination_from_newest(make_client):
"""desc 的 page 从最新端数:page1=最新 slice(前端首屏即此页)。"""
client = await make_client(_seed_desc)
r1 = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page": 1, "page_size": 2},
)
assert [it["msg_id"] for it in r1.json()["items"]] == ["a5", "a4"]
r2 = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page": 2, "page_size": 2},
)
assert [it["msg_id"] for it in r2.json()["items"]] == ["a3", "a2"]
async def test_order_invalid_rejected_422(make_client):
client = await make_client(_seed_desc)
r = await client.get("/api/messages", params={"order": "bogus"})
assert r.status_code == 422
async def test_before_cursor_returns_preceding_slice(make_client):
"""before=(3000,a4) → 严格早于该游标的消息按 desc 取,平手边界不含游标本行。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={
"talker_id": "t_a",
"order": "desc",
"page_size": 2,
"before_ts": 3000,
"before_id": "a4",
},
)
assert r.status_code == 200
body = r.json()
# 游标后剩余 a1/a2/a3;desc 前 2 条
assert [it["msg_id"] for it in body["items"]] == ["a3", "a2"]
assert body["total"] == 3
async def test_before_cursor_tie_boundary_exclusive(make_client):
"""before=(3000,a5):同 ts=3000 的 a4 因 msg_id<a5 仍在界内。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "before_ts": 3000, "before_id": "a5"},
)
body = r.json()
assert [it["msg_id"] for it in body["items"]] == ["a4", "a3", "a2", "a1"]
assert body["total"] == 4
async def test_before_requires_both_params_422(make_client):
"""游标两参必须成对:单传任一 422,防止静默忽略半个游标查错页。"""
client = await make_client(_seed_desc)
r1 = await client.get("/api/messages", params={"before_ts": 3000})
assert r1.status_code == 422
r2 = await client.get("/api/messages", params={"before_id": "a5"})
assert r2.status_code == 422
async def test_before_cursor_with_window_filter(make_client):
"""游标与 start/end 过滤叠加(loadOlder 带日期窗场景)。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "start": 1500},
)
assert [it["msg_id"] for it in r.json()["items"]] == ["a5", "a4", "a3"]
r2 = await client.get(
"/api/messages",
params={
"talker_id": "t_a",
"order": "desc",
"start": 1500,
"before_ts": 2000,
"before_id": "a3",
},
)
body = r2.json()
assert body["items"] == []
assert body["total"] == 0
async def test_load_older_flow_reproduces_full_asc_order(make_client):
"""前端新流程端到端不变式:desc 首页反转 + 逐游标上翻反转,拼接 = 全量 ASC。
这正是 ChatMain/ChatTimeline 的首屏+「加载更多」序列——顺序若与旧
ASC 分页不一致,时间线就会错乱。
"""
client = await make_client(_seed_desc)
page_size = 2
async def fetch(**extra):
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page_size": page_size, **extra},
)
assert r.status_code == 200
return r.json()
displayed: list[str] = []
body = await fetch()
# 会话总数以首屏(无游标)响应为准;游标响应的 total 是「游标后剩余数」
conv_total = body["total"]
displayed = [it["msg_id"] for it in body["items"][::-1]]
while len(displayed) < conv_total:
oldest = body["items"][-1]
body = await fetch(before_ts=oldest["timestamp"], before_id=oldest["msg_id"])
assert body["items"], "游标还未走完 total 就断了"
displayed = [it["msg_id"] for it in body["items"][::-1]] + displayed
assert displayed == ["a1", "a2", "a3", "a4", "a5"]
@@ -0,0 +1,42 @@
"""5 个新鲜度索引:create_all 建出 + 模型迁移双声明一致(防 autogenerate 反 DROP)。
迁移侧(c3d4e5f6a7b8)由临时库 alembic upgrade head 验证;本文件锁定
模型侧——Base.metadata 与 models.py __table_args__ 声明一旦被误删,
create_all 建出的表就缺索引,此测试即红。
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.base import Base
EXPECTED = {
"customer": "ix_customer_updated_at",
"chat_group": "ix_chat_group_updated_at",
"phone_call": "ix_phone_call_updated_at",
"media": "ix_media_updated_at",
"moments_post": "ix_moments_post_updated_at",
}
async def test_freshness_indexes_exist():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
names = await conn.run_sync(
lambda sync_conn: {
t: {ix["name"] for ix in sa.inspect(sync_conn).get_indexes(t)}
for t in EXPECTED
}
)
for table, index in EXPECTED.items():
assert index in names[table], f"{table}.{index} missing"
finally:
await engine.dispose()
@@ -0,0 +1,76 @@
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
注册测试里同步更新(那里 spy 断言 add_job 全集)。
"""
from __future__ import annotations
import logging
import time
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff
from app.storage.summary import day_key
from app.sync.scheduler import SyncScheduler
@pytest.fixture
async def sched_engine():
# StaticPool:内存库单连接共享,同 engine 的多 session 看到同一份数据
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
yield engine, maker
await engine.dispose()
async def test_analytics_tick_runs(sched_engine):
"""滚动 3 天窗覆盖今日 seed → stat_daily_staff 出行;tick 不抛。
_analytics_tick 用真实「今天」算滚动窗(time.time() 喂 day_key,上海
自然日)——seed 的 day 必须相对当日,否则窗口错过。
"""
engine, maker = sched_engine
today = day_key(int(time.time() * 1000))
async with maker() as s:
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=today,
send_cnt=1, recv_cnt=0, last_ts=1756950000000,
last_is_send=True))
s.add(SensitiveWord(word="私单"))
await s.commit()
sch = SyncScheduler(engine=engine, session_maker=maker)
await sch._analytics_tick() # 不抛即过
async with maker() as s:
n = (await s.execute(
select(func.count()).select_from(StatDailyStaff)
)).scalar_one()
assert n >= 1
async def test_analytics_tick_swallows_errors(sched_engine, monkeypatch, caplog):
"""内部异常只记日志不抛(保住下一个调度周期)。
直接让 recompute_days 抛错——_analytics_tick 内部的
``from app.storage.staff_summary import recompute_days`` 在调用时求值,
monkeypatch 模块属性即可命中。
"""
engine, maker = sched_engine
async def _boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("app.storage.staff_summary.recompute_days", _boom)
sch = SyncScheduler(engine=engine, session_maker=maker)
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # 不抛
assert any("analytics tick failed" in r.getMessage() for r in caplog.records)
+279
View File
@@ -0,0 +1,279 @@
"""C1 词库 CRUD + 预览。"""
from __future__ import annotations
import datetime as dt
from app.analytics.sensitive import scanner
from app.analytics.sensitive import words as words_svc
from app.db.models import Message, SensitiveHit, SensitiveScanState
async def test_create_and_duplicate(make_client):
async def seed(session):
w = await words_svc.create_word(
session, word="飞单", category="私单", exclude_words=["飞单群"]
)
assert w.id is not None and w.enabled is True
try:
await words_svc.create_word(session, word="飞单")
raise AssertionError("should raise")
except ValueError:
pass
await make_client(seed)
async def test_preview_count_and_excludes(make_client):
async def seed(session):
ts = 1756900000000
for i, text in enumerate(["今天聊飞单的事", "飞单群公告", "正常聊天"]):
session.add(Message(msg_id=f"m{i}", channel="wechat",
owner_account_id="wx1", talker_id="t1",
timestamp=ts + i, is_send=False, type="text",
text=text, fetched_at=dt.datetime(2026, 9, 4)))
await session.commit()
n1 = await words_svc.preview_count(session, "飞单", None)
assert n1 == 2 # SQLite 测试库走 LIKE 路径
n2 = await words_svc.preview_count(session, "飞单", ["飞单群"])
assert n2 == 1 # 排除词剔除「飞单群公告」
await make_client(seed)
async def test_api_words_crud(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.post("/api/analytics/sensitive/words",
json={"word": "私单", "category": "飞单"})
assert r.status_code == 200 and r.json()["word"] == "私单"
r = await client.post("/api/analytics/sensitive/words",
json={"word": "私单"})
assert r.status_code == 409
r = await client.post("/api/analytics/sensitive/words/preview",
json={"word": "私单"})
assert r.status_code == 200 and r.json()["count"] == 0
wid = 1
r = await client.patch(f"/api/analytics/sensitive/words/{wid}",
json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
r = await client.delete(f"/api/analytics/sensitive/words/{wid}")
assert r.status_code == 200
# 注:单字词 MySQL 拒预览分支(words.py ValueError)在 SQLite 测试库不可达
# ——dialect 判定不满足,不做单测;由注释 + 路由 400 映射保障。
async def test_api_patch_unknown_404(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.patch("/api/analytics/sensitive/words/999",
json={"enabled": False})
assert r.status_code == 404
async def test_api_delete_unknown_404(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.delete("/api/analytics/sensitive/words/999")
assert r.status_code == 404
async def test_api_create_empty_word_409(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.post("/api/analytics/sensitive/words",
json={"word": " "})
assert r.status_code == 409
# ---------------------------------------------------------------- C1 scanner
def _msg(msg_id, text, fetched, ts=1756900000000, talker="t1"):
return Message(msg_id=msg_id, channel="wechat", owner_account_id="wx1",
talker_id=talker, timestamp=ts, is_send=False, type="text",
text=text, fetched_at=fetched)
async def test_scan_incremental_and_idempotent(make_client):
async def seed(session):
f1 = dt.datetime(2026, 9, 4, 10, 0)
session.add(_msg("m1", "聊聊私单", f1))
session.add(_msg("m2", "正常", f1))
await words_svc.create_word(session, word="私单")
await session.commit()
r1 = await scanner.run_incremental(session)
await session.commit()
assert r1["hits"] == 1
assert r1["new_watermark"] == f1
r2 = await scanner.run_incremental(session) # 再跑:水位之后无新行
await session.commit()
assert r2["hits"] == 0
from sqlalchemy import func, select
n = (await session.execute(
select(func.count()).select_from(SensitiveHit)
)).scalar_one()
assert n == 1 # 幂等不重复
await make_client(seed)
async def test_late_arriving_history_scanned(make_client):
"""迟到数据:timestamp 很老但 fetched_at 新 → 必须被扫到(P2 水位设计)。"""
async def seed(session):
old = dt.datetime(2026, 9, 1, 8, 0)
session.add(_msg("m_old", "历史私单消息", old))
await words_svc.create_word(session, word="私单")
await session.commit()
r1 = await scanner.run_incremental(session)
await session.commit()
assert r1["hits"] == 1
# 迟到行:timestamp 比 m_old 的还老(fetched_at 现在)——
# 错误的 timestamp 水位实现会漏掉它,fetched_at 水位必须抓到
session.add(_msg("m_late", "迟到的私单", dt.datetime(2026, 9, 4, 12, 0),
ts=1735000000000))
await session.commit()
r2 = await scanner.run_incremental(session)
await session.commit()
assert r2["hits"] == 1 # 按 fetched_at 水位,迟到历史不漏
await make_client(seed)
async def test_single_char_like_fallback(make_client):
async def seed(session):
session.add(_msg("m1", "坑爹", dt.datetime(2026, 9, 4, 10, 0)))
await words_svc.create_word(session, word="坑") # 单字
await session.commit()
r = await scanner.run_incremental(session)
await session.commit()
assert r["hits"] == 1 # 单字走 LIKE 子串,增量行少语义精确
await make_client(seed)
async def test_disposition_preserved(make_client):
"""重扫已处置行不覆盖 disposition。"""
async def seed(session):
session.add(_msg("m1", "私单", dt.datetime(2026, 9, 4, 10, 0)))
await words_svc.create_word(session, word="私单")
await session.commit()
await scanner.run_incremental(session)
await session.commit()
from sqlalchemy import select
hit = (await session.execute(
select(SensitiveHit)
)).scalars().one()
hit.disposition = "false_positive"
await session.commit()
# 重扫同词同消息(水位回拨模拟)
st = await session.get(SensitiveScanState, "message_text")
st.last_fetched_at = None
await session.commit()
await scanner.run_incremental(session)
await session.commit()
hit2 = (await session.execute(
select(SensitiveHit)
)).scalars().one()
assert hit2.disposition == "false_positive"
await make_client(seed)
async def test_full_backfill_window_rows_scanned_by_incremental(
make_client, monkeypatch
):
"""回填窗口内新入库的行不漏:水位必须在词循环前取。
若 run_full 在词循环后才读 MAX(fetched_at),窗口内 commit 的行
fetched_at ≤ 水位,>= 增量也扫不到——首部署夜间回填与 02:00 全量
批写重叠时漏扫。m_max(无命中)垫高窗口内 MAX,锁死判别力。
"""
async def seed(session):
session.add(_msg("m1", "私单", dt.datetime(2026, 9, 4, 10, 0)))
await words_svc.create_word(session, word="私单")
await session.commit()
orig = scanner._scan_batch
fired = False
async def _hook(sess, word, rows):
nonlocal fired
if not fired: # 首个分片时模拟回填窗口内新消息入库
fired = True
sess.add(_msg("m_win", "窗口内私单", dt.datetime(2026, 9, 4, 11, 0)))
sess.add(_msg("m_max", "正常", dt.datetime(2026, 9, 4, 12, 0)))
await sess.commit()
return await orig(sess, word, rows)
monkeypatch.setattr(scanner, "_scan_batch", _hook)
await scanner.run_full(session) # 词行集已物化,窗口行不在其中
await session.commit()
st = await session.get(SensitiveScanState, "message_text")
# 水位是扫描前取的(10:00),未被窗口行推后
assert st.last_fetched_at == dt.datetime(2026, 9, 4, 10, 0)
r = await scanner.run_incremental(session)
await session.commit()
assert r["hits"] == 1 # 窗口内命中行(m_win)由增量补上
await make_client(seed)
# ------------------------------------------------------------- C1 命中列表/处置
async def test_hits_list_default_filter_and_dispose(make_client):
async def seed(session):
w = await words_svc.create_word(session, word="私单")
session.add(SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat",
ts=1756900000000, snippet="…私单…",
disposition="pending"))
session.add(SensitiveHit(word_id=w.id, msg_id="m2", channel="wechat",
ts=1756900001000, snippet="…私单…",
disposition="ignored"))
# 排序/word_id 过滤双断言用:同 disposition 两行、不同词、ts 拉开
w2 = await words_svc.create_word(session, word="飞单")
session.add(SensitiveHit(word_id=w.id, msg_id="m3", channel="wechat",
ts=1756900005000, snippet="…",
disposition="false_positive"))
session.add(SensitiveHit(word_id=w2.id, msg_id="m4", channel="wechat",
ts=1756900002000, snippet="…",
disposition="false_positive"))
await session.commit()
client = await make_client(seed)
r = await client.get("/api/analytics/sensitive/hits")
body = r.json()
assert body["total"] == 1 # 默认只 pending+confirmed
ids = [h["id"] for h in body["items"]]
r2 = await client.get("/api/analytics/sensitive/hits",
params={"disposition": "ignored"})
assert r2.json()["total"] == 1
r3 = await client.patch("/api/analytics/sensitive/hits",
json={"ids": ids, "disposition": "confirmed"})
assert r3.json()["updated"] == 1
r4 = await client.get("/api/analytics/sensitive/hits",
params={"disposition": "confirmed"})
assert r4.json()["total"] == 1
# ts 降序:m3(ts 更大)在前;m4 属他词被 word_id 过滤掉
r5 = await client.get("/api/analytics/sensitive/hits",
params={"disposition": "false_positive"})
fp = r5.json()
assert fp["total"] == 2
assert [h["msg_id"] for h in fp["items"]] == ["m3", "m4"]
r6 = await client.get("/api/analytics/sensitive/hits",
params={"disposition": "false_positive",
"word_id": fp["items"][0]["word_id"]})
assert r6.json()["total"] == 1
r7 = await client.get("/api/analytics/sensitive/hits",
params={"disposition": "bogus"})
assert r7.status_code == 422 # 拼错值响亮 422,不静默空列表
+248
View File
@@ -0,0 +1,248 @@
"""stat_daily_staff 维护层:派生/幂等/判别法/未映射兜底。
判别法(9-03 先例):rollup 只读 msg_daily_stats——回填后删光 message 明细,
员工汇总重算仍出完整数据。
"""
from __future__ import annotations
import datetime as dt
from app.db.models import (
Message,
MsgDailyStats,
StatDailyStaff,
WechatAccount,
WhatsappAccount,
)
from app.storage import staff_summary
def _mk_msg(msg_id: str, ts: int, is_send: bool | None) -> Message:
return Message(
msg_id=msg_id,
channel="wechat",
owner_account_id="wx1",
talker_id="t1",
timestamp=ts,
is_send=is_send,
text="x",
fetched_at=dt.datetime(2026, 9, 4, 12, 0),
)
async def _seed_pair(session) -> None:
"""两账号同一员工(wx1/wx2→u1) + 未映射账号 wx3,20260904 各有消息。"""
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u1", user_name="张三"))
rows = [
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5,
last_ts=1756900000000, last_is_send=True),
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="room",
peer_id="r1", day=20260904, send_cnt=3, recv_cnt=0,
last_ts=1756900000000, last_is_send=True),
MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=2, recv_cnt=1,
last_ts=1756900001000, last_is_send=False),
MsgDailyStats(channel="wechat", owner_account_id="wx3", peer_kind="friend",
peer_id="t9", day=20260904, send_cnt=1, recv_cnt=0,
last_ts=1756900002000, last_is_send=True),
]
session.add_all(rows)
await session.commit()
async def _seed_wa_and_days(session) -> None:
"""wechat 20260903/20260904 两天 + whatsapp 20260904 一天(uid→wa_u1)。"""
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WhatsappAccount(uid="wa1@x", user_id="wa_u1", user_name="李四"))
rows = [
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260903, send_cnt=4, recv_cnt=2,
last_ts=1756800000000, last_is_send=True),
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5,
last_ts=1756900000000, last_is_send=True),
MsgDailyStats(channel="whatsapp", owner_account_id="wa1@x", peer_kind="friend",
peer_id="p1", day=20260904, send_cnt=7, recv_cnt=3,
last_ts=1756900005000, last_is_send=True),
]
session.add_all(rows)
await session.commit()
async def test_recompute_multi_day_and_whatsapp(make_client):
"""行按 (day, channel, user_id) 正确分键:wechat 两天各一行 + whatsapp 一行;
WA 走 WhatsappAccount.uid→user_id 映射(而非 wechat 账号表)。"""
async def seed(session):
await _seed_wa_and_days(session)
n = await staff_summary.recompute_days(session, [20260903, 20260904])
await session.commit()
assert n == 3 # wechat×2天 + whatsapp×1天(空 day×channel 不计)
from sqlalchemy import select
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
keys = {(r.day, r.channel, r.user_id) for r in rows}
assert keys == {
(20260903, "wechat", "u1"),
(20260904, "wechat", "u1"),
(20260904, "whatsapp", "wa_u1"),
}
wa = next(r for r in rows if r.channel == "whatsapp")
assert wa.msg_sent == 7 and wa.msg_recv == 3 and wa.active_friends == 1
await make_client(seed)
async def test_stale_day_cleanup(make_client):
"""陈旧行清理:msg_daily_stats 里已消失的日期,重算后 stat 行必须消失。
- recompute_all 全表清空自愈:day 从 msg_daily_stats 整体消失(夜间
summary.recompute_all 清空重建的典型场景)→ distinct days 不含它,
只有全表清空能救;
- recompute_days 空日 delete-before-continue:day 仍在但某渠道行删光
→ 该 day×channel 的 stat 行删除,同日 wechat 行保留。
"""
async def seed(session):
from sqlalchemy import delete, select
await _seed_wa_and_days(session)
await staff_summary.recompute_all(session) # stat 先有 3 行
await session.commit()
# 场景 1:20260903 整天从 msg_daily_stats 消失 → recompute_all 自愈
await session.execute(
delete(MsgDailyStats).where(MsgDailyStats.day == 20260903)
)
n = await staff_summary.recompute_all(session)
await session.commit()
assert n == 2 # 只剩 20260904 的 wechat + whatsapp 两个 day×channel
days = set(
(await session.execute(select(StatDailyStaff.day))).scalars().all()
)
assert days == {20260904} # 20260903 陈旧行被清
# 场景 2:20260904 的 whatsapp 行删光 → 空日重算删行,wechat 保留
await session.execute(
delete(MsgDailyStats).where(MsgDailyStats.channel == "whatsapp")
)
await staff_summary.recompute_days(session, [20260904])
await session.commit()
keys = {
(r.day, r.channel)
for r in (await session.execute(select(StatDailyStaff))).scalars().all()
}
assert keys == {(20260904, "wechat")} # whatsapp 行消失,wechat 不动
await make_client(seed)
async def test_recompute_aggregates_by_user(make_client):
"""两账号同人合并(sent=10+3+2=15,recv=5+0+1=6;t1 在 wx1/wx2 都聊 → 合并后 distinct friend={t1} + room={r1});wx3 未映射按原值。"""
async def seed(session):
await _seed_pair(session)
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import select
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
by_uid = {r.user_id: r for r in rows}
assert set(by_uid) == {"u1", "wx3"} # wx3 映射不上按 owner 原值
u1 = by_uid["u1"]
assert u1.msg_sent == 15 and u1.msg_recv == 6
assert u1.active_friends == 1 # t1 跨账号去重
assert u1.active_rooms == 1
assert by_uid["wx3"].active_friends == 1
await make_client(seed)
async def test_recompute_idempotent(make_client):
async def seed(session):
await _seed_pair(session)
await staff_summary.recompute_days(session, [20260904])
await staff_summary.recompute_days(session, [20260904]) # 再跑不翻倍
await session.commit()
from sqlalchemy import func, select
n = (
await session.execute(
select(func.count()).select_from(StatDailyStaff)
)
).scalar_one()
assert n == 2 # u1 + wx3 各一行
await make_client(seed)
async def test_discriminant_survives_message_delete(make_client):
"""判别法:rollup 回填后删光 message,重算结果不变(只依赖 msg_daily_stats)。"""
async def seed(session):
await _seed_pair(session)
session.add(_mk_msg("m1", 1756900000000, True))
await session.commit()
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import delete, select
await session.execute(delete(Message))
await staff_summary.recompute_days(session, [20260904]) # 自愈重算
await session.commit()
rows = (await session.execute(select(StatDailyStaff))).scalars().all()
by_uid = {r.user_id: r for r in rows}
assert by_uid["u1"].msg_sent == 15 # 数据不丢
await make_client(seed)
async def test_recompute_all_and_rolling(make_client):
async def seed(session):
await _seed_pair(session)
n = await staff_summary.recompute_all(session)
await session.commit()
assert n == 1 # 全库只有一个「有数据的 day×channel」
await make_client(seed)
days = staff_summary.rolling_days(20260904, window=3)
assert days == [20260902, 20260903, 20260904]
async def test_recompute_carries_thread_stats(make_client):
"""首响/回复率三列(2026-09-07 批1)按账号→员工累加传递;未映射账号同样保留。"""
async def seed(session):
session.add(WechatAccount(wx_id="wx1", user_id="u1", user_name="张三"))
session.add(WechatAccount(wx_id="wx2", user_id="u1", user_name="张三"))
rows = [
MsgDailyStats(channel="wechat", owner_account_id="wx1", peer_kind="friend",
peer_id="t1", day=20260904, send_cnt=10, recv_cnt=5,
last_ts=1756900000000, last_is_send=True,
cust_threads=4, threads_answered=3, fr_sum_ms=600_000),
MsgDailyStats(channel="wechat", owner_account_id="wx2", peer_kind="room",
peer_id="r1", day=20260904, send_cnt=2, recv_cnt=1,
last_ts=1756900001000, last_is_send=False,
cust_threads=2, threads_answered=1, fr_sum_ms=240_000),
# 旧数据行(迁移前):三列 server_default 兜 0,聚合不炸
MsgDailyStats(channel="wechat", owner_account_id="wx3", peer_kind="friend",
peer_id="t9", day=20260904, send_cnt=1, recv_cnt=0,
last_ts=1756900002000, last_is_send=True),
]
session.add_all(rows)
await session.commit()
await staff_summary.recompute_days(session, [20260904])
await session.commit()
from sqlalchemy import select
by_uid = {
r.user_id: r
for r in (await session.execute(select(StatDailyStaff))).scalars().all()
}
u1 = by_uid["u1"] # wx1+wx2 合并:threads=6, answered=4, fr_sum=840000
assert u1.cust_threads == 6
assert u1.threads_answered == 4
assert u1.fr_sum_ms == 840_000
assert by_uid["wx3"].cust_threads == 0 # 未传三列 → server_default 0
assert by_uid["wx3"].threads_answered == 0
assert by_uid["wx3"].fr_sum_ms == 0
await make_client(seed)
+153 -1
View File
@@ -1,6 +1,6 @@
"""Tests for the msg_daily_stats 汇总层(日粒度会话统计)。
三个被钉死的行为:
四个被钉死的行为:
1. **日桶口径**:day 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期
函数——旧实现 from_unixtime 跟随 MySQL 会话时区,UTC 容器下和今日数
@@ -10,6 +10,8 @@
入口,一处挂全。幂等:同一批重放不产生重复/漂移。
3. **全量重算**::func:`app.storage.summary.recompute_all` 从 message 表
重建全部日行(夜间自愈/部署回填用),幂等。
4. **消息串口径**(2026-09-07 批1 首响/回复率):cust_threads/
threads_answered/fr_sum_ms 三列的串算法矩阵——见下方测试组。
fixtures 用内存 SQLite(测试对 message 的所有写入都走 upsert_messages,
从而同时覆盖钩子行为)。
@@ -183,3 +185,153 @@ async def test_recompute_all_rebuilds_and_is_idempotent(session):
await recompute_all(session) # 幂等(重建后是全新 ORM 对象,比字段值不比对象)
assert await _stats_rows(session) == rows
# ── 消息串口径:首响/回复率三列(2026-09-07 批1)────────────────────────── #
#
# 口径(定案):串=客户连续消息(is_send=False)不被员工消息(is_send=True)
# 打断的段;is_send=None 不参与串判定(跳过,不打断也不起串)。每个被回串
# 一个样本 fr_ms=员工首条回复 ts−串末条客户消息 ts。日切近似:日末未闭合
# 串计 threads 不计 answered,跨夜的串在次日段重新起算。
async def _thread_rows(session):
rows = (await session.execute(select(MsgDailyStats))).scalars().all()
return {
(r.peer_kind, r.peer_id, r.day): (r.cust_threads, r.threads_answered, r.fr_sum_ms)
for r in rows
}
async def _seed_thread(session, msgs) -> dict:
"""写一批消息(走钩子→recompute_days),返回三列断言字典。"""
await upsert_messages(session, msgs)
await session.commit()
return await _thread_rows(session)
async def test_thread_single_answered(session):
"""单串被回:fr=回复−串首条客户消息(此处串只有一条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
# (cust_threads, threads_answered, fr_sum_ms)
assert rows[("friend", "t1", 20260903)] == (1, 1, 5 * 60 * 1000)
async def test_thread_fr_takes_last_customer_msg(session):
"""多条客户消息串内:fr 取「末条」客户消息(非首条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("c3", _sh(2026, 9, 3, 10, 7), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 9)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 2 * 60 * 1000) # 10:09−10:07
async def test_two_threads_only_one_answered(session):
"""两串只回一串:threads=2/answered=1,fr 只含被回串。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False), # 新串,日末未闭合
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_thread_not_broken_by_unknown(session):
"""unknown(is_send=None)消息夹在串中:不打断串,fr 仍取末条客户消息。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 3 * 60 * 1000) # 10:05−10:02
async def test_thread_open_at_day_end_counts_unanswered(session):
"""日末未闭合串:计 threads 不计 answered(日切近似,fr 不含它)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 23, 50), is_send=False), # 日末未回
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_staff_msg_first_is_not_a_thread(session):
"""员工先发不算串:开场白式员工消息不产生 threads/answered。"""
rows = await _seed_thread(
session,
[
_msg("s0", _sh(2026, 9, 3, 9, 0)),
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
async def test_thread_closed_then_reopened_and_multi_fr(session):
"""闭合后再起串+多样本:fr_sum 累加各被回串的样本时长。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)), # 串1被回,fr=5min
_msg("s2", _sh(2026, 9, 3, 10, 8)), # 串已闭合,追加员工消息无效
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False),
_msg("c3", _sh(2026, 9, 3, 11, 4), is_send=False),
_msg("s3", _sh(2026, 9, 3, 11, 10)), # 串2被回,fr=6min(11:10−11:04)
],
)
assert rows[("friend", "t1", 20260903)] == (2, 2, 11 * 60 * 1000)
async def test_thread_cross_day_restarts_next_day(session):
"""日切近似:23:50 的串在当日计 open;次日段客户续发+被回在次日重新起算。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 23, 50), is_send=False), # 当日未回
_msg("c2", _sh(2026, 9, 4, 9, 0), is_send=False), # 次日新串(同串跨夜近似)
_msg("s1", _sh(2026, 9, 4, 9, 3)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
assert rows[("friend", "t1", 20260904)] == (1, 1, 3 * 60 * 1000) # 9:03−9:00
async def test_recompute_all_carries_thread_stats(session):
"""全量重算路径(逐会话全史分桶)同样计算三值,且与钩子路径一致。"""
msgs = [
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c3", _sh(2026, 9, 3, 11, 0), is_send=False),
]
await upsert_messages(session, msgs)
await session.commit()
hooked = await _thread_rows(session)
await recompute_all(session)
assert await _thread_rows(session) == hooked
assert hooked[("friend", "t1", 20260903)] == (2, 1, 3 * 60 * 1000)
+17 -4
View File
@@ -401,7 +401,7 @@ async def test_run_once_proceeds_after_stale_run_expires(monkeypatch):
# ---------------------------------------------------------------------------
async def test_start_registers_two_jobs_with_expected_triggers(engine, session_maker):
async def test_start_registers_all_jobs_with_expected_triggers(engine, session_maker):
"""start() wires a daily CronTrigger (full) + an IntervalTrigger (messages)."""
sched = SyncScheduler(
full_sync_hour=3,
@@ -416,10 +416,13 @@ async def test_start_registers_two_jobs_with_expected_triggers(engine, session_m
with patch.object(scheduler_module, "AsyncIOScheduler", return_value=spy):
await sched.start()
# Three jobs registered (2026-08-21 加每周全量对齐).
assert spy.add_job.call_count == 3
# Four jobs registered (2026-08-21 加每周全量对齐;2026-09-04 加 4h 分析链).
assert spy.add_job.call_count == 4
job_ids = {c.kwargs["id"] for c in spy.add_job.call_args_list}
assert job_ids == {"yunke_full_sync", "yunke_message_sync", "yunke_weekly_full_history"}
assert job_ids == {
"yunke_full_sync", "yunke_message_sync", "yunke_weekly_full_history",
"yunke_analytics_tick",
}
# Full sync -> CronTrigger(hour=3); messages -> IntervalTrigger(hours=6).
# add_job was called as (func, trigger, kwargs...) so trigger is args[1].
@@ -454,6 +457,16 @@ async def test_start_registers_two_jobs_with_expected_triggers(engine, session_m
assert weekly_call.args[0].__name__ == "_safe_page_run"
assert weekly_call.kwargs["args"] == ["full-history"]
# 2026-09-04 加 4h 分析链:纯 DB(非 _safe_page_run,无子进程),
# IntervalTrigger 与消息增量同节奏,不带 args。
tick_call = next(
c for c in spy.add_job.call_args_list if c.kwargs["id"] == "yunke_analytics_tick"
)
assert tick_call.args[0].__name__ == "_analytics_tick"
tick_interval = tick_call.args[1]
assert tick_interval.interval.total_seconds() == 6 * 3600
assert "args" not in tick_call.kwargs
# coalesce=True + max_instances=1 protects against overlap.
for c in spy.add_job.call_args_list:
assert c.kwargs["coalesce"] is True
+8 -1
View File
@@ -24,8 +24,15 @@ server {
# network; if the name changes, update here. Streaming endpoints
# (/api/media/) work without buffering because we turn proxy_buffering
# off — matches the dev-server behaviour (vite proxies /api/media first).
#
# DNS 加固(2026-09-04):nginx 默认启动时解析一次 hostname 并永久缓存,
# backend 重建/IP 漂移后 502(两天两次实战)。改用 docker 内嵌 DNS
# (127.0.0.11)按 TTL 重解析——variable proxy_pass 不带 URI,仍转发
# 原始完整请求路径(/api/...),与原行为一致。
location /api/ {
proxy_pass http://backend:8000;
resolver 127.0.0.11 valid=10s ipv6=off;
set $yunke_backend http://backend:8000;
proxy_pass $yunke_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+33
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"vue": "^3.5.40",
@@ -1045,6 +1046,22 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/element-plus": {
"version": "2.14.3",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
@@ -1756,6 +1773,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2221,6 +2239,21 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"vue": "^3.5.40",
+111
View File
@@ -0,0 +1,111 @@
// 数据分析板块 API(2026-09-04)。client.ts 的 http 已带 Bearer + baseURL=/api,
// getJson/requestJson 负责解包与泛型。Page/Channel 沿用 types.ts 现成定义。
import { getJson, requestJson } from './client'
import type { Channel, Page } from './types'
export type { Channel }
// 批2(2026-09-07):'custom'=自定义日期范围,须配合 start/end yyyymmdd
export type Scope = 'today' | 'week' | 'month' | 'last30' | 'custom'
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
export type DashboardChannel = 'all' | Channel
export interface OverviewCards {
msg_total: number
active_customers: number
active_staff: number
unmapped_accounts: number
call_cnt: number
first_contact: number
prev_msg_total?: number
msg_total_delta?: number | null
}
// B1 客户资产总览:全库存量快照,不随 scope/channel
export interface CustomerAssets {
total: number
active30: number
silent30_90: number
sleeping90p: number
never: number
wechat: number
whatsapp: number
deleted: number
}
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
// 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer,
// msg_delta 仅周/月/custom 且当前/前窗均非 0 才有值。
// 批2 五新列:fr_avg_ms/reply_rate(=null 无样本/无串)、first_contact_cnt、
// call_seconds/answer_rate(=null 无通话);deleted/blocked_friends=全库存量
export interface StaffRankingRow {
user_id: string; name: string; unmapped: boolean
msg_total: number; active_friends: number; active_rooms: number
call_cnt: number; msg_delta: number | null
fr_avg_ms: number | null
reply_rate: number | null
first_contact_cnt: number
call_seconds: number
answer_rate: number | null
deleted_friends: number
blocked_friends: number
}
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
// 渠道趋势序列 call_cnt 恒 0——通话量无渠道语义不拆,总量走 trend
export interface PerChannelCards { msg_total: number; active_customers: number; first_contact: number }
export interface OverviewResp {
scope: Scope
channel: Channel | 'all'
days: number[]
cards: OverviewCards
customer_assets: CustomerAssets
trend: TrendPoint[]
anomaly: Anomaly | null
staff_ranking: StaffRankingRow[]
actions: ActionCounts
by_channel?: { wechat: PerChannelCards; whatsapp: PerChannelCards }
trend_by_channel?: { wechat: TrendPoint[]; whatsapp: TrendPoint[] }
}
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
export interface ChurnRow {
friend_id: string; channel: string; nickname: string; owner_account_id: string | null
user_id: string | null; last_chat_time: number | null; hist_msgs: number; active_days: number
}
export interface UnrepliedRow {
peer_id: string; channel: string; owner_account_id: string; last_ts: number
wait_ms: number; preview: string; msg_id: string
}
export interface SensitiveWordRow {
id: number; word: string; category: string | null; enabled: boolean
min_len: number; exclude_words: string[]
}
export interface SensitiveHitRow {
id: number; word_id: number; word: string; category: string | null; msg_id: string
channel: string; owner_account_id: string | null; talker_id: string | null
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
}
// scope='custom' 时必须传 start/end(yyyymmdd,后端校验跨度≤366 天)
export const getOverview = (
channel: DashboardChannel, scope: Scope, start?: string, end?: string,
) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope, start, end })
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
export const getChurn = (params: Record<string, unknown>) =>
getJson<Page<ChurnRow>>('/analytics/churn', params)
export const getUnreplied = (params: Record<string, unknown>) =>
getJson<Page<UnrepliedRow>>('/analytics/unreplied', params)
export const getWords = () => getJson<SensitiveWordRow[]>('/analytics/sensitive/words')
export const previewWord = (data: { word: string; exclude_words?: string[] }) =>
requestJson<{ count: number; warn: boolean }>('post', '/analytics/sensitive/words/preview', data)
export const createWord = (data: { word: string; category?: string; exclude_words?: string[] }) =>
requestJson<{ id: number; word: string; enabled: boolean }>('post', '/analytics/sensitive/words', data)
export const patchWord = (id: number, data: Record<string, unknown>) =>
requestJson<Record<string, unknown>>('patch', `/analytics/sensitive/words/${id}`, data)
export const deleteWord = (id: number) =>
requestJson<{ ok: boolean }>('delete', `/analytics/sensitive/words/${id}`)
export const getHits = (params: Record<string, unknown>) =>
getJson<Page<SensitiveHitRow>>('/analytics/sensitive/hits', params)
export const disposeHits = (ids: number[], disposition: string) =>
requestJson<{ updated: number }>('patch', '/analytics/sensitive/hits', { ids, disposition })
+11
View File
@@ -94,6 +94,14 @@ export interface MessageQuery {
end?: number
page?: number
page_size?: number
/** 排序方向:asc(默认,最旧在前)/ desc(最新在前)。聊天窗首屏用 desc
* 取最新一页(O(page_size)),避开 ASC 深 OFFSET 逐行回表(大对话 2.5s+,
* 2026-09-07 超时修复)。 */
order?: 'asc' | 'desc'
/** keyset 游标(与 before_id 成对):只取严格早于 (before_ts, before_id) 的
* 消息。「加载更多」上翻用,每页恒 O(page_size),与 order 独立组合。 */
before_ts?: number
before_id?: string
}
export function getMessages(query: MessageQuery): Promise<Page<Message>> {
@@ -110,6 +118,9 @@ export function getMessages(query: MessageQuery): Promise<Page<Message>> {
end: query.end,
page: query.page ?? 1,
page_size: query.page_size ?? 50,
order: query.order,
before_ts: query.before_ts,
before_id: query.before_id,
}),
)
}
+31 -34
View File
@@ -32,17 +32,17 @@ const loading = ref(false)
const loadingOlder = ref(false)
const errorMsg = ref<string | null>(null)
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
// We land on the last page (most recent activity) and walk backwards to load
// older history. `currentPage` is the smallest page number currently loaded.
const currentPage = ref(1)
const totalPages = ref(1)
// Desc-keyset 流式加载(2026-09-07 深分页超时修复):首屏 order=desc 取最新
// PAGE_SIZE 条反转成 ASC 展示,「加载更多」带 (before_ts, before_id) 游标向更旧
// 翻——每页恒 O(PAGE_SIZE)。旧方案「先拉 page1 拿 total 再拉 ASC 末页」在大对话
// 上 OFFSET 数万行逐行回表(1 万条 2.5s),群聊再叠加 filesort(4 万条 count 4.7s
// /首页 5.1s/末页 10.7s),串行撞 axios 15s 超时 → ECONNABORTED。
const total = ref(0)
/** 首次加载是否完成(msgid 定位只在已加载数据上尝试,避免误报「不在当前页」)。 */
const initialLoaded = ref(false)
// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把
// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。
// 新会话已渲染的 messages/total/标题 覆盖回旧会话数据(自愈但错乱)。
// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。
let loadSeq = 0
let nameSeq = 0
@@ -51,7 +51,8 @@ const isGroup = computed(() => props.kind === 'group')
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。账号级入口(talkerId='')→ 空态。 */
const hasActive = computed(() => !!props.talkerId)
const hasMoreOlder = computed(() => currentPage.value > 1)
/** 还有更旧历史未加载:已加载数 < 会话总数(总数以首屏响应为准,游标响应的 total 是剩余数)。 */
const hasMoreOlder = computed(() => messages.value.length < total.value)
/**
* 关键词过滤:仅作用于"当前已加载"的消息(纯前端过滤,不触发重载——输入过程中
@@ -164,8 +165,8 @@ function scrollToBottom(): void {
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
/** Shared query params for both DM and group timelines (without pagination bits). */
function queryFor() {
return {
channel: CHANNEL,
// 群聊不带 owner:chat_group 表按 room_id 去重、owner 被最后写入的员工
@@ -176,15 +177,14 @@ function queryFor(pageNum: number) {
: { owner_account_id: props.ownerId, talker_id: props.talkerId as string }),
...(winStart.value != null && winEnd.value != null ? { start: winStart.value, end: winEnd.value } : {}),
...(msgType.value ? { msg_type: msgType.value } : {}),
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
* returns asc, so the last page holds the newest slice.
* Fetch the newest slice on entry: `order=desc` page 1 returns the most recent
* PAGE_SIZE messages; we reverse it to ASC for display. One request replaces
* the old two-step dance (page 1 for `total` + deep-offset last page).
*/
async function loadInitial(): Promise<void> {
const seq = ++loadSeq
@@ -193,8 +193,6 @@ async function loadInitial(): Promise<void> {
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
pendingMsgid.value = null
initialLoaded.value = true
// 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态
@@ -204,19 +202,11 @@ async function loadInitial(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const first = await getMessages(queryFor(1))
const res = await getMessages({ ...queryFor(), order: 'desc', page: 1 })
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages(queryFor(totalPages.value))
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
messages.value = last.items
}
currentPage.value = totalPages.value
total.value = res.total
// 后端 desc(新→旧);反转成时间线正序(旧→新)。
messages.value = [...res.items].reverse()
scrollToBottom()
} catch (e) {
if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换)
@@ -233,18 +223,25 @@ async function loadInitial(): Promise<void> {
nextTick(() => tryLocate())
}
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
/** Prepend the next older slice via keyset cursor. No-op when nothing older. */
async function loadOlder(): Promise<void> {
if (loadingOlder.value || !hasMoreOlder.value) return
const oldest = messages.value[0]
// 游标取最早一条已加载消息的 (timestamp, msg_id);timestamp 缺失无法定位
// (生产数据 0 行 NULL,防御性早退)。
if (!oldest || oldest.timestamp == null) return
loadingOlder.value = true
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
currentPage.value = prevPage
const res = await getMessages({
...queryFor(),
order: 'desc',
page: 1,
before_ts: oldest.timestamp,
before_id: oldest.msg_id,
})
// 后端 desc(新→旧);反转后正好衔接在现有列表之前。
messages.value = [...[...res.items].reverse(), ...messages.value]
// Keep the user's scroll anchored near where they were.
nextTick(() => {
const el = streamRef.value
+39
View File
@@ -0,0 +1,39 @@
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
// 契约:容器须稳定存在(v-show 可,v-if 卸载重建不支持——chart 只 init 一次)。
import { onBeforeUnmount, type Ref } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { EChartsCoreOption } from 'echarts/core'
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
export function useChart(el: Ref<HTMLElement | null>) {
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
const render = (option: EChartsCoreOption) => {
if (!el.value) {
console.warn('[useChart] render called before element mounted')
return
}
if (!chart) {
chart = echarts.init(el.value)
ro = new ResizeObserver(() => chart?.resize())
ro.observe(el.value)
}
chart.setOption(option, true) // notMerge:指标切换时丢弃旧系列
}
onBeforeUnmount(() => {
ro?.disconnect()
chart?.dispose()
chart = null
})
return { render }
}
export { echarts }
+19
View File
@@ -98,6 +98,25 @@ function logout(): void {
<el-menu-item index="/search" @click="goPlain('/search')">
全局搜索
</el-menu-item>
<!-- 数据分析 -->
<el-sub-menu index="analytics">
<template #title>
<span class="yk-sidenav__group">数据分析</span>
</template>
<el-menu-item index="/analytics/dashboard" @click="goPlain('/analytics/dashboard')">
数据看板
</el-menu-item>
<el-menu-item index="/analytics/churn" @click="goPlain('/analytics/churn')">
流失预警
</el-menu-item>
<el-menu-item index="/analytics/unreplied" @click="goPlain('/analytics/unreplied')">
该回未回
</el-menu-item>
<el-menu-item index="/analytics/sensitive" @click="goPlain('/analytics/sensitive')">
敏感词
</el-menu-item>
</el-sub-menu>
</el-menu>
<div class="yk-sidenav__footer">
<el-button text class="yk-sidenav__logout" @click="logout">
+25
View File
@@ -67,6 +67,31 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/SearchView.vue'),
meta: { title: '搜索' },
},
{
// 数据分析板块(spec 2026-09-04):四页
path: '/analytics/dashboard',
name: 'analytics-dashboard',
component: () => import('@/views/analytics/DashboardView.vue'),
meta: { title: '数据看板' },
},
{
path: '/analytics/churn',
name: 'analytics-churn',
component: () => import('@/views/analytics/ChurnView.vue'),
meta: { title: '流失预警' },
},
{
path: '/analytics/unreplied',
name: 'analytics-unreplied',
component: () => import('@/views/analytics/UnrepliedView.vue'),
meta: { title: '该回未回' },
},
{
path: '/analytics/sensitive',
name: 'analytics-sensitive',
component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' },
},
{
path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat',
+31 -34
View File
@@ -35,15 +35,15 @@ const loading = ref(false)
const loadingOlder = ref(false)
const errorMsg = ref<string | null>(null)
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
// We land on the last page (most recent activity) and walk backwards to load
// older history. `currentPage` is the smallest page number currently loaded.
const currentPage = ref(1)
const totalPages = ref(1)
// Desc-keyset 流式加载(2026-09-07 深分页超时修复,与 staffchat/ChatMain 同款):
// 首屏 order=desc 取最新 PAGE_SIZE 条反转成 ASC 展示,「加载更多」带
// (before_ts, before_id) 游标向更旧翻——每页恒 O(PAGE_SIZE)。旧方案
// 「先拉 page1 拿 total 再拉 ASC 末页」在大对话上 OFFSET 数万行逐行回表,
// 群聊再叠加 filesort,串行撞 axios 15s 超时 → ECONNABORTED。
const total = ref(0)
// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把
// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。
// 新会话已渲染的 messages/total/标题 覆盖回旧会话数据(自愈但错乱)。
// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。
let loadSeq = 0
let nameSeq = 0
@@ -62,7 +62,8 @@ const activeId = computed<string | null>(() => props.talkerId ?? props.roomId ??
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。 */
const hasActive = computed(() => !!activeId.value)
const hasMoreOlder = computed(() => currentPage.value > 1)
/** 还有更旧历史未加载:已加载数 < 会话总数(总数以首屏响应为准,游标响应的 total 是剩余数)。 */
const hasMoreOlder = computed(() => messages.value.length < total.value)
/**
* 关键词过滤:仅作用于"当前已加载"的消息(/api/messages 无 q 参数,非全量历史)。
@@ -175,8 +176,8 @@ function scrollToBottom(): void {
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
/** Shared query params for both DM and group timelines (without pagination bits). */
function queryFor() {
return {
channel: channelTyped.value,
// 群聊不带 owner:chat_group 表按 room_id 去重、owner 被最后写入的员工
@@ -186,15 +187,14 @@ function queryFor(pageNum: number) {
? { room_id: props.roomId as string }
: { owner_account_id: props.ownerId, talker_id: props.talkerId as string }),
...(winStart.value != null && winEnd.value != null ? { start: winStart.value, end: winEnd.value } : {}),
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
* returns asc, so the last page holds the newest slice.
* Fetch the newest slice on entry: `order=desc` page 1 returns the most recent
* PAGE_SIZE messages; we reverse it to ASC for display. One request replaces
* the old two-step dance (page 1 for `total` + deep-offset last page).
*/
async function loadInitial(): Promise<void> {
const seq = ++loadSeq
@@ -202,8 +202,6 @@ async function loadInitial(): Promise<void> {
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
// 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态
loading.value = false
return
@@ -211,19 +209,11 @@ async function loadInitial(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const first = await getMessages(queryFor(1))
const res = await getMessages({ ...queryFor(), order: 'desc', page: 1 })
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages(queryFor(totalPages.value))
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
messages.value = last.items
}
currentPage.value = totalPages.value
total.value = res.total
// 后端 desc(新→旧);反转成时间线正序(旧→新)。
messages.value = [...res.items].reverse()
scrollToBottom()
} catch (e) {
if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换)
@@ -235,18 +225,25 @@ async function loadInitial(): Promise<void> {
}
}
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
/** Prepend the next older slice via keyset cursor. No-op when nothing older. */
async function loadOlder(): Promise<void> {
if (loadingOlder.value || !hasMoreOlder.value) return
const oldest = messages.value[0]
// 游标取最早一条已加载消息的 (timestamp, msg_id);timestamp 缺失无法定位
// (生产数据 0 行 NULL,防御性早退)。
if (!oldest || oldest.timestamp == null) return
loadingOlder.value = true
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
currentPage.value = prevPage
const res = await getMessages({
...queryFor(),
order: 'desc',
page: 1,
before_ts: oldest.timestamp,
before_id: oldest.msg_id,
})
// 后端 desc(新→旧);反转后正好衔接在现有列表之前。
messages.value = [...[...res.items].reverse(), ...messages.value]
// Keep the user's scroll anchored near where they were.
nextTick(() => {
const el = streamRef.value
+241
View File
@@ -0,0 +1,241 @@
<template>
<div class="churn-page">
<div class="toolbar">
<el-radio-group v-model="channel" @change="reload">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-select v-if="kind === 'real'" v-model="tier" @change="reload" class="tier-select">
<el-option :value="30" label="沉默 30-60 天" />
<el-option :value="60" label="沉默 60-90 天" />
<el-option :value="90" label="沉默 90 天+" />
</el-select>
<el-button :loading="exporting" @click="exportCsv">导出 CSV</el-button>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<!-- el-tabs 的事件名是 tab-change(无 change;v-model 只换值不触发重查) -->
<el-tabs v-model="kind" @tab-change="reload">
<el-tab-pane label="真流失" name="real" />
<el-tab-pane label="从未激活" name="inactive" />
<el-tab-pane label="待交接" name="handover" />
</el-tabs>
<div class="kind-hint">{{ kindHint }}(列排序仅当前页)</div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="error-bar" />
<el-table v-loading="loading" :data="rows" :row-class-name="rowClass" @row-click="goChat">
<el-table-column prop="nickname" label="客户" min-width="160" show-overflow-tooltip />
<el-table-column label="归属员工" min-width="150">
<template #default="{ row }">{{ row.owner_account_id || row.user_id || '-' }}</template>
</el-table-column>
<el-table-column label="最后沟通" width="170">
<template #default="{ row }">{{ row.last_chat_time ? formatTime(row.last_chat_time) : '从未沟通' }}</template>
</el-table-column>
<el-table-column prop="hist_msgs" label="历史消息量" width="110" sortable />
<el-table-column prop="active_days" label="活跃天数" width="100" sortable />
</el-table>
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
</div>
</template>
<script setup lang="ts">
// A3 流失预警(spec §3.3):三分法 tab(真流失/从未激活/待交接) + 渠道切换 +
// 沉默分层(仅真流失) + CSV 导出 + 行点击跳聊天窗。
// 数据来自 /analytics/churn;/analytics/health 挂载拉一次做「数据截至」角标。
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAnalyticsHealth, getChurn, type Channel, type ChurnRow } from '@/api/analytics'
import { exportListToCsv } from '@/utils/csvExport'
import { channelLabel, formatTime, formatTimeShort } from '@/utils/format'
type ChurnKind = 'real' | 'inactive' | 'handover'
// 三分法口径随 tab 展示(小字),避免用户把「从未激活」误读成「真流失的轻档」
const KIND_HINTS: Record<ChurnKind, string> = {
real: '真流失=历史消息 ≥5 且连续沉默(按上方分层筛选)',
inactive: '从未激活=加好友后从未有过沟通',
handover: '待交接=归属员工已离职,客户等待重新分配',
}
const router = useRouter()
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
const route = useRoute()
const qChannel = route.query.channel
const channel = ref<Channel>(
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
)
const qType = route.query.type
const kind = ref<ChurnKind>(
qType === 'real' || qType === 'inactive' || qType === 'handover' ? qType : 'real',
)
const qTier = Number(route.query.tier)
const tier = ref([30, 60, 90].includes(qTier) ? qTier : 30)
const page = ref(1)
const pageSize = 20
const rows = ref<ChurnRow[]>([])
const total = ref(0)
const loading = ref(false)
const exporting = ref(false)
const error = ref('')
const asOf = ref<number | null>(null)
let seq = 0 // 竞态守卫:channel/tier/tab 快速切换时旧响应作废(DashboardView 定型模式)
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const resp = await getChurn({
channel: channel.value,
type: kind.value,
// tier 仅 real 分支生效(后端 inactive/handover 忽略);值恒为 30/60/90 不会 422
tier: tier.value,
min_msgs: 5,
page: page.value,
page_size: pageSize,
})
if (my !== seq) return
rows.value = resp.items
total.value = resp.total
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// 筛选切换回第一页再查
function reload() {
page.value = 1
load()
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onPageChange(p: number): void {
page.value = p
load()
}
const asOfText = computed(() => formatTimeShort(asOf.value))
const kindHint = computed(() => KIND_HINTS[kind.value])
// 归属账号缺失(未映射/历史数据)无法定位聊天窗,不标可点击样式(全局类,style.css)
function rowClass({ row }: { row: ChurnRow }): string {
return row.owner_account_id ? 'yk-clickable-row' : ''
}
// 行点击跳聊天窗(项目惯例,同 CustomerList.viewChat):微信→员工聊天窗口
// single tab;WA→ChatTimeline。owner 为空不跳,响亮提示。
function goChat(row: ChurnRow) {
if (!row.owner_account_id) {
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
return
}
if (row.channel === 'wechat') {
router.push({
name: 'staff-chat',
params: { ownerId: row.owner_account_id, tab: 'single', talkerId: row.friend_id },
})
return
}
router.push({
name: 'chat',
params: { channel: row.channel, ownerId: row.owner_account_id, talkerId: row.friend_id },
})
}
// CSV 平铺带「归属员工」列 = v1 简化:spec 的「按员工分组」语义由表格列承担
// (spec §3.3 注)。exportListToCsv 自带全量翻页 + BOM + 10000 行安全上限。
async function exportCsv() {
// 空检 + 成功条数提示:项目所有 exportListToCsv 调用点的统一 UX(CustomerList 等)
if (!total.value) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv<ChurnRow>({
fetchPage: (p, size) =>
getChurn({
channel: channel.value,
type: kind.value,
tier: tier.value,
min_msgs: 5,
page: p,
page_size: size,
}),
columns: [
{ key: 'nickname', label: '客户' },
{ key: 'channel', label: '渠道', format: (r) => channelLabel(r.channel) },
{ key: 'owner_account_id', label: '归属员工' },
{
key: 'last_chat_time',
label: '最后沟通',
format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '从未沟通'),
},
{ key: 'hist_msgs', label: '历史消息量' },
{ key: 'active_days', label: '活跃天数' },
],
filename: `流失预警_${kind.value}_${channel.value}`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
exporting.value = false
}
}
onMounted(() => {
load()
// health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏),
// 不进页面 error alert
getAnalyticsHealth()
.then((h) => {
asOf.value = h.data_as_of
})
.catch(() => {})
})
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.tier-select {
width: 150px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
}
.kind-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
margin: 0 0 12px;
}
.error-bar {
margin-bottom: 12px;
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,734 @@
<template>
<div class="pa4-dashboard">
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-radio-group v-model="scope" @change="load">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="last30">近30天</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<!-- C1 自定义日期范围:daterange 自保证 start≤end,选完触发 load;
跨度>366 天后端 422 走页面 error alert -->
<el-date-picker
v-if="scope === 'custom'"
v-model="customRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYYMMDD"
:clearable="false"
style="width: 260px"
@change="load"
/>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<!-- 预警行动入口:三卡直达处置页(2026-09-07 管理者反馈「纯汇总数字看不到
行动入口」);计数随 channel 切换自然跟随 overview 载荷,0 灰显仍可点 -->
<div class="action-row">
<el-card
v-for="a in actionCards" :key="a.label" shadow="never"
class="action-card" :class="{ zero: a.count === 0 }"
@click="router.push(a.to)"
>
<span class="action-icon">{{ a.icon }}</span>
<span class="action-label">{{ a.label }}</span>
<span class="action-count">{{ a.count }}</span>
</el-card>
</div>
<!-- B1 客户资产总览:全库存量快照(不随筛选变)——先看客户盘子,再看
下方当期经营卡片;分层按最近沟通时间(从未激活=加过没聊过) -->
<el-card v-if="assets" shadow="never" class="assets-card">
<div class="assets-row">
<span class="assets-title">客户资产</span>
<span class="asset-item">总客户 <b>{{ fmt(assets.total) }}</b></span>
<span class="asset-item">活跃(30天) <b>{{ fmt(assets.active30) }}</b></span>
<span class="asset-item">沉默(30-90天) <b>{{ fmt(assets.silent30_90) }}</b></span>
<span class="asset-item">沉睡(90天+) <b>{{ fmt(assets.sleeping90p) }}</b></span>
<span class="asset-item">从未激活 <b>{{ fmt(assets.never) }}</b></span>
<span class="asset-item small">微信 {{ fmt(assets.wechat) }} · WA {{ fmt(assets.whatsapp) }}</span>
<span class="asset-item muted">已删好友 {{ fmt(assets.deleted) }}</span>
</div>
</el-card>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
<template #title>
昨日消息量较前 7 日均值{{ anomalyView.dir }} {{ anomalyView.pct }}%({{ anomalyView.yesterday }} vs 均值
{{ anomalyView.mean }})
<span v-if="anomalyView.staffCount">— 涉及 {{ anomalyView.staffCount }} 名员工</span>
</template>
<div v-if="anomalyView.staffCount" class="anomaly-staff">
<div v-for="s in anomalyView.topStaff" :key="s.user_id">
{{ s.user_id }}:{{ s.yesterday }}(均值 {{ s.prev7_mean }}){{ s.isNew ? '(新)' : '' }}
</div>
</div>
</el-alert>
<div v-loading="loading" class="cards">
<el-card v-for="c in cardList" :key="c.label" shadow="never">
<div class="card-label">{{ c.label }}</div>
<div class="card-value">{{ c.value }}</div>
<div v-if="c.delta !== undefined && c.delta !== null" class="card-delta" :class="{ down: c.delta < 0 }">
环比 {{ (c.delta * 100).toFixed(1) }}%
</div>
<div v-else-if="c.hint" class="card-hint">{{ c.hint }}</div>
</el-card>
</div>
<el-card v-loading="loading" shadow="never" class="trend-card">
<template #header>
<div class="trend-header">
<el-radio-group v-model="trendMetric" size="small">
<el-radio-button value="msg">消息量</el-radio-button>
<el-radio-button value="active_customers">活跃客户</el-radio-button>
<el-radio-button value="call_cnt">通话量</el-radio-button>
</el-radio-group>
<!-- 数字摘要行:图之前先给一眼可读的合计/日均/峰值 -->
<span v-if="trendSummary" class="trend-summary">近30天 {{ trendSummary }}</span>
</div>
</template>
<div ref="chartEl" class="trend-chart"></div>
</el-card>
<!-- 员工活跃排行:条形图(量级对比一眼可读,2026-09-07「看不到对比」反馈)
+ 明细表(图表的 table-view 孪生)。图取当前指标 TOP 20,表仍全量。 -->
<el-card v-loading="loading" shadow="never" class="rank-card">
<template #header>
<div class="trend-header">
<span>员工活跃排行</span>
<el-radio-group v-model="rankMetric" size="small">
<el-radio-button value="msg_total">消息量</el-radio-button>
<el-radio-button value="active_friends">活跃客户</el-radio-button>
<el-radio-button value="call_cnt">通话量</el-radio-button>
</el-radio-group>
<span class="rank-scope">按所选时间范围 · TOP {{ rankBarRows.length }}</span>
</div>
</template>
<div v-if="rankBarRows.length" ref="rankBarEl" class="rank-bar"></div>
<el-table :data="data?.staff_ranking ?? []" max-height="420" size="small">
<el-table-column type="index" label="#" width="48" />
<el-table-column label="员工" min-width="160">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.unmapped" size="small" type="info" class="unmapped-tag">未映射</el-tag>
</template>
</el-table-column>
<el-table-column label="消息量" align="right">
<template #default="{ row }">{{ fmt(row.msg_total) }}</template>
</el-table-column>
<el-table-column label="活跃客户" align="right">
<template #default="{ row }">{{ fmt(row.active_friends) }}</template>
</el-table-column>
<el-table-column label="活跃群" align="right">
<template #default="{ row }">{{ fmt(row.active_rooms) }}</template>
</el-table-column>
<el-table-column label="通话量" align="right">
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
</el-table-column>
<!-- 批2 五新列(2026-09-07 管理者需求):新增客户=当期首沟通;
首响/回复率=消息串口径(null=无样本/无串);
通话时长/接通率=落窗通话;删粉/拉黑=全库存量(「删/拉」) -->
<el-table-column label="新增客户" align="right">
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column>
<el-table-column label="首响" align="right">
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
</el-table-column>
<el-table-column label="回复率" align="right">
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
</el-table-column>
<el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column>
<el-table-column label="接通率" align="right">
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
</el-table-column>
<el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
</el-table-column>
<el-table-column label="环比" align="right" width="90">
<template #default="{ row }">
<span
v-if="row.msg_delta !== null"
class="rank-delta"
:class="{ down: row.msg_delta < 0 }"
>{{ (row.msg_delta * 100).toFixed(1) }}%</span>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
// A1 数据看板:筛选栏(渠道×时间范围,自定义日期 C1) → 预警行动入口 →
// 客户资产总览(B1 全库快照) → 异常警示条 → 五卡片 → 30 日趋势折线 →
// 员工活跃排行表(批2 五新列:首响/回复率/新增客户/通话时长+接通率/删粉拉黑)。
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
type CustomerAssets, type DashboardChannel, type OverviewResp, type Scope,
type StaffRankingRow,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format'
const router = useRouter()
interface CardItem {
label: string
value: string
delta?: number
hint?: string
}
interface AnomalyStaffItem {
user_id: string
yesterday: number
prev7_mean: number
isNew: boolean
}
interface AnomalyView {
dir: string
pct: number
yesterday: number
mean: number
staffCount: number
topStaff: AnomalyStaffItem[]
}
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
const channel = ref<DashboardChannel>('all')
const scope = ref<Scope>('today')
// C1 自定义日期范围:[start, end] yyyymmdd 字符串(value-format)
const customRange = ref<[string, string] | null>(null)
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null)
const asOf = ref<number | null>(null)
const loading = ref(false)
const error = ref('')
const chartEl = ref<HTMLElement | null>(null)
const { render } = useChart(chartEl)
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
async function load() {
// custom 未选完区间不请求——保留上一次数据等选择(daterange 保证 start≤end)
if (scope.value === 'custom' && (!customRange.value?.[0] || !customRange.value?.[1])) {
return
}
const my = ++seq
loading.value = true
error.value = ''
try {
const ov = await getOverview(
channel.value, scope.value,
scope.value === 'custom' ? customRange.value?.[0] : undefined,
scope.value === 'custom' ? customRange.value?.[1] : undefined,
)
if (my !== seq) return
data.value = ov
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
const asOfText = computed(() => formatTimeShort(asOf.value))
// 异常员工偏离分:相对前 7 日均值的变化幅度;无均值但昨日有量/新增 → 最高优先
const anomalyDeviation = (s: AnomalyStaff) =>
s.prev7_mean > 0
? Math.abs(s.yesterday - s.prev7_mean) / s.prev7_mean
: s.yesterday > 0 || s.new
? Infinity
: 0
// 异常警示展示模型:判空/格式化收在 computed,模板只读定型字段(slot 闭包里
// 无法沿用 v-if 的空值窄化,预拍平最稳)
const anomalyView = computed<AnomalyView | null>(() => {
const a = data.value?.anomaly
if (!a) return null
return {
dir: a.total_delta_pct > 0 ? '高' : '低',
pct: Math.abs(Math.round(a.total_delta_pct * 100)),
yesterday: a.yesterday_total,
mean: a.prev7_mean,
staffCount: a.staff.length,
// 截前 5 条前按「诊断价值」重排,不能沿用后端 yesterday 降序——归零
// (掉同步/离职,spec §3.2 核心用例)恰在降序尾部,掉线事故(>5 人被
// 标记)时最先被裁掉。归零/新增置顶,其余按偏离幅度降序。
topStaff: [...a.staff]
.sort((x, y) => anomalyDeviation(y) - anomalyDeviation(x))
.slice(0, 5)
.map((s) => ({
user_id: s.user_id,
yesterday: s.yesterday,
prev7_mean: s.prev7_mean,
isNew: !!s.new,
})),
}
})
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
// ── B1 客户资产总览(全库存量,随 overview 载荷回来) ──
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
const fmtFr = (ms: number | null) => {
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 百分比 1 位小数(回复率/接通率);null 显 '-'
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => {
if (!s) return '-'
const mins = Math.round(s / 60)
if (mins < 60) return `${mins}分`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 删粉/拉黑合并一列「删/拉」;都 0 显 '-'
const fmtDelBlocked = (r: StaffRankingRow) =>
r.deleted_friends || r.blocked_friends
? `${r.deleted_friends}/${r.blocked_friends}`
: '-'
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
const trendVals = (): number[] =>
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)。
// 跳转带 query 预选目标页筛选(评审断裂修复):单渠道跟随当前选择,all 不带
// channel(目标页是单渠道清单,落地默认微信);敏感词无渠道概念只带 disposition
const actionCards = computed(() => {
const a = data.value?.actions
const ch: { channel?: Channel } =
channel.value !== 'all' ? { channel: channel.value } : {}
return [
{
icon: '🔴', label: '该回未回 2h', count: a?.unreplied_2h ?? 0,
to: { path: '/analytics/unreplied', query: { ...ch, hours: '2' } },
},
{
icon: '🟡', label: '流失预警 30天+', count: a?.churn_real_30d ?? 0,
to: { path: '/analytics/churn', query: { ...ch, type: 'real', tier: '30' } },
},
{
icon: '⚠', label: '敏感词待处置', count: a?.sensitive_pending ?? 0,
to: { path: '/analytics/sensitive', query: { disposition: 'pending' } },
},
]
})
// channel=all 时的卡片拆分小字(微信 X · WA Y);活跃员工跨渠道重复计数不拆、
// 通话量无渠道语义不拆(后端 by_channel 只给这三项)
function splitHint(key: 'msg_total' | 'active_customers' | 'first_contact'): string | undefined {
const bc = data.value?.by_channel
if (channel.value !== 'all' || !bc) return undefined
return `微信 ${fmt(bc.wechat[key])} · WA ${fmt(bc.whatsapp[key])}`
}
const cardList = computed<CardItem[]>(() => {
const c = data.value?.cards
if (!c) return []
return [
{
label: '消息量',
value: fmt(c.msg_total),
// 今日卡片无 msg_total_delta 键;周/月可能有 null(前值缺失)→ 统一 undefined 不展示
delta: scope.value === 'today' ? undefined : (c.msg_total_delta ?? undefined),
hint: splitHint('msg_total'),
},
{ label: '活跃客户', value: fmt(c.active_customers), hint: splitHint('active_customers') },
{
label: '活跃员工',
value: fmt(c.active_staff),
hint: c.unmapped_accounts > 0 ? `含未映射账号 ${c.unmapped_accounts}` : undefined,
},
{ label: '通话量', value: fmt(c.call_cnt) },
{ label: '首次沟通客户', value: fmt(c.first_contact), hint: splitHint('first_contact') },
]
})
// 大数压缩:轴刻度/标注统一 1.1万 口径(裸 11254 难读)
const compact = (n: number) => (n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString())
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
const trendSummary = computed(() => {
const t = data.value?.trend
if (!t || !t.length) return ''
const vals = trendVals()
const total = vals.reduce((a, b) => a + b, 0)
const max = Math.max(...vals)
const maxDay = t[vals.indexOf(max)].day
const dstr = `${String(maxDay).slice(4, 6)}-${String(maxDay).slice(6)}`
return `合计 ${total.toLocaleString()} · 日均 ${Math.round(total / vals.length).toLocaleString()} · 峰值 ${max.toLocaleString()}(${dstr})`
})
// all 模式双系列渠道色:微信=EP 主蓝,WhatsApp=EP success 绿(与单系列默认色系一致)
const CHANNEL_SERIES = [
{ key: 'wechat', name: '微信', color: '#409EFF' },
{ key: 'whatsapp', name: 'WhatsApp', color: '#67C23A' },
] as const
function draw() {
const t = data.value?.trend
if (!t || !t.length) return
const labels = t.map((p) => `${String(p.day).slice(4, 6)}-${String(p.day).slice(6)}`)
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
const interval = Math.max(0, Math.ceil(labels.length / 6) - 1)
// all 模式双系列(消息量/活跃客户):两条线+legend(两系列必须有图例)。
// 选择性标注原则——图例+tooltip+摘要行(合计口径)够了,此模式不画
// markPoint/endLabel/markLine。通话量无渠道语义,all 下仍走下方单系列合计。
const tbc = data.value?.trend_by_channel
if (channel.value === 'all' && trendMetric.value !== 'call_cnt' && tbc) {
render({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: {
trigger: 'axis',
valueFormatter: (v: unknown) => compact(Number(v)),
},
legend: {
data: CHANNEL_SERIES.map((s) => s.name),
top: 4,
itemWidth: 14,
textStyle: { color: '#606266' },
},
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval, hideOverlap: true, color: '#909399' },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), color: '#909399' },
splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } },
},
series: CHANNEL_SERIES.map((s) => ({
name: s.name,
type: 'line' as const,
smooth: true,
lineStyle: { width: 2, color: s.color },
itemStyle: { color: s.color },
areaStyle: { opacity: 0.1, color: s.color },
showSymbol: false,
data: tbc[s.key].map((p) => p[trendMetric.value] as number),
})),
})
return
}
const vals = trendVals()
const maxIdx = vals.indexOf(Math.max(...vals))
const avg = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
render({
grid: { left: 56, right: 56, top: 28, bottom: 28 },
tooltip: {
trigger: 'axis',
valueFormatter: (v: unknown) => compact(Number(v)),
},
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval, hideOverlap: true, color: '#909399' },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), color: '#909399' },
// 实线细网格(虚线网格是可视化反模式——虚线该留给参考线)
splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } },
},
series: [
{
type: 'line',
smooth: true,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.1 },
data: vals,
showSymbol: false,
// 选择性标注:终点一个数(其余靠轴+tooltip,逐点标数是反模式)
endLabel: {
show: true,
formatter: () => compact(vals[vals.length - 1]),
color: '#606266',
fontSize: 12,
},
// 峰值:小圆点+2px 表面描边环(默认大水滴太响);标注峰值日的数
markPoint: {
symbol: 'circle',
symbolSize: 9,
itemStyle: { borderColor: '#fff', borderWidth: 2 },
label: {
formatter: () => compact(vals[maxIdx]),
fontSize: 11,
color: '#606266',
offset: [0, -12],
},
data: [{ coord: [maxIdx, vals[maxIdx]] }],
},
// 均值参考线:虚线在此语义正确(参考阈值)
markLine: {
silent: true,
symbol: 'none',
lineStyle: { type: 'dashed', color: '#c0c4cc', width: 1 },
label: {
formatter: `均值 ${compact(avg)}`,
position: 'insideEndTop',
fontSize: 11,
color: '#909399',
},
data: [{ type: 'average', name: '均值' }],
},
},
],
})
}
// post:等 DOM 更新后再画,保证首帧 chartEl 已挂载(趋势图恒 30 天,随 data 重画)
watch([data, trendMetric], draw, { flush: 'post' })
// ── 员工排行条形图:幅度对比的正确图型(横向条形),单系列单色(名义
// 类别不做值渐变——颜色随实体不随排名),条端标值,其余靠轴+表。──
const rankMetric = ref<'msg_total' | 'active_friends' | 'call_cnt'>('msg_total')
const rankBarEl = ref<HTMLElement | null>(null)
const { render: renderRankBar } = useChart(rankBarEl)
const RANK_BAR_TOP = 20
const rankBarRows = computed(() =>
// 后端固定按 msg_total 排序——切指标后必须按当前指标重排再截 TOP,
// 否则条形图顺序错乱(2026-09-07 用户反馈「点活跃客户/通话量排列错乱」)
[...(data.value?.staff_ranking ?? [])]
.filter((r) => r[rankMetric.value] > 0)
.sort((a, b) => b[rankMetric.value] - a[rankMetric.value])
.slice(0, RANK_BAR_TOP),
)
function drawRankBar() {
const rows = rankBarRows.value
if (!rows.length) return
renderRankBar({
grid: { left: 8, right: 64, top: 8, bottom: 8, containLabel: true },
tooltip: {
trigger: 'item',
valueFormatter: (v: unknown) => compact(Number(v)),
},
xAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), color: '#909399' },
splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } },
},
yAxis: {
type: 'category',
inverse: true, // 第 1 名在顶部
data: rows.map((r) => r.name),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#606266' },
},
series: [
{
type: 'bar',
barWidth: 14, // ≤24px 细条规范
itemStyle: { color: '#409EFF', borderRadius: [0, 4, 4, 0] }, // 数据端 4px 圆角,基线方角
label: {
show: true,
position: 'right',
formatter: (p: { value: unknown }) => compact(Number(p.value)),
color: '#606266',
fontSize: 11,
},
data: rows.map((r) => r[rankMetric.value]),
},
],
})
}
watch([data, rankMetric], drawRankBar, { flush: 'post' })
onMounted(() => {
load()
// health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏),
// 不进页面 error alert
getAnalyticsHealth()
.then((h) => {
asOf.value = h.data_as_of
})
.catch(() => {})
})
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
}
.anomaly {
margin-bottom: 16px;
}
.anomaly-staff {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 16px;
min-height: 60px;
}
.card-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.card-value {
font-size: 26px;
font-weight: 600;
margin: 6px 0 2px;
}
.card-delta {
font-size: 12px;
color: var(--el-color-success);
}
.card-delta.down {
color: var(--el-color-danger);
}
.card-hint {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.trend-header {
display: flex;
align-items: center;
gap: 12px;
}
.trend-summary {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.trend-chart {
height: 320px;
}
.action-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.action-card {
cursor: pointer;
}
.action-card.zero {
opacity: 0.55;
}
.action-card :deep(.el-card__body) {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
.action-label {
font-size: 13px;
color: var(--el-text-color-primary);
}
.action-count {
margin-left: auto;
font-size: 22px;
font-weight: 600;
}
/* B1 客户资产总览:轻量一行条(参照 action-card 的密度) */
.assets-card {
margin-bottom: 16px;
}
.assets-card :deep(.el-card__body) {
padding: 10px 14px;
}
.assets-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 20px;
}
.assets-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-right: 4px;
}
.asset-item {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.asset-item b {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-left: 2px;
}
.asset-item.small {
font-size: 12px;
}
.asset-item.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.rank-bar {
height: 480px;
margin-bottom: 8px;
}
.rank-card {
margin-top: 16px;
}
.rank-scope {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.unmapped-tag {
margin-left: 6px;
}
.rank-delta {
color: var(--el-color-success);
}
.rank-delta.down {
color: var(--el-color-danger);
}
</style>
@@ -0,0 +1,523 @@
<template>
<div class="sensitive-page">
<div class="toolbar">
<span class="kind-hint">敏感词命中治理:默认只显示待处置+已确认;处置后离开队列。</span>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<!-- el-tabs 的事件名是 tab-change(无 change;v-model 只换值不触发重查) -->
<el-tabs v-model="tab" @tab-change="onTabChange">
<el-tab-pane label="命中列表" name="hits">
<div class="hit-toolbar">
<el-select v-model="disposition" class="disp-select" @change="reloadHits">
<!-- 'all'=默认(pending+confirmed)。EP 对空串 value 视为未选中显示
英文占位符,故用哨兵值而非 '' -->
<el-option value="all" label="待处置+已确认" />
<el-option value="pending" label="待处置" />
<el-option value="confirmed" label="已确认" />
<el-option value="false_positive" label="误报" />
<el-option value="ignored" label="已忽略" />
</el-select>
<el-button
type="primary"
:disabled="!selectedHits.length || disposing"
@click="disposeSelected('confirmed')"
>
确认
</el-button>
<el-button
:disabled="!selectedHits.length || disposing"
@click="disposeSelected('false_positive')"
>
误报
</el-button>
<el-button :disabled="!selectedHits.length || disposing" @click="disposeSelected('ignored')">
忽略
</el-button>
<span v-if="selectedHits.length" class="sel-hint">已选 {{ selectedHits.length }} 条</span>
</div>
<el-alert v-if="hitsError" type="error" :title="hitsError" :closable="false" class="error-bar" />
<el-table
v-loading="hitsLoading"
:data="hitRows"
empty-text="暂无命中记录"
@selection-change="onHitSelection"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="word" label="词" width="150" show-overflow-tooltip />
<el-table-column label="分类" width="120">
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column prop="snippet" label="上下文" min-width="260" show-overflow-tooltip>
<template #default="{ row }">{{ row.snippet || '-' }}</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.ts) }}</template>
</el-table-column>
<el-table-column label="处置状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="dispMeta(row.disposition).type">
{{ dispMeta(row.disposition).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goContext(row)">上下文</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="hitPage"
:page-size="hitPageSize"
:total="hitTotal"
layout="total, prev, pager, next"
class="pager"
@current-change="onHitPageChange"
/>
</el-tab-pane>
<el-tab-pane label="词库管理" name="words">
<div class="add-form">
<el-input
v-model="newWord"
class="ipt-word"
placeholder="新词(建议 ≥2 字;单字词不做预览)"
:disabled="adding"
/>
<el-input v-model="newCategory" class="ipt-cat" placeholder="分类(可选,如 飞单)" :disabled="adding" />
<el-input
v-model="newExcludes"
class="ipt-excl"
placeholder="排除词,逗号分隔(可选)"
:disabled="adding"
/>
<el-button type="primary" :loading="adding" @click="onAddWord">预览并添加</el-button>
</div>
<el-alert v-if="wordsError" type="error" :title="wordsError" :closable="false" class="error-bar" />
<el-table v-loading="wordsLoading" :data="wordRows" empty-text="词库为空">
<el-table-column prop="word" label="词" min-width="130" />
<el-table-column label="分类" width="130">
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column label="排除词" min-width="200">
<template #default="{ row }">{{ row.exclude_words.length ? row.exclude_words.join(',') : '-' }}</template>
</el-table-column>
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch v-model="row.enabled" @change="onToggleEnabled(row, $event)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" size="small" :disabled="deleting" @click="onDeleteWord(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
// C1 敏感词治理(spec §3.6):双 tab——命中列表(disposition 筛选,默认空值=
// 待处置+已确认;多选批量 确认/误报/忽略;行「上下文」按 群/微信/WA 三路
// 跳聊天窗)+ 词库管理(加词先预览命中量:≥500 警示确认,MySQL 单字词 400
// 分支提示后放行;启停 switch/删除带级联确认)。
// 控敏红线:确认文案不回显命中正文,console 不打词与 snippet,错误只透传
// 后端文案(后端已保证不含词/正文)。
// health 挂载拉一次做「数据截至」角标;seq 三路径守卫/分页/tab-change/
// kind-hint 均照 ChurnView/UnrepliedView 定型。
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createWord,
deleteWord,
disposeHits,
getAnalyticsHealth,
getHits,
getWords,
patchWord,
previewWord,
type SensitiveHitRow,
type SensitiveWordRow,
} from '@/api/analytics'
import { formatTime, formatTimeShort } from '@/utils/format'
type TabName = 'hits' | 'words'
type DisposeAction = 'confirmed' | 'false_positive' | 'ignored'
// 后端 DispositionParam 枚举的展示映射(乱值防御回退原值灰 tag)
const DISPOSITION_META: Record<string, { label: string; type: 'warning' | 'danger' | 'info' }> = {
pending: { label: '待处置', type: 'warning' },
confirmed: { label: '已确认', type: 'danger' },
false_positive: { label: '误报', type: 'info' },
ignored: { label: '已忽略', type: 'info' },
}
function dispMeta(d: string): { label: string; type: 'warning' | 'danger' | 'info' } {
return DISPOSITION_META[d] ?? { label: d, type: 'info' }
}
const router = useRouter()
const route = useRoute()
const tab = ref<TabName>('hits')
const asOf = ref<number | null>(null)
const asOfText = computed(() => formatTimeShort(asOf.value))
// ---- 命中列表 tab ----
// 'all'=默认(pending+confirmed,后端不传 disposition 即此语义);其余必须是
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)。
// 看板敏感词行动卡跳转带 ?disposition=pending 预选(白名单外回默认,
// 只在初始化读一次,不 watch;敏感词无渠道概念,不带 channel)
type DispFilter = 'all' | 'pending' | 'confirmed' | 'false_positive' | 'ignored'
const DISP_FILTERS: readonly DispFilter[] = [
'all', 'pending', 'confirmed', 'false_positive', 'ignored',
]
const qDisp = route.query.disposition
const disposition = ref<DispFilter>(
typeof qDisp === 'string' && DISP_FILTERS.includes(qDisp as DispFilter)
? (qDisp as DispFilter)
: 'all',
)
const hitPage = ref(1)
const hitPageSize = 20
const hitRows = ref<SensitiveHitRow[]>([])
const hitTotal = ref(0)
const hitsLoading = ref(false)
const hitsError = ref('')
const selectedHits = ref<SensitiveHitRow[]>([])
const disposing = ref(false)
let hitsSeq = 0 // 竞态守卫:disposition/翻页/tab 快速切换时旧响应作废(三路径同 ChurnView)
async function loadHits(): Promise<void> {
const my = ++hitsSeq
hitsLoading.value = true
hitsError.value = ''
try {
const params: Record<string, unknown> = { page: hitPage.value, page_size: hitPageSize }
if (disposition.value !== 'all') params.disposition = disposition.value
const resp = await getHits(params)
if (my !== hitsSeq) return
hitRows.value = resp.items
hitTotal.value = resp.total
// 处置后行离开默认队列,本页可能被清空且翻页越界——回落末页重查一次
const lastPage = Math.max(1, Math.ceil(hitTotal.value / hitPageSize))
if (!resp.items.length && hitTotal.value > 0 && hitPage.value > lastPage) {
hitPage.value = lastPage
void loadHits()
return
}
} catch (e) {
if (my === hitsSeq) hitsError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === hitsSeq) hitsLoading.value = false
}
}
// 筛选切换回第一页再查(el-select change 直挂,参数忽略——ChurnView 惯例)
function reloadHits(): void {
hitPage.value = 1
void loadHits()
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onHitPageChange(p: number): void {
hitPage.value = p
void loadHits()
}
// selection-change 的模板内联箭头带 TS 注解会触发 vue 模板解析错误,收敛到
// script(ContactsPane 惯例)
function onHitSelection(rows: SensitiveHitRow[]): void {
selectedHits.value = rows
}
async function disposeSelected(d: DisposeAction): Promise<void> {
const ids = selectedHits.value.map((r) => r.id)
if (!ids.length) {
ElMessage.warning('请先勾选命中记录')
return
}
disposing.value = true
try {
// 单页勾选 ≤ page_size(20),远低于后端 ids 上限 1000
const resp = await disposeHits(ids, d)
ElMessage.success(`已标记 ${resp.updated} 条:${dispMeta(d).label}`)
await loadHits() // 处置完即出默认队列,重查当前页(越界自动回落末页)
} catch (e) {
ElMessage.error(`处置失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
disposing.value = false
}
}
/**
* 行「上下文」跳聊天窗定位原消息:群命中→群聊时间轴;微信单聊→员工聊天窗
* single tab 带 ?msgid=;WA→ChatTimeline。owner 缺失无法定位,响亮提示
* (同 ChurnView/UnrepliedView)。跳转参数只带 id,不带词/snippet(控敏)。
*/
function goContext(row: SensitiveHitRow): void {
if (row.room_id) {
if (!row.owner_account_id) return warnNoOwner()
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
})
return
}
if (row.channel === 'wechat') {
if (!row.owner_account_id) return warnNoOwner()
const params: Record<string, string> = { ownerId: row.owner_account_id, tab: 'single' }
if (row.talker_id) params.talkerId = row.talker_id
router.push({
name: 'staff-chat',
params,
// msgid 定位依赖 talker 定位到会话,缺 talker 时丢弃
query: row.talker_id && row.msg_id ? { msgid: row.msg_id } : undefined,
})
return
}
if (!row.owner_account_id) return warnNoOwner()
const params: Record<string, string> = { channel: row.channel, ownerId: row.owner_account_id }
if (row.talker_id) params.talkerId = row.talker_id
router.push({ name: 'chat', params })
}
function warnNoOwner(): void {
ElMessage.warning('该命中缺少归属账号,无法查看聊天记录')
}
// ---- 词库管理 tab ----
const newWord = ref('')
const newCategory = ref('')
const newExcludes = ref('')
const adding = ref(false)
const deleting = ref(false)
const wordRows = ref<SensitiveWordRow[]>([])
const wordsLoading = ref(false)
const wordsError = ref('')
let wordsSeq = 0 // 同 hitsSeq:加词/删词/启停与 tab 切换并发时旧响应作废
async function loadWords(): Promise<void> {
const my = ++wordsSeq
wordsLoading.value = true
wordsError.value = ''
try {
const resp = await getWords()
if (my !== wordsSeq) return
wordRows.value = resp
} catch (e) {
if (my === wordsSeq) wordsError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === wordsSeq) wordsLoading.value = false
}
}
// 逗号分隔(全角/半角都可)→去空白去空串
function parseExcludes(s: string): string[] {
return s
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean)
}
/**
* 预览阶段的两个分支,返回「是否继续添加」:
* ① 400 单字词(MySQL 生产拒预览防全扫;SQLite 测试库不触发)——提示后
* 放行,跳过预览直接走添加;
* ② warn=true(count≥500)——confirm 警示,取消则中止。
* 其他错误(网络等)向上抛,由 onAddWord 统一 toast。
*/
async function previewStage(word: string, excludeWords: string[]): Promise<boolean> {
let prev: { count: number; warn: boolean }
try {
prev = await previewWord({ word, exclude_words: excludeWords })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
// 分支①:文案与 yunke-server app/analytics/sensitive/words.py 单字拒扫
// ValueError 的固定措辞耦合——后端改措辞需同步这里的关键词
if (msg.includes('单字') || msg.includes('不做预览')) {
ElMessage.info('单字词不做预览,已直接添加——生效后从命中列表观察')
return true
}
throw e
}
if (prev.warn) {
try {
await ElMessageBox.confirm(
`预计命中 ${prev.count} 条(≥500),建议换长词或加排除词。仍要添加吗?`,
'命中量警示',
{ type: 'warning', confirmButtonText: '仍要添加', cancelButtonText: '返回修改' },
)
} catch {
return false // 用户取消,中止(不进外层 catch——这里吞掉 'cancel')
}
}
return true
}
async function onAddWord(): Promise<void> {
const word = newWord.value.trim()
if (!word) {
ElMessage.warning('请输入敏感词')
return
}
const excludeWords = parseExcludes(newExcludes.value)
adding.value = true
try {
if (!(await previewStage(word, excludeWords))) return
await createWord({
word,
category: newCategory.value.trim() || undefined,
exclude_words: excludeWords.length ? excludeWords : undefined,
})
ElMessage({
type: 'success',
message: '已添加(下次 4h 扫描生效;要立即生效可让管理员跑 CLI sensitive-scan --full 扫历史)',
duration: 5000,
})
newWord.value = ''
newCategory.value = ''
newExcludes.value = ''
await loadWords()
} catch (e) {
// 409 词已存在等后端文案直接透出(后端错误只含 id 不含词,控敏安全)
ElMessage.error(`添加失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
adding.value = false
}
}
// switch change 的模板内联箭头带注解会炸模板编译,收敛 script;失败也重查,
// 把 switch 视觉回滚到服务端真值
function onToggleEnabled(row: SensitiveWordRow, val: string | number | boolean): void {
void applyEnabled(row.id, Boolean(val))
}
async function applyEnabled(id: number, enabled: boolean): Promise<void> {
try {
await patchWord(id, { enabled })
await loadWords()
} catch (e) {
ElMessage.error(`操作失败:${e instanceof Error ? e.message : String(e)}`)
await loadWords()
}
}
async function onDeleteWord(row: SensitiveWordRow): Promise<void> {
// 控敏:确认文案只说「该词」,不回显词与任何命中正文
try {
await ElMessageBox.confirm(
'删除该词将连带删除其全部命中记录,不可恢复。确认删除?',
'删除敏感词',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
},
)
} catch {
return // 用户放弃
}
deleting.value = true
try {
await deleteWord(row.id)
ElMessage.success('已删除')
await loadWords()
} catch (e) {
ElMessage.error(`删除失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
deleting.value = false
}
}
// ---- tab 切换与挂载 ----
// 每次切 tab 都重查对应列表(数据最新,与 ChurnView tab-change→reload 一致)
function onTabChange(name: string | number): void {
if (name === 'words') void loadWords()
else void loadHits()
}
onMounted(() => {
void loadHits()
// health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏),
// 不进页面 error alert
getAnalyticsHealth()
.then((h) => {
asOf.value = h.data_as_of
})
.catch(() => {})
})
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.kind-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
white-space: nowrap;
}
.hit-toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.disp-select {
width: 160px;
}
.sel-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.add-form {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ipt-word {
width: 230px;
}
.ipt-cat {
width: 160px;
}
.ipt-excl {
width: 230px;
}
.error-bar {
margin-bottom: 12px;
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,199 @@
<template>
<div class="unreplied-page">
<div class="toolbar">
<el-radio-group v-model="channel" @change="reload">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-radio-group v-model="hours" @change="reload">
<el-radio-button :value="1">超 1h</el-radio-button>
<el-radio-button :value="2">超 2h</el-radio-button>
<el-radio-button :value="4">超 4h</el-radio-button>
<el-radio-button :value="24">超 24h</el-radio-button>
</el-radio-group>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<div class="kind-hint">
尾部为客户消息且超过 {{ hours }} 小时未回应的对话(通话也算已回应)。点击行可跳转聊天窗定位该消息。
</div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="error-bar" />
<el-table v-loading="loading" :data="rows" :row-class-name="rowClass" @row-click="goChat">
<el-table-column prop="peer_id" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="owner_account_id" label="员工账号" width="170" />
<el-table-column label="最后消息" width="170">
<template #default="{ row }">{{ formatTime(row.last_ts) }}</template>
</el-table-column>
<el-table-column label="等待时长" width="130">
<template #default="{ row }">
<span :class="{ hot: row.wait_ms > DAY_MS }">{{ fmtWait(row.wait_ms) }}</span>
</template>
</el-table-column>
<el-table-column prop="preview" label="预览" min-width="240" show-overflow-tooltip />
</el-table>
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
</div>
</template>
<script setup lang="ts">
// B2 该回未回(行动清单,非报表):尾部为客户消息且超 N 小时未回应的对话。
// channel + 时长筛选 + 等待着色(>24h danger) + 行点击跳聊天窗(微信带
// ?msgid= 定位该条消息)。数据来自 /analytics/unreplied;不导出 CSV。
// health 挂载拉一次做「数据截至」角标(seq/分页/health-once 均照 ChurnView)。
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAnalyticsHealth, getUnreplied, type Channel, type UnrepliedRow } from '@/api/analytics'
import { formatTime, formatTimeShort } from '@/utils/format'
const DAY_MS = 24 * 3600 * 1000
const router = useRouter()
const route = useRoute()
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
const qChannel = route.query.channel
const channel = ref<Channel>(
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
)
// 默认 2h(spec §3.4/§8 口径速查表,后端 Query 默认同为 2)——首屏不是 >1h 超集
const qHours = Number(route.query.hours)
const hours = ref([1, 2, 4, 24].includes(qHours) ? qHours : 2)
const page = ref(1)
const pageSize = 20
const rows = ref<UnrepliedRow[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref('')
const asOf = ref<number | null>(null)
let seq = 0 // 竞态守卫:channel/hours 快速切换时旧响应作废(ChurnView 定型模式)
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const resp = await getUnreplied({
channel: channel.value,
hours: hours.value,
page: page.value,
page_size: pageSize,
})
if (my !== seq) return
rows.value = resp.items
total.value = resp.total
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// 筛选切换回第一页再查
function reload() {
page.value = 1
load()
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onPageChange(p: number): void {
page.value = p
load()
}
const asOfText = computed(() => formatTimeShort(asOf.value))
/**
* 等待时长:`X时Y分`(不满一天)/ `X天Y时`(满一天起)。
* >24h 由模板加 danger 色(.hot),这里只管文字。
*/
function fmtWait(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--'
const minutes = Math.floor(ms / 60000)
const h = Math.floor(minutes / 60)
if (h < 24) return `${h}时${minutes % 60}分`
return `${Math.floor(h / 24)}天${h % 24}时`
}
// owner 理论恒非空(候选集来自 msg_daily_stats 的 owner),防御性判空不亏
function rowClass({ row }: { row: UnrepliedRow }): string {
return row.owner_account_id ? 'yk-clickable-row' : ''
}
/**
* 行点击跳聊天窗:微信→员工聊天窗 single tab 并带 ?msgid= 定位最后一条
* 客户消息(同 StatsPane 红包明细跳转);WA→ChatTimeline。owner 缺失
* 不跳,响亮提示(同 ChurnView)。
*/
function goChat(row: UnrepliedRow) {
if (!row.owner_account_id) {
ElMessage.warning('该对话缺少归属账号,无法查看聊天记录')
return
}
if (row.channel === 'wechat') {
router.push({
name: 'staff-chat',
params: { ownerId: row.owner_account_id, tab: 'single', talkerId: row.peer_id },
query: row.msg_id ? { msgid: row.msg_id } : undefined,
})
return
}
router.push({
name: 'chat',
params: { channel: row.channel, ownerId: row.owner_account_id, talkerId: row.peer_id },
})
}
onMounted(() => {
load()
// health(数据截至)与筛选无关,挂载拉一次即可;失败只影响角标(静默隐藏),
// 不进页面 error alert
getAnalyticsHealth()
.then((h) => {
asOf.value = h.data_as_of
})
.catch(() => {})
})
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 8px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
}
.kind-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
margin: 0 0 12px;
}
.error-bar {
margin-bottom: 12px;
}
.hot {
color: var(--el-color-danger);
font-weight: 600;
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>