Compare commits
49 Commits
5cfc09121a
...
9d3cc8e76e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d3cc8e76e | |||
| 085a9b4fd3 | |||
| feb3b98665 | |||
| 39791b2a49 | |||
| bbfad520f2 | |||
| 1aadc80d6b | |||
| 9f2dff08d9 | |||
| 487f45b074 | |||
| 8499a74a72 | |||
| 0fad1b09bb | |||
| c2d6d6feba | |||
| 518d02bc8f | |||
| 7f32f1a43c | |||
| 1ba6768a46 | |||
| 54ed7e745b | |||
| 1650a050e6 | |||
| 4ac73fff49 | |||
| 4cec162f53 | |||
| 5fb19dd3ac | |||
| f839a6db20 | |||
| 6b99202e9b | |||
| 6a1c904018 | |||
| 6e67e9e631 | |||
| 956f243fce | |||
| aa59461b03 | |||
| c7b3ce9d1c | |||
| 0c14be0777 | |||
| 1e0db3913c | |||
| 52af531598 | |||
| 247bfc7277 | |||
| 3b4254639d | |||
| 6790710965 | |||
| b067d690f1 | |||
| ad153aa2f7 | |||
| 02e100849b | |||
| 49f2ffe8ea | |||
| af05e58976 | |||
| 5b6a9bd35b | |||
| e3bb97bdf3 | |||
| 4cfc2fa6a9 | |||
| 0b1d377db7 | |||
| 8b29326b2e | |||
| 565006239b | |||
| 81af615738 | |||
| 0bc1250476 | |||
| aecb556efd | |||
| 33c5596543 | |||
| d1b636abdc | |||
| c31d1cc450 |
@@ -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
|
||||
|
||||
@@ -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 个工作日(估),测试随批写。
|
||||
@@ -0,0 +1,5 @@
|
||||
"""分析服务层——所有数据分析指标的口径收敛处(spec 2026-09-04 §2)。
|
||||
|
||||
指标函数是纯查询(只依赖 AsyncSession,无 HTTP/请求上下文),E2 报表订阅
|
||||
(二批)直接复用。口径速查表:spec §8;函数 docstring 与之同步。
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 ==="
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,不静默空列表
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Generated
+33
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user