Compare commits
133 Commits
9ba8063ca8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 682249a3fd | |||
| 80b721b84f | |||
| acd6997bf0 | |||
| 253be99eca | |||
| e00d399195 | |||
| 2ac63e3844 | |||
| 0dc5993af7 | |||
| 0b975a8289 | |||
| 8f9ef747cc | |||
| 3d2889bcde | |||
| 2c7625c3e4 | |||
| 422a726ee8 | |||
| be0d6f844e | |||
| 577617545f | |||
| 4e53a99629 | |||
| b72e957da2 | |||
| 602bee8fdf | |||
| a9197f696a | |||
| ddeda3fa22 | |||
| 2587e4dd08 | |||
| 10b7e5e534 | |||
| e3fadfe1a6 | |||
| 3cf77ec6d5 | |||
| 7343e1c9aa | |||
| 5196a72ac2 | |||
| df8d2b30d5 | |||
| c56f13059d | |||
| 7b0e1601b7 | |||
| 940762e7ff | |||
| 2b10c07fb7 | |||
| 9fbd572bb7 | |||
| 23ac95f2e4 | |||
| 3d5644c42a | |||
| 31cd2d92aa | |||
| 57ace98792 | |||
| b0ebe8a15a | |||
| 03c08881b6 | |||
| 07c9d091a1 | |||
| c9f8c844d6 | |||
| b51b32a14b | |||
| 729710da63 | |||
| 8b555a5ad4 | |||
| afc71e61ef | |||
| d523813435 | |||
| 7758a3351f | |||
| 639edf56a6 | |||
| 0fce21aaf7 | |||
| b2e8edacc2 | |||
| e725c7032a | |||
| ad3beb7625 | |||
| 5bfcebf20c | |||
| 2bb4cee76d | |||
| a38170d205 | |||
| 08614ddb3f | |||
| b2e1b699eb | |||
| 99773f69b9 | |||
| 9dc22cd1e6 | |||
| 58d274096d | |||
| 0b0dd0808b | |||
| 373fb266a6 | |||
| 2b1f91762b | |||
| e0c764997f | |||
| 399ecc680f | |||
| 2b516362cd | |||
| b8c2bef6df | |||
| 5f6bfe1ff6 | |||
| 27f91e560d | |||
| a866ea8abd | |||
| d42895337d | |||
| 4ad239597e | |||
| 8257086921 | |||
| 7a7eba766d | |||
| c7b7df5011 | |||
| 2e526cd056 | |||
| dfa4fdd420 | |||
| 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 | |||
| 5cfc09121a | |||
| e365108244 | |||
| 0ade58a750 | |||
| 421998be54 | |||
| e3f7aa1304 | |||
| 6880ff1b97 | |||
| 0a5eb49359 | |||
| 284cecfcd9 | |||
| 974929e091 |
@@ -26,3 +26,6 @@ media/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# 部署过程产生的 git bundle 临时包
|
||||||
|
*.bundle
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ python -m app.sync.cli sync --entity friends # one entity
|
|||||||
python -m app.sync.cli --help # full usage
|
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)
|
### Docker mode (full stack)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -176,7 +184,7 @@ Entity names accepted by `sync --entity` and `run_once`:
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```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 yunkes && npm run build # clean production build
|
||||||
cd yunke-server && python -m app.sync.cli --help # CLI usage
|
cd yunke-server && python -m app.sync.cli --help # CLI usage
|
||||||
docker compose config # validates the stack
|
docker compose config # validates the stack
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# docker-compose.server.yml — 服务器(阿里云 ECS + 1Panel)专用生产编排。
|
||||||
|
#
|
||||||
|
# 与 docker-compose.yml(本地五服务开发栈)的差异:
|
||||||
|
# - 不含 mysql/redis:MySQL 复用 1Panel 已装实例(已建 yunke 库,用户权限
|
||||||
|
# 「所有区域(%)」,容器经宿主机内网 IP 访问,3306 不对公网);redis 应用
|
||||||
|
# 尚未接线(config 里只有一个默认 URL),省一份常驻内存——app 将来接
|
||||||
|
# redis 时记得同步回这里。
|
||||||
|
# - backend/scheduler 挂到 external 网络 1panel-network(1Panel 的 MySQL
|
||||||
|
# 容器 1Panel-mysql-YL0F 只绑 127.0.0.1:3306,宿主机网络够不着;加入它
|
||||||
|
# 所在网络后用容器名直连,不改 MySQL 任何配置)。MYSQL_HOST 填该容器名。
|
||||||
|
# frontend 不需要——它只跟 backend 说话。
|
||||||
|
# - 端口只绑 127.0.0.1:frontend 供 1Panel OpenResty(host 网络)反代;
|
||||||
|
# backend 不映射宿主机(/api 由 frontend 容器内 nginx 反代,见
|
||||||
|
# yunkes/nginx.conf)。
|
||||||
|
# - TZ=Asia/Shanghai:调度按北京时间触发(FULL_SYNC_HOUR=2 即北京 02:00)。
|
||||||
|
#
|
||||||
|
# 用法(服务器仓库根目录,完整步骤见 docs/DEPLOY.md):
|
||||||
|
# docker compose -f docker-compose.server.yml --env-file .env up -d --build
|
||||||
|
# docker compose -f docker-compose.server.yml logs -f scheduler
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./yunke-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- uvicorn
|
||||||
|
- app.api.main:app
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
DB_URL: mysql+asyncmy://${MYSQL_USER:-yunke}:${MYSQL_PASSWORD}@${MYSQL_HOST:?set MYSQL_HOST in .env}:${MYSQL_PORT:-3306}/${MYSQL_DATABASE:-yunke}
|
||||||
|
MEDIA_DIR: /data/media
|
||||||
|
YUNKE_BASE_URL: ${YUNKE_BASE_URL:-https://crm2.yunkecn.com}
|
||||||
|
YUNKE_USERNAME: ${YUNKE_USERNAME:-}
|
||||||
|
YUNKE_PASSWORD: ${YUNKE_PASSWORD:-}
|
||||||
|
YK_USERID: ${YK_USERID:-}
|
||||||
|
YK_COMPANY: ${YK_COMPANY:-}
|
||||||
|
YK_COOKIE: ${YK_COOKIE:-}
|
||||||
|
API_TOKEN: ${API_TOKEN:-}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
|
# 登录页账密(ADMIN_USER/ADMIN_PASSWORD,默认 admin/admin123,可在 .env 覆盖)
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- 1panel
|
||||||
|
volumes:
|
||||||
|
# 媒体 bind mount:服务器上 ./yunke-server/media 数据直观可见、好备份
|
||||||
|
- ./yunke-server/media:/data/media
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
build:
|
||||||
|
context: ./yunke-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- app.sync.cli
|
||||||
|
- scheduler
|
||||||
|
environment:
|
||||||
|
DB_URL: mysql+asyncmy://${MYSQL_USER:-yunke}:${MYSQL_PASSWORD}@${MYSQL_HOST:?set MYSQL_HOST in .env}:${MYSQL_PORT:-3306}/${MYSQL_DATABASE:-yunke}
|
||||||
|
MEDIA_DIR: /data/media
|
||||||
|
YUNKE_BASE_URL: ${YUNKE_BASE_URL:-https://crm2.yunkecn.com}
|
||||||
|
YUNKE_USERNAME: ${YUNKE_USERNAME:-}
|
||||||
|
YUNKE_PASSWORD: ${YUNKE_PASSWORD:-}
|
||||||
|
YK_USERID: ${YK_USERID:-}
|
||||||
|
YK_COMPANY: ${YK_COMPANY:-}
|
||||||
|
YK_COOKIE: ${YK_COOKIE:-}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
|
# 调度节奏(北京时间):每晚全量 + 每 4h 消息增量
|
||||||
|
FULL_SYNC_HOUR: ${FULL_SYNC_HOUR:-2}
|
||||||
|
MESSAGE_INTERVAL_HOURS: ${MESSAGE_INTERVAL_HOURS:-4}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- 1panel
|
||||||
|
volumes:
|
||||||
|
- ./yunke-server/media:/data/media
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./yunkes
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
# 只绑 127.0.0.1:公网流量一律走 1Panel OpenResty 反代(80/443),
|
||||||
|
# 5173 不暴露到安全组
|
||||||
|
- "127.0.0.1:5173:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# 1Panel 的 MySQL 容器所在网络(external:只挂靠,不创建不修改)
|
||||||
|
1panel:
|
||||||
|
name: 1panel-network
|
||||||
|
external: true
|
||||||
@@ -96,6 +96,9 @@ services:
|
|||||||
YK_COOKIE: ${YK_COOKIE:-}
|
YK_COOKIE: ${YK_COOKIE:-}
|
||||||
API_TOKEN: ${API_TOKEN:-}
|
API_TOKEN: ${API_TOKEN:-}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
|
# 登录页账密(默认 admin/admin123,可在 .env 覆盖)
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
volumes:
|
volumes:
|
||||||
# 媒体走 bind mount(不占 Docker 虚拟磁盘——本机实测 C 盘被 2.5GB
|
# 媒体走 bind mount(不占 Docker 虚拟磁盘——本机实测 C 盘被 2.5GB
|
||||||
# volume 撑爆导致引擎 500,2026-08-22);服务器上数据直观可见好备份
|
# volume 撑爆导致引擎 500,2026-08-22);服务器上数据直观可见好备份
|
||||||
|
|||||||
+232
-137
@@ -1,181 +1,276 @@
|
|||||||
# 云客数据镜像 — 服务器部署指南
|
# 云客数据镜像 — 服务器部署 runbook(1Panel 服务器,2026-09-03 实测落地版)
|
||||||
|
|
||||||
> 2026-08-21|适用于 Linux 服务器 + Docker Compose 五服务部署
|
> 形态:1Panel 管理,OpenResty 站点 + 公网高位端口,复用 1Panel MySQL,
|
||||||
|
> 数据从本地整体搬迁。本地开发栈(五服务 docker-compose.yml)不在本文范围。
|
||||||
|
> **实测拓扑**:服务器在路由器 NAT 后(网卡 192.168.100.x,公网 IP 为路由器,
|
||||||
|
> 公网 80 端口被同网其他设备占用)。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────┐
|
用户浏览器 ──https──▶ 1Panel OpenResty(host 网络, 80/443)
|
||||||
│ Docker Compose │
|
│ 反向代理
|
||||||
│ ┌────────┐ ┌────────┐ ┌────────────────────┐ │
|
▼
|
||||||
│ │ MySQL 8│ │ Redis 7│ │ backend (FastAPI) │ │
|
127.0.0.1:5173 frontend(nginx 容器)
|
||||||
│ │ :3306 │ │ :6379 │ │ :8000 │ │
|
├─ 静态页面(Vue SPA)
|
||||||
│ └────┬───┘ └────┬───┘ └─────────▲──────────┘ │
|
└─ /api/* ──容器网络──▶ backend:8000(FastAPI,不映射宿主机)
|
||||||
│ │ │ │ /api │
|
│
|
||||||
│ ┌────▼───────────▼───┐ ┌────────┴──────────┐ │
|
scheduler(Playwright+Chromium)│
|
||||||
│ │ scheduler │ │ frontend (nginx) │ │
|
抓 crm2.yunkecn.com ────────┤
|
||||||
│ │ APScheduler │ │ :5173 → :80 │ │
|
▼
|
||||||
│ │ + Playwright │ └───────────────────┘ │
|
宿主机内网IP:3306 1Panel MySQL(yunke 库)
|
||||||
│ │ + Chromium │ │
|
媒体:./yunke-server/media(bind mount)
|
||||||
│ └────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **scheduler**:每晚 02:00 全量 + 每 4h 消息增量 + 每周日 05:00 全量对齐(全部浏览器 fetch 零风控)→ 需要 Playwright + Chromium(镜像已内置)
|
- **站点只有一个**:前端容器内 nginx 已把 `/api` 反代到 backend,浏览器只跟
|
||||||
- **restart: unless-stopped**:容器崩溃自动重启(服务器上的守护方案,无需本地计划任务)
|
一个域名说话(不要给后端单独建站,会跨域+双证书)。
|
||||||
|
- **调度**:每晚 02:00 全量 + 每 4h 消息增量(北京时间,compose 已设 TZ)。
|
||||||
|
|
||||||
## 前置要求
|
## 0. 前置条件核对
|
||||||
|
|
||||||
- Linux 服务器(2C4G 起步,磁盘 ≥ 20GB:MySQL 数据 + 媒体文件 2.5GB+)
|
- [ ] ECS 已装 1Panel + Docker;建议 2C4G 起步,磁盘 ≥ 20GB(库 2.2GB + 媒体 2.5GB + 镜像 2.6GB)
|
||||||
- Docker 24+ 与 Docker Compose v2
|
- [ ] 1Panel 应用商店已装 **OpenResty**(建站依赖)
|
||||||
- 一个有效的云客会话 cookie(从浏览器登录云客后复制)
|
- [ ] 1Panel MySQL 版本 **8.x**;已建库 `yunke` + 专用用户,库权限设
|
||||||
|
「**所有区域(%)**」(容器从 docker 网段连入;3306 不对公网开,无风险)
|
||||||
|
- [ ] 对外访问走「1Panel 域名管理加 `公网IP:高位端口`(实测 32002)+
|
||||||
|
路由器端口转发到本机同端口」;**3306 / 5173 / 8000 不对公网开**
|
||||||
|
(公网 80 已被其他设备占用,域名+HTTPS 为将来可选项)
|
||||||
|
- [ ] Docker Hub 镜像加速:1Panel「容器→设置」里配加速器(拉 python/node/nginx
|
||||||
|
基础镜像用);apt/pip/npm/playwright 源已在 Dockerfile 内置国内镜像
|
||||||
|
- [ ] 服务器可访问 `crm2.yunkecn.com` 和 `*.oss.yunkecn.com`(抓取+媒体下载)
|
||||||
|
- [ ] 从本地 `.env` 拿到云客凭据:`YK_USERID` / `YK_COMPANY` / `YK_COOKIE`
|
||||||
|
|
||||||
## 部署步骤
|
## 1. 拿代码
|
||||||
|
|
||||||
### 1. 上传代码
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <你的仓库地址> yunke
|
# 部署目标与自建 Git 同一台机器——clone 走本机回环,不出公网;
|
||||||
cd yunke
|
# 后续升级 git pull 同样走 localhost,快且不依赖外网
|
||||||
|
git clone http://127.0.0.1:25311/xinyouhe/yunke.git /opt/yunke
|
||||||
|
cd /opt/yunke
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置 .env
|
> 可选:想在 1Panel 界面管理这个栈,用「容器→编排→创建编排」选路径
|
||||||
|
> `/opt/yunke`,指定 `docker-compose.server.yml`。命令行 `docker compose` 与
|
||||||
|
> 1Panel 编排管理的是同一套容器,不冲突。
|
||||||
|
|
||||||
|
## 2. 配 .env(仓库根目录)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp yunke-server/.env.example .env # 或手动创建
|
cd /opt/yunke && cat > .env <<'EOF'
|
||||||
```
|
# ---- 1Panel MySQL(在 1Panel「数据库」里建的 yunke 库)----
|
||||||
|
MYSQL_HOST=172.19.x.x # 宿主机内网 IP(ECS 控制台看,不是公网 IP)
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=yunke
|
||||||
|
MYSQL_USER=yunke
|
||||||
|
MYSQL_PASSWORD=<1Panel 里建库时设的密码>
|
||||||
|
|
||||||
必须设置的项:
|
# ---- 云客会话(从本地 .env 原样搬)----
|
||||||
|
|
||||||
```ini
|
|
||||||
# 云客会话(从浏览器 F12 → Network → 任意请求的 Cookie 头复制)
|
|
||||||
YK_USERID=<32位userId>
|
YK_USERID=<32位userId>
|
||||||
YK_COMPANY=<公司码>
|
YK_COMPANY=<公司码>
|
||||||
YK_COOKIE=user=xxx; userToken=yyy
|
YK_COOKIE=user=xxx; userToken=yyy
|
||||||
|
YUNKE_BASE_URL=https://crm2.yunkecn.com
|
||||||
|
|
||||||
# MySQL(docker-compose 自动创建库和用户)
|
# ---- 查看后台鉴权(公网必填!)----
|
||||||
MYSQL_ROOT_PASSWORD=<强密码>
|
API_TOKEN=<openssl rand -hex 32 生成>
|
||||||
MYSQL_PASSWORD=<强密码>
|
|
||||||
|
|
||||||
# 查看后台的访问令牌(留空=无鉴权,仅限内网)
|
# ---- 凭据加密密钥(必填)----
|
||||||
API_TOKEN=<随机长字符串>
|
ENCRYPTION_KEY=<python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())">
|
||||||
|
|
||||||
|
# ---- 调度(北京时间)----
|
||||||
|
FULL_SYNC_HOUR=2
|
||||||
|
MESSAGE_INTERVAL_HOURS=4
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 启动
|
## 3. 起栈
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env up -d --build
|
docker compose -f docker-compose.server.yml --env-file .env up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
首次构建约 5-10 分钟(含 Chromium 下载 ~150MB)。启动后:
|
首次构建 5-15 分钟(后端镜像 ~2.6GB 含 Chromium;源已配国内镜像)。
|
||||||
|
此时库还是空的,backend 日志可能有表不存在的报错——正常,下一步导数据。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps # 五服务全部 Up(healthy)
|
docker compose -f docker-compose.server.yml ps # 三服务 Up
|
||||||
docker compose logs -f scheduler # 看调度器日志
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 数据库迁移(alembic schema)
|
## 4. 数据搬迁(本地 → 服务器,压缩后约 3GB)
|
||||||
|
|
||||||
后端启动**不会**自动建表,schema 由 alembic 迁移链管理(镜像已打包
|
### 4.1 本地导出(Windows,仓库根目录)
|
||||||
`alembic.ini` + `migrations/`,`DB_URL` 由 compose 注入)。
|
|
||||||
|
|
||||||
**新库首次部署**(空 MySQL 直接建全量表):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec backend alembic upgrade head
|
# MySQL 一致性快照(--single-transaction 不锁表;ngram 全文索引随表定义导出)
|
||||||
```
|
docker compose exec mysql mysqldump -uroot -p<本地root密码> \
|
||||||
|
--single-transaction --routines --triggers --default-character-set=utf8mb4 \
|
||||||
|
yunke | gzip > yunke.sql.gz
|
||||||
|
|
||||||
**旧库升级**(2026-08-22 `migrate_sqlite_to_mysql.py` 搬迁产物,没有
|
# 媒体(2.5GB)
|
||||||
alembic_version 表)——2026-08-25 实战踩通的完整路径,原样记录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) 前置校验:chat_group 无 NULL owner_account_id(复合 PK 迁移要求 NOT NULL,
|
|
||||||
# 非 0 则是必须人工核对的数据问题,不要带病升级)
|
|
||||||
docker compose exec mysql mysql -uyunke -p<密码> yunke \
|
|
||||||
-e "SELECT COUNT(*) FROM chat_group WHERE owner_account_id IS NULL"
|
|
||||||
|
|
||||||
# 2) stamp 到搬迁脚本对应的链上位置,再升级到 head
|
|
||||||
docker compose exec backend alembic stamp d8eee96cd017
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
stamp 版本号不是拍脑袋定的:`d8eee96cd017` 是与 2026-08-22
|
|
||||||
`migrate_sqlite_to_mysql`(按当时模型 `create_all` 建表)产物 schema
|
|
||||||
一致的链上位置——stamp 它即声明「到此为止已应用」,其后迁移(chat_group
|
|
||||||
复合 PK、群主列、类型修正等)逐一正常补上。其它日期建的旧库须自行核对
|
|
||||||
表结构选 stamp 点,不要照抄这个版本号。
|
|
||||||
|
|
||||||
**每次拉新代码后**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ 类型变更类迁移(如 fe63c6789e94)在 MySQL 上为 COPY 算法、会锁
|
|
||||||
> `message` 等大表写入——避开抓取窗口(每 4h 增量、02:00 全量)执行。
|
|
||||||
|
|
||||||
涉及存量数据回填的更新另跑回填脚本(幂等,重跑 0 改动;脚本默认连
|
|
||||||
`./smoke.db`,服务器上须显式传 DB_URL——容器 env 已带,直接透传):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec backend python -c "import asyncio,os; from scripts.backfill_chat_group_fields import main; asyncio.run(main(os.environ['DB_URL']))"
|
|
||||||
docker compose exec backend python -c "import asyncio,os; from scripts.backfill_msg_types import main; asyncio.run(main(os.environ['DB_URL']))"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/api/health # {"status":"ok"}
|
|
||||||
curl http://localhost:5173/ # 前端页面
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 数据迁移(把本地 SQLite 数据搬到服务器 MySQL)
|
|
||||||
|
|
||||||
**方式 A:重抓(推荐,数据最新)**
|
|
||||||
|
|
||||||
服务器上手动跑一轮全量:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec scheduler bash
|
|
||||||
DB_URL="mysql+asyncmy://yunke:<密码>@mysql:3306/yunke" \
|
|
||||||
bash scripts/full_sync_all.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
耗时 6-12 小时(消息全量翻页),跑完后调度器自动接管日常增量。
|
|
||||||
|
|
||||||
**方式 B:SQLite → MySQL 数据搬迁**
|
|
||||||
|
|
||||||
把本地 smoke.db 和 media/ 目录传到服务器,用 Python 脚本逐表导:
|
|
||||||
(如有需要我可以写迁移脚本,~100 行)
|
|
||||||
|
|
||||||
### 7. 媒体文件
|
|
||||||
|
|
||||||
媒体存在 Docker volume `media_data` 里。本地已有 2.5GB 媒体的话:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 本地打包
|
|
||||||
tar czf media.tar.gz -C yunke-server media/
|
tar czf media.tar.gz -C yunke-server media/
|
||||||
|
|
||||||
# 传到服务器后灌入 volume
|
|
||||||
docker run --rm -v yunke_media_data:/target -v $(pwd):/src alpine \
|
|
||||||
sh -c "cd /target && tar xzf /src/media.tar.gz --strip-components=1"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 日常运维
|
本地 root 密码在本地根目录 `.env` 的 `MYSQL_ROOT_PASSWORD`。
|
||||||
|
|
||||||
|
### 4.2 传输
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -P 22 yunke.sql.gz media.tar.gz root@<ECS公网IP>:/opt/yunke/
|
||||||
|
```
|
||||||
|
|
||||||
|
(1Panel 文件管理上传也行,但 3GB 大文件 scp 更稳。)
|
||||||
|
|
||||||
|
### 4.3 服务器导入
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/yunke
|
||||||
|
|
||||||
|
# MySQL:1Panel MySQL 容器名在「容器」列表看(形如 1Panel-mysql-xxxx)
|
||||||
|
gunzip yunke.sql.gz
|
||||||
|
docker exec -i <1Panel-mysql容器名> mysql -uyunke -p<密码> \
|
||||||
|
--default-character-set=utf8mb4 yunke < yunke.sql
|
||||||
|
# ⚠️ 86 万行 message 的 ngram 全文索引导入时重建,约 12 分钟——慢是正常,
|
||||||
|
# 不要中断;期间导入脚本看似卡在索引阶段属预期。
|
||||||
|
|
||||||
|
# 媒体:解到 bind mount 目录(compose 已挂 ./yunke-server/media)。
|
||||||
|
# 包内根目录是 media/(打包时 -C yunke-server),必须 -C 解到 yunke-server 下
|
||||||
|
tar xzf media.tar.gz -C yunke-server
|
||||||
|
|
||||||
|
# 数据自带 alembic_version=head,不需要跑 alembic upgrade。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 导入后验证 + 补差量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <1Panel-mysql容器名> mysql -uyunke -p<密码> yunke \
|
||||||
|
-e "SELECT COUNT(*) FROM message; SELECT COUNT(*) FROM customer;"
|
||||||
|
# 数字应与本地一致(message ≈ 86 万)
|
||||||
|
|
||||||
|
# 补齐 dump 之后新产生的消息(增量,水位已随数据搬来)
|
||||||
|
docker compose -f docker-compose.server.yml exec scheduler \
|
||||||
|
python scripts/scrape_via_page.py --entity messages --max-conversations 200
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 站点与对外访问(实测:1Panel 端口模式)
|
||||||
|
|
||||||
|
1. 1Panel「网站→创建网站→反向代理」:代理地址 `127.0.0.1:5173`(已建则跳过)
|
||||||
|
2. 1Panel「网站→设置→域名管理」添加一条 `公网IP:高位端口`(实测
|
||||||
|
`219.157.255.213:32002`)→ 站点多监听该端口
|
||||||
|
3. 路由器端口转发:`外部 32002 → 192.168.100.132:32002`
|
||||||
|
4. 访问 `http://219.157.255.213:32002`,别人零配置直接用
|
||||||
|
5. 502 排查:栈是否 Up、`curl 127.0.0.1:5173` 是否通(OpenResty 是 host 网络,
|
||||||
|
1Panel 默认)
|
||||||
|
|
||||||
|
将来要 HTTPS:买真实域名 → 1Panel 证书走 **DNS 验证**(HTTP 验证需要 80,
|
||||||
|
已被占)→ 路由器加 443 转发。
|
||||||
|
|
||||||
|
前端登录鉴权即 `.env` 的 `API_TOKEN`(`Authorization: Bearer <token>` 或
|
||||||
|
`?token=`;**公网部署绝不能留空**)。
|
||||||
|
|
||||||
|
## 6. 验收清单
|
||||||
|
|
||||||
|
- [ ] `docker compose -f docker-compose.server.yml ps` 三服务 Up
|
||||||
|
- [ ] `curl http://127.0.0.1:5173/` 返回前端页面
|
||||||
|
- [ ] `curl http://127.0.0.1:8000/api/health` 返回 `{"status":"ok"}`
|
||||||
|
- [ ] 域名 HTTPS 可打开,客户列表/聊天记录有数据(抽查语音列表→聊天记录)
|
||||||
|
- [ ] 不带 token 调 `/api/*` 返回 401(鉴权生效)
|
||||||
|
- [ ] scheduler 日志无 cookie 过期告警:`docker compose -f docker-compose.server.yml logs scheduler`
|
||||||
|
- [ ] 安全组复核:3306 / 5173 / 8000 未对公网开放
|
||||||
|
|
||||||
|
## 7. 日常运维
|
||||||
|
|
||||||
| 操作 | 命令 |
|
| 操作 | 命令 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 看调度日志 | `docker compose logs -f scheduler` |
|
| 看调度日志 | `docker compose -f docker-compose.server.yml logs -f scheduler` |
|
||||||
| 手动触发全量 | `docker compose exec scheduler bash scripts/full_sync_all.sh` |
|
| 手动触发全量 | `docker compose -f docker-compose.server.yml exec scheduler bash scripts/full_sync_all.sh` |
|
||||||
| 换 cookie | 改 `.env` 的 `YK_COOKIE` → `docker compose restart scheduler backend` |
|
| 换云客 cookie | 改 `.env` 的 `YK_COOKIE` → `docker compose -f docker-compose.server.yml up -d`(重建注入 env) |
|
||||||
| 备份 MySQL | `docker compose exec mysql mysqldump -uroot -p<密码> yunke > backup.sql` |
|
| 备份 MySQL | `docker exec <1Panel-mysql容器名> mysqldump -uyunke -p<密码> --single-transaction yunke | gzip > /opt/backups/yunke-$(date +%F).sql.gz` |
|
||||||
| 备份媒体 | `docker run --rm -v yunke_media_data:/data -v $(pwd):/out alpine tar czf /out/media.tgz -C /data .` |
|
| 备份媒体 | `tar czf /opt/backups/media-$(date +%F).tgz -C yunke-server media/` |
|
||||||
| 升级版本 | `git pull && docker compose up -d --build && docker compose exec backend alembic upgrade head`(回填见「数据库迁移」) |
|
| 升级版本 | `git pull && docker compose -f docker-compose.server.yml --env-file .env up -d --build` |
|
||||||
|
|
||||||
## 已知注意事项
|
- 备份可挂 1Panel「计划任务」(Shell 脚本),如每日 03:00 跑上面两条。
|
||||||
|
- **升级时若有新 alembic 迁移**:加跑
|
||||||
|
`docker compose -f docker-compose.server.yml exec backend alembic upgrade head`
|
||||||
|
(类型变更类迁移在 MySQL 上锁大表写入,避开抓取窗口:北京 02:00 全量、
|
||||||
|
每 4h 增量;增量耗时约 15-30 分钟)。
|
||||||
|
|
||||||
1. **cookie 过期**:云客会话定期失效,过期后日志出现 `⚠️ cookie 可能已过期` 提示 → 从浏览器重新复制 `YK_COOKIE` 并 restart
|
### 数据分析板块首部署附加步骤(一次性,2026-09-04)
|
||||||
2. **首次消息全量很慢**(6-12h),之后增量每轮 15-30 分钟
|
|
||||||
3. **FTS 搜索**:MySQL 上自动回退 LIKE(暂无全文索引);如搜索量大可在 MySQL 建 FULLTEXT 索引
|
alembic 链自当前线上版本起新增 3 支迁移:`a7d3e9f1c2b4` msg_daily_stats
|
||||||
4. **时区**:容器默认 UTC;调度按容器时区触发(FULL_SYNC_HOUR=2 即 UTC 02:00)。要北京时间可在 compose 加 `TZ: Asia/Shanghai`
|
日汇总表、`f0a1b2c3d4e5` 分析板块四新表 + `message.fetched_at` 索引、
|
||||||
5. **服务器需能访问 crm2.yunkecn.com 和 *.oss.yunkecn.com**(抓取 + 媒体下载)
|
`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`。
|
||||||
|
|
||||||
|
### 管理后台批次A附加步骤(一次性,2026-09-09)
|
||||||
|
|
||||||
|
迁移链新增 `b7c8d9e0f1a2`(经营管理域六表:first_reply_fact /
|
||||||
|
employee_metric_daily / employee_call_daily / employee_score_config /
|
||||||
|
employee_score_snapshot / management_followup)。**服务器 backend 容器
|
||||||
|
不会自动跑 alembic**,须手动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose -f docker-compose.server.yml exec backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
部署带该迁移后在**夜间低峰**跑两条一次性回填(幂等;本地实测耗时:
|
||||||
|
fact 72s / 宽表+通话 71s,均分钟级):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli first-reply-facts
|
||||||
|
sudo docker compose -f docker-compose.server.yml exec backend python -m app.sync.cli employee-metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
- `first-reply-facts`:跨日精确首响事实表(本地实测 11,020 会话 → 6.8 万行)。
|
||||||
|
- `employee-metrics`:员工指标宽表 + 通话日小表(本地实测 12,371 宽表行 +
|
||||||
|
1,163 通话行);**必须在 first-reply-facts 之后跑**——fact 表是后续评分/
|
||||||
|
下钻的数据源,须先填充(宽表自身从 message 表经同一算法重算串类列,
|
||||||
|
不读 fact 表,先跑宽表不会出 0,但 fact 缺失会阻塞后续能力)。
|
||||||
|
- 两命令均为按日 delete+insert 幂等重建,复跑结果一致,中断重跑无副作用。
|
||||||
|
|
||||||
|
## 8. 本地收尾(部署验证通过后)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop yunke-scheduler-1 # 停本地调度,避免两端同时抓取触发云客风控
|
||||||
|
```
|
||||||
|
|
||||||
|
- 本地栈保留作开发环境,连本地 MySQL;此后本地改代码 → `git push` →
|
||||||
|
服务器 `git pull` 升级,两边数据各自独立。
|
||||||
|
- 本地 backend/frontend(8000/5173)按需启停,不影响服务器。
|
||||||
|
|
||||||
|
## 9. 已知注意事项
|
||||||
|
|
||||||
|
1. **cookie 过期**:云客会话定期失效,scheduler 日志出现
|
||||||
|
`⚠️ cookie 可能已过期` → 浏览器重新复制 `YK_COOKIE` 并按上表换。
|
||||||
|
2. **时区**:server 编排已设 `TZ=Asia/Shanghai`,02:00 即北京时间全量。
|
||||||
|
3. **搜索性能**:message.text 已建 ngram FULLTEXT 索引(迁移 b41e8fa0c7d2),
|
||||||
|
数据搬迁导入时索引重建约 12 分钟属正常;重导后无需手工处理。
|
||||||
|
4. **服务器需能访问云客域名**(抓取 + OSS 媒体下载),ECS 出网正常即满足。
|
||||||
|
5. **MySQL 权限**:yunke 库用户须允许 `%` 或 docker 网段来源;3306 靠安全组
|
||||||
|
兜底不对公网暴露。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
|||||||
|
# 批次A Task 0 核实记录:五项核实 + 回测名单
|
||||||
|
|
||||||
|
- **日期**:2026-09-09
|
||||||
|
- **方式**:`docker exec -i yunke-mysql-1 mysql -uyunke -pyunke yunke -e "<SQL>"`(容器名以 `docker ps` 实测为准,确认为 `yunke-mysql-1`)
|
||||||
|
- **用途**:本记录的全部结论直接约束批次A(数据底座)后续任务的实现细节;偏差处显式标注「影响 Task N」。任务编号引用 [2026-09-08-management-console-batch-a-data-foundation.md](./2026-09-08-management-console-batch-a-data-foundation.md)。
|
||||||
|
- **数据快照范围**:`stat_daily_staff` / `msg_daily_stats` 覆盖 20251024 ~ 20260907;`phone_call.start_call_time` 覆盖 2025-10-18 ~ 2026-09-05。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ① 员工主数据(staff 表)
|
||||||
|
|
||||||
|
| 指标 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| staff 总数 | **41**(与计划假设 ≈41 一致) |
|
||||||
|
| `is_on_job=1` | 41(全部在职) |
|
||||||
|
| `wechat_account` DISTINCT user_id | 41 |
|
||||||
|
| `whatsapp_account` DISTINCT user_id | 20 |
|
||||||
|
| 微信幽灵账号(user_id 不在 staff) | **0** |
|
||||||
|
| WA 幽灵账号(补充核实) | **0** |
|
||||||
|
| `stat_daily_staff` DISTINCT user_id | 40(其 user_id 全部在 staff 内;员工 `FD685A5620184156BE75A8D73FB72F64` 无任何消息统计行) |
|
||||||
|
|
||||||
|
**结论**:账号表 → staff 无幽灵映射,`known_user_ids` 过滤(v2 §1.3 #11)零损耗,不会意外丢行。两个渠道的 account_map 产出 user_id 均能落到 staff。**无偏差,Task 5/6 按计划实现即可。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ② phone_call 列与单位
|
||||||
|
|
||||||
|
表结构关键列:`call_id`(PK)、`channel`、`start_call_time`(bigint)、`call_duration`(int)、`call_status`(int)、`incoming_call`(int)、`user_id`、`call_number`、`fetched_at`、`updated_at`。
|
||||||
|
|
||||||
|
### channel 分布
|
||||||
|
|
||||||
|
| channel | 行数 |
|
||||||
|
|---|---|
|
||||||
|
| `voice_call` | 2,504 |
|
||||||
|
| `wa_voice_call` | 686 |
|
||||||
|
| 合计 | **3,190**(与已知 3,190 条一致) |
|
||||||
|
|
||||||
|
### call_duration 单位:**秒**(确认)
|
||||||
|
|
||||||
|
| 指标 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| 总和 | 263,651 秒 = **73.24 小时**(对照已知约 73 小时,吻合) |
|
||||||
|
| 均值 | 82.65 秒(对照预期均值 ≈82 秒,吻合) |
|
||||||
|
|
||||||
|
### call_status × duration 交叉
|
||||||
|
|
||||||
|
| call_status | 行数 | duration=0 | duration>0 | 语义判定 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | 1,508 | 31 | 1,477 | **接通**(含 31 条 0 秒即挂) |
|
||||||
|
| 2 | 1,682 | 1,682 | 0 | **未接通**(未接/拒接/取消,与方向无关) |
|
||||||
|
|
||||||
|
### incoming_call 分布
|
||||||
|
|
||||||
|
| incoming_call | 行数 | 语义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 1,764 | 呼入 |
|
||||||
|
| 0 | 1,426 | 呼出 |
|
||||||
|
|
||||||
|
### start_call_time:**毫秒 epoch**(确认)
|
||||||
|
|
||||||
|
13 位整数;`FROM_UNIXTIME(x/1000)` 解出 max = 2026-09-05 16:00:55(合理近期),按秒解释则超范围。min = 2025-10-18。与 `message.timestamp` 毫秒同口径。
|
||||||
|
|
||||||
|
### call_number 形态(按 channel 完全可分)
|
||||||
|
|
||||||
|
- `voice_call` → 微信 ID:`wxid_*` 或自定义微信号(`csf7169708`、`yuanye751380`、`Maple_1988` 等)
|
||||||
|
- `wa_voice_call` → WA JID:`2348182388888@s.whatsapp.net` 等
|
||||||
|
|
||||||
|
### 与计划默认假设的偏差(重要)
|
||||||
|
|
||||||
|
1. **计划假设 #4「PhoneCall.channel 是通话类型(phone_call/voice_call)非消息渠道」不完全成立**:实测无 `phone_call`(蜂窝)值,且 `wa_voice_call` **能**从 channel 列判别 WA。计划 Task 5 注释「voice_call 行无法从列判别微信/WA」不成立。**影响 Task 5**:`_followup_exists` 可按渠道收窄——`voice_call` 只查该员工 wechat 账号的 1:1 消息、`wa_voice_call` 只查 whatsapp 账号(当前双渠道任一匹配的写法也不会错——wxid 不会撞 WA JID,仅多一次无效查询;收窄属优化非必需)。
|
||||||
|
2. `call_duration` 单位=秒、`start_call_time`=毫秒 epoch:**与计划默认一致,Task 5 无需换算**(`day_bounds_ms` 直接用)。
|
||||||
|
3. 接通判定:计划默认 `duration>0`。实测 `call_status=1 AND duration>0` = 1,477 行,`duration>0` 口径与之差 31 行(31 条接通但 0 秒,占 1.0%)。**影响 Task 5**:`answered=dur>0` 口径可用;若追求语义精确可改 `call_status=1`(含 0 秒接通),差异 ≤1%,实现时任选其一并在表注释写明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ③ customer 归属字段填充率
|
||||||
|
|
||||||
|
| 指标 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| 总行数 | 16,339 |
|
||||||
|
| `user_id` 填充 | 16,339(**100%**) |
|
||||||
|
| `owner_account_id` 填充 | 16,339(**100%**) |
|
||||||
|
| `delete_flag=1` | 53 |
|
||||||
|
|
||||||
|
分渠道:wechat 7,040(owner 填充 7,040)/ whatsapp 9,299(owner 填充 9,299)。
|
||||||
|
|
||||||
|
### 两条归属路径一致性(全表校验,非抽样)
|
||||||
|
|
||||||
|
`customer.user_id` vs `owner_account_id → wechat_account.wx_id / whatsapp_account.uid → user_id`:
|
||||||
|
|
||||||
|
| 渠道 | 校验行数 | 一致 |
|
||||||
|
|---|---|---|
|
||||||
|
| whatsapp | 9,299 | 9,299 |
|
||||||
|
| wechat | 7,040 | 7,040 |
|
||||||
|
|
||||||
|
**结论**:两条路径 100% 等价、零冲突。生命周期归属覆盖面完整。**影响 Task 6**:默认 `owner_account_id → account_map → user_id` 路径确认可用;`customer.user_id` 列可直接当冗余等价路径使用(如需省一次 join,实测安全)。
|
||||||
|
|
||||||
|
补充观察(**影响 Task 3/6 的 peer 对齐**):**微信侧主体是无后缀裸 ID,后缀形态是少数**。`customer.friend_id` 实测形态分布(全表):
|
||||||
|
|
||||||
|
| 渠道 | 形态 | 行数 | 占比 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| wechat | **无后缀裸 ID**(`a_515855826`、`a053136a` 等) | 6,779 | **96.3%** |
|
||||||
|
| wechat | `@openim` 后缀 | 261 | 3.7% |
|
||||||
|
| whatsapp | `@s.whatsapp.net` | 9,266 | 99.6% |
|
||||||
|
| whatsapp | `@lid` 后缀 | 33 | 0.4% |
|
||||||
|
|
||||||
|
`message.talker_id` 侧同查(行数口径):wechat 裸 ID 844,287 / `@openim` 16,873 / `@chatroom` 17,003(群);whatsapp `@s.whatsapp.net` 72,357 / `@lid` 4,284 / `@g.us` 9,439(群)。即:微信 peer 主键以裸 ID 为准、`@openim` 为少数共存;WA 侧基本全带后缀。Task 3 实现 peer 匹配时按实测形态对齐(两边原值相等才命中),不做猜测性截断/补后缀。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ④ 群占比(msg_daily_stats,量化混合口径偏差)
|
||||||
|
|
||||||
|
| channel | peer_kind | 行数 | 消息量(send+recv) | 会话线程(cust_threads) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| wechat | friend | 38,343 | 269,074 | 82,879 |
|
||||||
|
| wechat | room | 16,234 | 609,089 | 26,220 |
|
||||||
|
| whatsapp | friend | 11,673 | 77,994 | 14,178 |
|
||||||
|
| whatsapp | room | 866 | 8,086 | 1,402 |
|
||||||
|
| **合计** | | **67,116** | **964,243** | **124,679** |
|
||||||
|
|
||||||
|
占比汇总:
|
||||||
|
|
||||||
|
| 维度 | room 占比 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 微信消息量 | **69.4%**(609,089/878,163) | 微信消息近七成来自群 |
|
||||||
|
| WA 消息量 | 9.4%(8,086/86,080) | WA 几乎纯 1:1 |
|
||||||
|
| 总消息量 | **64.0%**(617,175/964,243) | |
|
||||||
|
| 总会话线程 | **22.2%**(27,622/124,679) | |
|
||||||
|
|
||||||
|
**结论**:若现有看板首响/回复率不分 `peer_kind`,消息量维度被群污染 64%、线程维度 22%——污染主要来自微信侧(微信消息 69% 是群消息)。批次A `first_reply_fact` / `employee_metric_daily` 强制 `peer_kind='friend'` 过滤是必要的,不能沿用混合口径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⑤ call_followup join 键(call_number ↔ message.talker_id,1:1 消息)
|
||||||
|
|
||||||
|
| channel | 分母(call_number 非空) | 命中(talker_id 匹配且非群消息) | 命中率 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `voice_call` | 2,504 | 2,378 | **95.0%** |
|
||||||
|
| `wa_voice_call`(补充核实) | 686 | 681 | **99.3%** |
|
||||||
|
|
||||||
|
**结论**:双渠道命中率均远超 50% 阈值,`call_followup_count` 一期**高置信**,无需降级标记、无需在口径中注记低置信。**影响 Task 5**:24h 跟进判定按计划实现。未命中成因已精确解释(复核补充):`voice_call` 的 126 条未命中**恰好等于** `call_number` 带 `@chatroom` 后缀的 126 条群通话(实测这 126 条对 1:1 消息全部 0 命中——join 限定 `room_id` 为空,群通话本就不该匹配);剔除群通话后 1:1 可匹配通话命中率为 **2,378/2,378 = 100%**。`wa_voice_call` 侧后缀分布为 `@s.whatsapp.net` 676 / `@g.us`(群)9 / `@lid` 1,未命中 5 条**全部为** `@g.us` 群通话(逐条列出验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回测员工名单
|
||||||
|
|
||||||
|
选取依据:消息量/WA 量/活跃群排名 + 低样本端(见文末各候选分渠道数据)。覆盖:微信为主、WA 为主、群运营为主、高负载、低样本(前三类与高负载有合并)。
|
||||||
|
|
||||||
|
| # | user_id | 名字 | 覆盖类型 | 关键数据(20251024~20260907) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `369D257EEC9A4357935FDE19DDD99CAA` | 王云青 | **高负载 + 群运营为主 + 微信为主**(合并) | 消息量 #1(162,740:微信 162,371 / WA 369);活跃群 #1(4,036);通话 431+9 |
|
||||||
|
| 2 | `9C0B195A2C234C829FF507E011B4B460` | 田瑞志 | **WA 为主** | WA 消息 9,986 / 总 11,597 = 86%;WA 通话 28 |
|
||||||
|
| 3 | `F99F6557C2904A7E8F4C5A3BD8A597A1` | 张士蓓 | **双通道混合**(WA 量 #1,群 #2) | 微信 91,936 + WA 12,529;活跃群 2,273;通话最多(471 微信 + 97 WA) |
|
||||||
|
| 4 | `DC48798A5EA24DBB8B31A89CB858E08B` | 15639270965 | **纯微信无 WA 行**(验证 WA 缺失路径) | 微信 78,680,`stat_daily_staff` 无 whatsapp 行;通话 3 |
|
||||||
|
| 5 | `946C08639BF84DFEBBF5EC2A607AE1FA` | 18539430257 | **低样本**(验证空数据路径) | 消息 8 条,无群、无通话、无 WA |
|
||||||
|
|
||||||
|
> 名单中的 #1/#3 建议作为 Task 8「本地全量回填实测」的 2 名回测员工(数据最厚、双通道+通话+群全齐)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 偏差汇总(对后续任务的约束清单)
|
||||||
|
|
||||||
|
| # | 偏差/确认 | 影响 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `phone_call.channel` 实测仅 `voice_call`(微信)/`wa_voice_call`(WA)两值,**无蜂窝 `phone_call` 值**;channel 可判别渠道,计划「无法从列判别微信/WA」结论修正 | Task 5(followup 可按渠道收窄,属优化非必需) |
|
||||||
|
| 2 | `call_duration` 单位=**秒**(263,651s=73.24h≈已知 73h);`start_call_time`=**毫秒 epoch** | Task 5(默认口径正确,无需换算) |
|
||||||
|
| 3 | 接通判定 `dur>0` vs `call_status=1` 差 31 行(1.0%),前者少计 31 条 0 秒接通 | Task 5(任选其一,注释写明) |
|
||||||
|
| 4 | `customer.owner_account_id` 填充 **100%**,与 `user_id` 列全表 100% 等价 | Task 6(默认 owner 路径确认;可直接用 user_id 列省 join) |
|
||||||
|
| 5 | 微信侧 peer ID **主体为无后缀裸 ID**(customer.friend_id 6,779/7,040=96.3%;message.talker_id 行数口径 844,287 裸 vs 16,873 `@openim`),后缀形态(`@openim` 等)为少数;WA 侧基本全带后缀(`@s.whatsapp.net`/`@lid`) | Task 3/6(peer 对齐按实测形态原值匹配,勿截断/勿补后缀) |
|
||||||
|
| 6 | 群消息占总消息量 64%(微信侧 69%) | Task 3/6(friend 过滤必须,佐证 spec 口径) |
|
||||||
|
| 7 | call_number↔talker_id 命中率 voice_call 95.0% / wa_voice_call 99.3% | Task 5(call_followup_count 高置信,不降级) |
|
||||||
|
| 8 | staff 41 行无幽灵账号;`stat_daily_staff` 仅 40 名员工有行 | Task 5/6(known_user_ids 过滤零损耗) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 批次A回填实测与对账(Task 8,2026-09-09)
|
||||||
|
|
||||||
|
- **环境**:本地 Docker MySQL(`yunke-mysql-1`),数据快照同上(stat/msg 覆盖 20251024~20260907)。
|
||||||
|
- **方式**:Task 7 真机全量回填后本任务只读对账抽查(未重跑回填);对账前全库同源校验复验一次,确认库状态即 Task 7 收尾状态。
|
||||||
|
|
||||||
|
## 1. 回填实测汇总(Task 7 真机数字,Task 8 复验)
|
||||||
|
|
||||||
|
| 项目 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| `first-reply-facts` | 11,020 会话 → **68,476** fact 行,**72s**(冷) |
|
||||||
|
| `employee-metrics` | **12,371** 宽表行(day×channel×user)+ **1,163** 通话行(**314** 天并集),**71s** 冷 / 36s 热 |
|
||||||
|
| 幂等复跑 | 两命令复跑结果一致(delete+insert 语义,无重复无漂移) |
|
||||||
|
| 全库同源 | `SUM(employee_metric_daily.threads_answered)` = **68,476** ≡ fact 行数(Task 8 复验通过) |
|
||||||
|
| 孤儿检查 | 宽表/通话表 user_id 不在 staff = **0**;fact 账号不在对应渠道账号表 = **0**(Task 8 复验通过) |
|
||||||
|
| 通话日并集修复 | `employee_call_daily` 覆盖 **314** 天(修复前 306):8 个消息空窗日找回 27 通话 / 13 接通 |
|
||||||
|
|
||||||
|
## 2. 对账抽查(2 员工 × 3 日期 × 三组)
|
||||||
|
|
||||||
|
员工取自本文回测名单:**王云青** `369D257EEC9A4357935FDE19DDD99CAA`(#1,高负载+群运营,混合口径差异最大场景;1 微信 + 2 WA 账号)、**田瑞志** `9C0B195A2C234C829FF507E011B4B460`(#2,WA 为主;1 微信 + 2 WA 账号)。日期 20260815 / 20260801 / 20260601(近/中/远)。跨渠道员工按 channel 分别对账(①②组逐渠道逐日须一致)。
|
||||||
|
|
||||||
|
判定标准:**①组(消息计数列 ↔ msg_daily_stats friend 聚合)与 ②组(串类列 ↔ fact 行数)必须逐日一致;③组(宽表串类 cust_threads ↔ 日切 stat_daily_staff.cust_threads)允许差异,差值=跨夜串归属效应。**
|
||||||
|
|
||||||
|
### 王云青
|
||||||
|
|
||||||
|
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| wechat | 20260601 | 186 / 177 | 186 / 177 | ✅ | 76 | 76 | ✅ | 76 | 121 | 45 |
|
||||||
|
| wechat | 20260801 | 0 / 38 | 0 / 38 | ✅ | 0 | 0 | ✅ | 0 | 16 | 16 |
|
||||||
|
| wechat | 20260815 | 0 / 23 | 0 / 23 | ✅ | 0 | 0 | ✅ | 0 | 17 | 17 |
|
||||||
|
| whatsapp | 20260601 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||||
|
| whatsapp | 20260801 | 0 / 1 | 0 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||||
|
| whatsapp | 20260815 | 0 / 4 | 0 / 4 | ✅ | 1 | 1 | ✅ | 1 | 2 | 1 |
|
||||||
|
|
||||||
|
### 田瑞志
|
||||||
|
|
||||||
|
| 渠道 | 日期 | ① mds sent/recv | ① 宽表 sent/recv | ① 判定 | ② fact 行 | ② 宽表 answered | ② 判定 | ③ 宽表 threads | ③ 日切 threads | ③ 差值 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| wechat | 三日期均 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||||
|
| whatsapp | 20260601 | 1 / 1 | 1 / 1 | ✅ | 1 | 1 | ✅ | 1 | 1 | 0 |
|
||||||
|
| whatsapp | 20260801 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | 2 | 2 |
|
||||||
|
| whatsapp | 20260815 | (无行) | 0 / 0 | ✅ | (无行) | 0 | ✅ | 0 | (无行) | — |
|
||||||
|
|
||||||
|
注:「无行」= 源表该 (渠道, 日) 无该员工数据,宽表记 0/0——一致。20260601 无 WA 数据未换日期:田瑞志 whatsapp 该日恰好有 1 条收发(对账有效),王云青 whatsapp 全期仅 369 条消息、无行属真实分布。
|
||||||
|
|
||||||
|
**结论:①②组 12/12 逐日全部一致(含渠道拆分);③组宽表全部 ≤ 日切值。**
|
||||||
|
|
||||||
|
## 3. 跨夜串差值观察(③组差异解释)
|
||||||
|
|
||||||
|
- **归属语义**:宽表串按「串末条客户消息所在上海自然日」归属(`first_reply_facts.compute_conversation`);日切表按「当日有客户消息的 peer 数」计。串末条必在归属日当天 ⇒ 结构上保证 ③组逐日 宽表 ≤ 日切,差值=归属到其他日终了的跨夜/多日串。
|
||||||
|
- **差值形态**:王云青 wechat 三日差 45/16/17——该员工深夜消息多(20260801/15 发送 0、仅收 38/23 条,客户消息跨零点后串归属顺延);whatsapp 差 0/1。田瑞志 whatsapp 20260801 差 2(2 个 peer 当日来消息但串末条在次日)。
|
||||||
|
- **串未丢失验证**:王云青 wechat 全期 宽表 12,055 threads(11,930 answered,314 天)vs 日切 20,981(14,647 answered,312 天)——串模型把多日串折叠为 1(比值 57.4%),且抽查 20260814~31 逐日,日切 17 线程的 20260815 对应串终落在真实结束日(0819=8、0821=10、0828=9),无凭空丢失。
|
||||||
|
- 另:宽表 `cust_threads ≥ threads_answered`(如王云青 wechat 20260602 = 54/52)——未闭合尾串计入分母不产 fact 样本,与算法注释一致。
|
||||||
|
|
||||||
|
## 4. 批次A验收清单(Task 8 收尾核对)
|
||||||
|
|
||||||
|
| # | 项 | 状态 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 6 张表本地存在,migration 幂等 + downgrade 干净 | ✅ | Task 2(迁移 `b7c8d9e0f1a2`,alembic head 实测一致;表实测在库:first_reply_fact / employee_metric_daily / employee_call_daily / employee_score_config / employee_score_snapshot / management_followup) |
|
||||||
|
| 2 | first-reply-facts 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||||
|
| 3 | employee-metrics 全量回填成功且复跑幂等 | ✅ | Task 7(本文 §1) |
|
||||||
|
| 4 | 跨夜串判别测试通过 | ✅ | Task 3(test_first_reply_facts.py 恰 12 例,Task 8 复跑全绿;含跨夜/多日串/同毫秒并列) |
|
||||||
|
| 5 | friend-only 口径判别测试通过 | ✅ | Task 6(test_all_friend_pairs_only_friend + 宽表 friend 过滤判别例) |
|
||||||
|
| 6 | 同源判别测试通过 + 全库 SUM 验证 | ✅ | Task 3/6;全库 68,476 ≡ 68,476(本文 §1 复验) |
|
||||||
|
| 7 | 幽灵员工过滤测试通过 | ✅ | Task 5/6;孤儿=0(本文 §1 复验) |
|
||||||
|
| 8 | 对账抽查 ①②一致、③差异可解释 | ✅ | 本文 §2/§3 |
|
||||||
|
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
|
||||||
|
| 10 | 核实文档完整 | ✅ | 本文补齐 |
|
||||||
|
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 批次B冒烟(Task B5,2026-09-09)
|
||||||
|
|
||||||
|
经营管理后台六端点真机冒烟 + 性能初测。回测员工王云青 `369D257EEC9A4357935FDE19DDD99CAA`(#1 高负载+群运营),窗口 30 天 `20260810~20260908`。
|
||||||
|
|
||||||
|
## 0. 环境与启动方式
|
||||||
|
|
||||||
|
- **基线**:`pytest tests/ -q` → **721 passed in 60.9s**(批次B收尾后全绿)。
|
||||||
|
- **后端启动**:docker 无 `yunke-backend/frontend-1` 容器;冒烟前 8000 端口空闲(连接拒绝,无常驻进程)。本任务自起:`yunke-server/.venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000`(无 `--reload`),`GET /api/health` → `{"status":"ok"}`;`openapi.json` 含 management 路由(grep 命中)确认新代码已加载。
|
||||||
|
- **鉴权模式**:`.env` 中 `API_TOKEN=`(空值)→ 鉴权中间件 no-op,**免鉴权直接 curl**(无 Authorization 头)。
|
||||||
|
- **进程处置**:uvicorn 留跑供用户查看,Git Bash PID **2423**(`kill 2423` 关闭;或任务管理器结束对应 python.exe)。
|
||||||
|
|
||||||
|
## 1. 六端点结果(HTTP 码 × 耗时 × 大小)
|
||||||
|
|
||||||
|
首次访问含冷启动(连接池/查询编译/InnoDB 冷缓存),dashboard 另测暖态三次:
|
||||||
|
|
||||||
|
| 端点 | HTTP | 首次耗时 | 暖态耗时 | 大小 | 判定 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `/api/management/dashboard` | 200 | 12.03s | **2.92 / 2.39 / 2.18s** | 22,309B | ⚠️ 超 1.5s 目标(慢段定位见 §2) |
|
||||||
|
| `/api/management/employees?page_size=50` | 200 | 0.34s | 0.30 / 0.31s | 58,865B | ✅ ≤1.5s |
|
||||||
|
| `/api/management/employees/{王云青}` | 200 | 0.40s | — | 4,739B | ✅ |
|
||||||
|
| `…/metrics?section=service` | 200 | 0.40s | — | 4,655B | ✅ |
|
||||||
|
| `…/drilldown?metric_code=first_reply_p90` | 200 | 0.016s | — | 5,268B | ✅ |
|
||||||
|
| `/api/management/metric-definitions` | 200 | 0.005s | — | 4,081B | ✅ |
|
||||||
|
|
||||||
|
## 2. dashboard 慢段定位(分段实测,不做优化——决策归批次F)
|
||||||
|
|
||||||
|
用 app 同一 session 复放 dashboard 组装流水线逐段计时(合计 2.685s ≈ 暖态端点耗时):
|
||||||
|
|
||||||
|
| 段 | 耗时 |
|
||||||
|
|---|---|
|
||||||
|
| load_score_config | 0.044s |
|
||||||
|
| assemble_employees(评分+异常,2 窗聚合) | 0.262s |
|
||||||
|
| wide_daily_rows / call_daily_rows | 0.007s / 0.002s |
|
||||||
|
| service_daily_trend | 0.032s |
|
||||||
|
| data_as_of | 0.009s |
|
||||||
|
| **action_counts(unreplied+churn+sensitive)** | **2.328s** |
|
||||||
|
|
||||||
|
action_counts 内部:`unreplied(hours=2)` **2.38s**;`churn(real,30d)` 0.12s;sensitive count 0.005s。unreplied 再拆:候选窗口函数查询(row_number over 67,116 行 friend 分区 → 3,123 候选)仅 **0.10~0.16s**;>500 候选走「仅本页决赛圈」分支无 N+1,但**单条尾部消息点查耗时 1.7~1.9s**。EXPLAIN 实证:`channel=None`(全平台)时无法使用 channel 打头的全部索引(`ix_message_channel_owner_talker_time(channel,owner,talker,timestamp)` 等),优化器退化到 `ix_message_room`(`room_id IS NULL/''`)→ **扫 378,202 行 + filesort**(message 表 96.4 万行)。
|
||||||
|
|
||||||
|
优化方向(供批次F选择):① 查询改写按渠道 UNION/`channel IN (…)` 绑定前缀走现有索引;② 补 `(owner_account_id, talker_id, timestamp)` 无 channel 前缀索引;③ dashboard 对 action_counts 做短 TTL 缓存。除 unreplied 外全链路 <0.5s,达标无压力。
|
||||||
|
|
||||||
|
## 3. 合理性抽查
|
||||||
|
|
||||||
|
- **employees 列表**:`total=41`/rows=41(=本文① staff 总数 ✓)。**王云青行与宽表 SQL 聚合逐字段全等**:msg_sent=111 / msg_recv=1003 / cust_threads=89 / threads_answered=57 / fr_sum_ms=698,140,844、days_with_data=29。数量级说明:「千级」预期对应全期全口径(全期 162,740 条消息/12,055 串,群聊为主);本窗 friend 口径 recv=1003 千级 ✓,sent=111 为真实分布——同窗全口径 recv≈2.0 万(stat_daily_staff 实测) vs friend 1,003,差额几乎全是群消息;cust_threads=89 是串模型多日串折叠(本文 §3 已验证折叠比 57.4%)+本窗 friend 活动偏低。
|
||||||
|
- **dashboard kpis**:active_employees=**40**(~40 ✓,=本文① stat 有数员工数)、active_customers=2,195、msg_total=42,422、cust_threads=9,330、answered=8,460、call 263/接通 113;lifecycle new/cooling/dormant/reactivated=231/1,432/3,353/145。**anomalies=28(critical 3 / warning 25)**,首条 `reply_p90_worsened` critical:P90 324.98min vs 基线期 18.06min(+1700%,样本 62、涉及 51 客户)。dimension_summary:risk/action 全 41 pending(批次C接线,符合预期),service scored 27/insufficient 4/pending 10、均分 56.0。趋势 30 点,末日 20260908 全零——数据止于 20260907(本文快照范围),边界正确。
|
||||||
|
- **detail(王云青)**:`score.trial=true` ✓、grade=**E**(total=39,scored 非 insufficient)、dimensions:risk/action **pending** ✓(批次C)、service 47.8 / customer 0.0 / workload 100;含 accounts/prev/baseline/anomalies。
|
||||||
|
- **metrics section=service**:数值与宽表窗聚合一致 + trend 30 点 ✓。
|
||||||
|
- **drilldown first_reply_p90**:total=**57**=该员工账号窗内 fact 行数(=threads_answered ✓)。首行 fr_ms=**498,242,238ms≈5.8 天**——超「分钟~小时」预期,但与源表全等(fact MAX 同值);窗内全库更极端值达 1,617,406,247ms≈18.7 天(thread_end 08-14 → reply 09-02,沉睡客户复活串),属真实数据形态非计算缺陷。该员工 p50=97,153ms(1.6min)/p90=1,651,758ms(27.5min)分钟级 ✓。极值是否 winsorize/封顶归批次F口径决策。
|
||||||
|
- **metric-definitions**:19 项定义(msg_sent…call_followup_rate)+ score_config + caliber_notes,`config_source="db"`(评分配置 v1.0 种子生效 ✓)。
|
||||||
|
|
||||||
|
**结论:六端点全 200、数据与库逐字段可对;唯一性能缺口=dashboard 的 unreplied 行动计数(channel 无前缀索引可用),暖态 2.2~2.9s,其余端点全部达标。**
|
||||||
|
|
||||||
|
## 4. 批次B遗留观察项归档(终评补录,2026-09-09)
|
||||||
|
|
||||||
|
| # | 项 | 归档 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | call_band 样本基数:band 类指标样本量取 active_friends(规格字面),WA 通话量上来后"通话多客户少"员工会被误标 insufficient——当前数据零影响(实测 calls≥1 且 af<10 仅 5 人且 calls≤5,怎么选都不足);归批次F回测前一行修正(sample_basis 字段或按源指标取样本) |
|
||||||
|
| 2 | 配置源名唯一性:两个 code 指向同一源指标会使 employees comparisons 键静默覆盖——已在 validate_config 加源名唯一校验+测试(批次B终评闭环),默认配置六个源名互异不受影响 |
|
||||||
|
| 3 | dashboard 性能(2.2~2.9s,unreplied channel=None 无前缀索引)与 fr_ms 天级极值 winsorize:见上文 §2/§3,决策归批次F |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 批次D走查与验收(Task D4,2026-09-10)
|
||||||
|
|
||||||
|
批次D收尾:员工总览(b72e957)+ 员工详情(5776175)+ 驾驶舱环比/异常跳员工闭环(422a726,前置接口/时间预设 ddeda3f)。Playwright 真机七步全链路走查 + v1 §19.1 员工子集与批次D验收清单逐项核对。环境:后端 uvicorn 8000(免鉴权)+ 前端 vite dev 5173,视口 **1366px**。
|
||||||
|
|
||||||
|
## 0. 四门禁(全绿)
|
||||||
|
|
||||||
|
| 门禁 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 `pytest tests/ -q` | **723 passed** in 65.29s |
|
||||||
|
| 前端 `npm run build` | ✓ built in 1.03s(exit 0) |
|
||||||
|
| 前端 `npm run test`(vitest) | Test Files 2 passed / **Tests 23 passed** |
|
||||||
|
|
||||||
|
## 1. 七步动线走查记录(数字为真机实测,关键值已对 API 复核)
|
||||||
|
|
||||||
|
**① 驾驶舱默认近30天(20260812~20260910)**:三 KPI 卡环比箭头 ✓(客户端自 prev_kpis 按 (cur-prev)/prev 计算,API 复核数值吻合)——全渠道:活跃客户 **1,992人 ↓12.6%**(prev 2,278)/ 活跃员工 **40人 →0.0%**(40/40)/ 消息量 **37,742条 ↓21.0%**(prev 47,749);切微信:**979人 ↓25.3% / 40人 ↑2.6% / 27,949条 ↓29.6%**。方向与量级合理(等长紧邻前窗)。
|
||||||
|
|
||||||
|
**② 异常卡员工名 → 员工详情**:点微信异常榜员工名链接(落点须为 a.el-link;整行点击触发行下钻抽屉,属预期交互)→ 落地 `/management/employees/18B5EE6B1AD94B15A5CD1CDABCECAB96?channel=wechat&start=…&end=…`(驾驶舱当前筛选 query 随行 ✓)。
|
||||||
|
|
||||||
|
**③ 员工详情(朱晨曦,综合 21/E)**:五维条 **3 实 + 2 待数据**——合规风险/行动执行 pending 灰条占位,服务质量 0 分/客户经营 0 分/有效负载 100 分(API dimensions 逐项一致;行序随接口返回序,见遗留①)。四对比表(当前/上期/基线/中位)✓(cust_threads 62/114/107+中位)。服务质量页签双折线渲染(canvas 1074×300)→「查看超时明细」→ 首响P90 抽屉 **Total 11** → 点行进聊天:**17 条消息**,时间线与所点明细行吻合(客户 08-31 11:59:12 → 回复 17:24:11 ≈ 5小时24分)。
|
||||||
|
|
||||||
|
**④ 客户经营页签**:返回列表再进 → section=customer →「沉睡客户名单」抽屉 **Total 89** ✓。
|
||||||
|
|
||||||
|
**⑤ 员工总览对比**:勾选 2 人 → 对比抽屉并列两列指标 ✓(2-5 人为按钮启用门槛),关闭复原。
|
||||||
|
|
||||||
|
**⑥ 排序+翻页+CSV(微信渠道)**:综合分降序(服务端 sort)——第 1 页 **55→21**(林峰 55 领头),第 2 页 **11 / 1 / 其余「--」**(18 人 insufficient_sample **空值排尾** ✓)。导出 CSV:**3 行口径前言**(口径/窗口+渠道/试算版本)**+ 表头 + 41 行数据**,toast「已导出 41 条」。
|
||||||
|
|
||||||
|
**⑦ 控制台与持久化**:走查全程 console **0 errors**;修复遗留告警后(见 §3)全新加载 **0 warnings**。刷新保持 ✓——列表 view=service 刷新还原(Total 41)、详情返回后 section=service 还原。
|
||||||
|
|
||||||
|
**附**:9 档时间预设逐一真机核验:今日 0910~0910 / 昨日 0909 / 近7 0904~0910 / 近30 0812~0910 / 本周 0907~0910(周一周首 ✓)/ 上周 0831~0906 / 本月 0901~0910 / 上月 0801~0831 / 自定义 0901~0907。1366px 驾驶舱与员工总览双页截图目检:KPI 3×2 网阵、双趋势图、表格列均无塌陷/无横向溢出。
|
||||||
|
|
||||||
|
## 2. 验收清单核对
|
||||||
|
|
||||||
|
**v1 §19.1 员工子集(6 项)**
|
||||||
|
|
||||||
|
| # | 项 | 状态 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 能查看所有员工多维状态 | ✅ | total=41(API=列表=CSV 行数三方一致);四视图(管理概览/工作负载/服务质量/客户经营)切换正常 |
|
||||||
|
| 2 | 任一分数可展开为指标/公式/样本/事实 | ✅ | 表头「?」→ 指标口径抽屉(19 项定义+公式+样本);行/详情下钻按钮 → 事实明细 → 聊天原文(§1 ③) |
|
||||||
|
| 3 | 四对比 | ✅ | 详情四对比表逐列可见(§1 ③:62/114/107/中位) |
|
||||||
|
| 4 | 样本不足不评分 | ✅ | 18 人 insufficient_sample 综合分显「--」(§1 ⑥),CSV 同口径 |
|
||||||
|
| 5 | 异常两次点击进事实 | ✅ | 异常卡员工名(第1击)→ 详情下钻按钮(第2击)→ 事实明细/聊天 |
|
||||||
|
| 6 | 默认排序非总分/消息量榜 | ✅ | 默认 sort_by=anomaly_severity(异常严重度优先) |
|
||||||
|
|
||||||
|
**批次D验收清单(5 项)**
|
||||||
|
|
||||||
|
| # | 项 | 状态 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释 | ✅ | §1 ⑤⑥;CSV 前言 3 行 |
|
||||||
|
| 2 | 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/下钻进抽屉/账号跳聊天 | ✅ | §1 ③④;页签懒加载+loaded 缓存+section URL 还原 |
|
||||||
|
| 3 | 驾驶舱:三 KPI 环比箭头/异常卡可跳员工详情 | ✅ | §1 ①②(闭环打通) |
|
||||||
|
| 4 | 时间预设全套 9 档 | ✅ | §1 附 |
|
||||||
|
| 5 | URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐 | ✅ | §1 ⑦ + §0;后端改动=dashboard prev_kpis(D3,随测试提交) |
|
||||||
|
|
||||||
|
## 3. 走查发现并修复(随本任务提交)
|
||||||
|
|
||||||
|
| 问题 | 修复 |
|
||||||
|
|---|---|
|
||||||
|
| `AnomalyList.vue` 员工链接 `:underline="false"` 触发 Element Plus 废弃 API 告警——驾驶舱每次加载刷 **31 条** `[el-link] underline boolean deprecated`(异常榜每行一条) | 改 `underline="never"`(string API);修复后全新加载 0 errors 0 warnings |
|
||||||
|
|
||||||
|
## 4. 遗留观察项(不阻塞)
|
||||||
|
|
||||||
|
| # | 项 | 归档 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 五维条行序随接口返回序 | 合规风险/行动执行(待数据)排在三实维之前——非固定语义序;批次E 接线 risk/action 后自然消解,如需固定序前端按 dim 顺序数组排即可 |
|
||||||
|
| 2 | 走查任务字面 Step6 措辞偏差 | 「切视图服务质量→点综合分列排序」按字面不可执行(综合分列仅管理概览视图有);实际动线=管理概览排序→切服务质量视图导出,已按实际动线完成走查 |
|
||||||
|
| 3 | 空值显示 `-` 与 `--` 混用 | format.ts 的 formatMsDuration/formatRatio 按各自注释约定 null 显 `-`,员工总览本地数值/比率格式化显 `--`——同表跨列观感不一;纯观感项,批次E 顺手统一即可 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 批次F-调度接入验证(Task F3,2026-09-10)
|
||||||
|
|
||||||
|
F1(`_analytics_tick` 段2 管理域滚动,0dc5993)/ F2(夜间链管理步,2ac63e3)已提交后,本地真机直调 tick 验证 + 本地 scheduler 镜像重建。方法:临时脚本(已删)按 tests/test_scheduler_analytics_tick.py 的构造方式 `SyncScheduler(engine=create_async_engine(get_settings().db_url), session_maker=...)`,不 start、只 `await scheduler._analytics_tick()`,对六个上游函数挂计时包装(与测试 monkeypatch 同一延迟 import 打点位),连跑两次。
|
||||||
|
|
||||||
|
## 1. tick 两段实测(本地真机库,连跑两次)
|
||||||
|
|
||||||
|
| 段 | 内容 | 第1次(冷) | 第2次(暖) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 总计 | `_analytics_tick` 全程 | **2.78s** | **0.74s** |
|
||||||
|
| 段1 | recompute_days(staff 滚动3天) | 0.86s | 0.15s |
|
||||||
|
| 段1 | scanner.run_incremental(敏感词增量) | 0.14s | 0.02s |
|
||||||
|
| 段2 | pairs_active_in_days(fact 活跃对发现) | 0.04s | 0.02s |
|
||||||
|
| 段2 | recompute_pairs(fact 重算) | 0.00s | 0.00s |
|
||||||
|
| 段2 | recompute_metric_days(宽表滚动3天) | 1.51s | 0.52s |
|
||||||
|
| 段2 | recompute_call_days(通话滚动3天) | 0.22s | 0.02s |
|
||||||
|
|
||||||
|
(段值不含各自 session.commit;日志输出:`staff_days=0 scanned=10 hits=0` + `mgmt fact_pairs=0 wide_rows=168 call_rows=0`,两次一致。)
|
||||||
|
|
||||||
|
- **远低于 <60s 预算**。本地镜像已停同步 7 天,滚动窗(0908~0910)内无消息/通话活动——`staff_days=0`、`fact_pairs=0`、`call_rows=0` 属停同步后的数据真空而非缺陷;`wide_rows=168`= 3 天 × 56 行/天(39 员工,wechat 38 + whatsapp 18 行/天,双渠道员工两行),宽表对零活跃员工照常出零值行。段2 主成本=宽表重算,随员工数×天数扩展而非会话数,服务器侧活跃会话数百的形态下同量级。
|
||||||
|
- **幂等 ✓**:三表行数跑前 fact=68,476 / wide=12,371 / calls=1,163 → 第1次后 68,476 / **12,539** / 1,163 → 第2次后 68,476 / 12,539 / 1,163。wide 的 +168 为首跑补齐:0908~0910 三天行在表内原本不存在(本地夜间链停摆 7 天没写过),第1次 DELETE+INSERT 补齐后第2次行数纹丝不动——滚动窗语义下幂等成立。
|
||||||
|
|
||||||
|
## 2. 本地 scheduler 容器重建(范围仅本地;服务器镜像 2026-09-09 已重建不碰)
|
||||||
|
|
||||||
|
- `docker compose up -d --build scheduler`:镜像 `yunke-scheduler:latest`(`ded96dfc9625`,2026-09-10 构建)重建成功,容器 recreate + 起来。
|
||||||
|
- 冒烟:`docker exec yunke-scheduler-1 python -c "import app.sync.scheduler"` → ok;容器日志确认 **4 个 job 全注册**——3×`_safe_page_run`(full/messages/full-history)+ **`SyncScheduler._analytics_tick`(F1 新 job 已进夜间/4h 链)**,"Scheduler started" 正常。
|
||||||
|
- **调度状态**:验证完即 `docker stop`——重建前容器本就 Exited 7 天(本地不跑调度,避免与服务器调度器双跑同刷云客触发风控),维持 stopped 现状;容器已绑新镜像,用户随时 `docker compose start scheduler` 即以新代码上线。mysql/redis 未动。
|
||||||
|
|
||||||
|
## 3. 已知边界(归档)
|
||||||
|
|
||||||
|
| # | 项 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | phone_call 未进页面同步链 | 通话小表(employee_call_daily)暂从既有静态源重生成——本地 call_rows=0 即此边界的体现;审计 P0-5 由并行会话领地,不在此处理 |
|
||||||
|
| 2 | 补同步历史日期 | tick 只滚动当日+前2天;更早日期的修正由夜间全量(full_sync 汇总/管理步)覆盖语义兜底 |
|
||||||
|
| 3 | 本地数据真空 | 本地镜像停摆 7 天,0908~0910 无消息/通话——fact/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
|||||||
|
# 云客经营管理后台实施计划 v2(贴合代码修订版)
|
||||||
|
|
||||||
|
- **日期**:2026-09-08
|
||||||
|
- **版本**:v2.0
|
||||||
|
- **状态**:待评审后进入开发
|
||||||
|
- **与 v1 的关系**:替代 `2026-09-08-yunke-management-console-implementation.md`(plans/ 与 specs/ 两份副本内容相同)的**技术设计部分**;产品范围、页面内容细则、验收标准沿用 v1,本文不重复,冲突处以本文为准
|
||||||
|
- **上游方案**:[云客数据智能管理平台完整实施计划](./2026-09-08-yunke-data-intelligence-management-plan.md)
|
||||||
|
- **修订方式**:对 v1 逐项做「现有代码 ↔ 文档要求」查漏补缺,钉在真实代码事实上重新设计底座
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 已确认的决策记录
|
||||||
|
|
||||||
|
| 决策项 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 一期范围 | 按 v1 原计划全量:管理驾驶舱 + 员工总览 + 员工详情 + 考核快照(Task 0-13) |
|
||||||
|
| 业务口径 | 全按 v1 §21 默认值:自然时间 SLA、2h+24h 双 SLA、30 天沉睡、综合分标「试算」、CSV 导出、仅管理层可见、个人基线=过去 4 周 |
|
||||||
|
| 底座方案 | 方案 B:新增首响事实表 + 收敛版员工宽表(v1 架构骨架不变) |
|
||||||
|
| 关键修正 | 见 §1 修订总览——fact 表独立计算(跨日精确算法)、宽表 1:1 口径全量自算、生命周期沿用距今天数口径 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 相对 v1 的修订总览
|
||||||
|
|
||||||
|
### 1.1 P1 级(正确性/口径,必须修正)
|
||||||
|
|
||||||
|
| # | 问题 | 证据 | 修正 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **跨夜串丢失**:现有日切算法下「客户 23:00 发→员工次日 9:00 回」的首响样本整条丢失(Day1 串未闭合只计 threads;Day2 重起算时无客户消息不成串,员工回复落空段)。P90/24h 回复率系统性低估,而评分重度依赖这两项 | `app/storage/summary.py` `_thread_stats` 日切近似,docstring 自述「跨夜串归属日偏差」实为样本丢失 | fact 表**不挂写钩子**,由 4h 滚动+夜间全量任务用**跨日精确串算法**(按会话全历史计算)独立生成;写钩子零改动,`_thread_stats` 不改 |
|
||||||
|
| 2 | **stat_daily_staff 是 friend+room 混合口径**:`sent/recv/threads/answered/fr` 五列聚合无 kind 过滤,群消息污染首响/回复率。v1 说「从 stat_daily_staff 拷贝」不可行 | `app/storage/staff_summary.py` `recompute_days` 聚合循环 | 宽表消息计数列从 msg_daily_stats 按 `peer_kind='friend'` 自算(account_map 归并模式复用);串类三列由 fact 模块同源输出(见 #5);管理页与现有看板排行的数字差异属口径不同,各自标注 |
|
||||||
|
| 3 | **「降温」定义冲突**:上游整合计划(`2026-09-08-yunke-data-intelligence-management-plan.md` §8.1/§15)降温=频次低于个人基线;现有 churn/客户资产分层=距今天数区间。两套「降温」会并存打架 | `app/analytics/metrics.py` churn 三分法 | 一期生命周期 4 列统一用**现有距今天数口径**;频次基线降温后置二期;差异写入 metric-definitions |
|
||||||
|
| 4 | **对账口径必须预写死**:fact(跨日精确)与 msg_daily_stats 三列(日切近似)即使 friend 口径也不相等,差集=跨夜闭合串 | 同 #1 | Task 2 验收断言:fact 样本集 ⊇ 日切被回串,差集=跨夜串;禁止相等断言 |
|
||||||
|
| 5 | **回复率分子分母必须同源**:v2 初稿宽表串类列(cust_threads 等)取自 msg_daily_stats 日切聚合,而回复率分子取自 fact(跨日)——多日未回串使日切分母膨胀,回复率被系统性低估 | 本轮复查发现(#2 修正的连带遗漏) | 宽表串类三列改由 fact 计算模块**同源输出**(按串末条归属日的 threads/answered/fr_sum);msg_sent/recv 与 active_friends/rooms 与串算法无关,仍取 msg_daily_stats |
|
||||||
|
|
||||||
|
### 1.2 P2 级(性能/实现修正)
|
||||||
|
|
||||||
|
| # | 问题 | 修正 |
|
||||||
|
|---|---|---|
|
||||||
|
| 6 | 周期分位数若 Python 拉全窗口 fact 行(月窗最坏 ~8 万行 ≈10MB 传输)不可取 | 改 SQL 窗口函数 `PERCENT_RANK() OVER (PARTITION BY user_id ORDER BY fr_ms)`(MySQL 8.4 / SQLite 3.25+ 双方言支持),传输降为员工数×2 行 |
|
||||||
|
| 7 | fact 4h 增量成本 | 滚动 3 天=近 3 天活跃会话**全历史重算**(最大 friend 会话 5.2 万条 ≈1-2s × 几十活跃会话,4h 一次可接受);一期取简单正确,预留「尾部增量」优化(历史已闭合串不变) |
|
||||||
|
| 8 | full_sync_all.sh 步数变化 | 12→**14** 步:第 13 步 fact 全量重建、第 14 步宽表全量重建(宽表必须在 fact 之后) |
|
||||||
|
| 9 | **通话列渠道错位**:v2 初稿把通话四列放进 (day, channel, user) 宽表,但 `phone_call.channel` 是**通话类型**(phone_call/voice_call)非消息渠道(models.py docstring 自证「calls are not tied to a messaging channel」)——channel=all 聚合时通话四列双计 | `app/db/models.py` PhoneCall 定义(批次A计划定案) | 通话拆出独立小表 `employee_call_daily`(day, user_id 主键,无渠道维),宽表删去通话四列 |
|
||||||
|
|
||||||
|
### 1.3 P3 级(补充明确/简化)
|
||||||
|
|
||||||
|
| # | 项 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| 10 | 员工主数据来源未核实 | 列入 Task 0 核实(默认 = `staff` 表,user_id 主键 + is_on_job,models.py 已有) |
|
||||||
|
| 11 | 幽灵员工 | account_map 映射不上的 owner(离职/异常账号),宽表生成时直接**过滤**,不进管理域员工列表(评分场景不适用 analytics 的「读侧标注」策略) |
|
||||||
|
| 12 | 功能开关降级 | 现状单 admin 账号无角色可分,v1 Task 13 的「功能开关」是过度设计;改为**上线顺序控制**(本地先行→服务器后上),RBAC 二/三期(与 v1 §15 一致) |
|
||||||
|
| 13 | fr_ms 边界 | 按 ts 排序保证非负;超长样本(>30 天)保留在 fact,评分按异常裁剪原则处理(上游整合计划 §6.5「极端值不直接放大分数」):>30 天样本参与分布展示,但不作为 0 分依据,单独列入超时明细 |
|
||||||
|
| 14 | 五维分布图 | 一期只画 3 个可用维度(客户经营/服务质量/有效负载)+ 2 个「待数据」占位(行动执行/风险合规),不暗示完整五维 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 查漏补缺:复用映射总表
|
||||||
|
|
||||||
|
| v1 能力项 | 现有可复用 | 本次动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 员工日指标底座 | `stat_daily_staff`(8 列,混合口径)、`msg_daily_stats`(首响三列,日切近似) | 两表**均不动**;新增 `first_reply_fact` + `employee_metric_daily`(§3) |
|
||||||
|
| 时间窗/环比口径 | `app/analytics/metrics.py` `scope_day_keys`/`_prev_scope_days`(5 种窗+同期对齐) | management 层直接 import,不复制时区逻辑 |
|
||||||
|
| 生命周期分层 | `metrics.py` 客户资产分层 / churn 三分法(距今天数口径) | 提炼共享函数,日聚合任务与 analytics 共用;analytics 调用点保持不变(包装层) |
|
||||||
|
| 首响均值/回复率算法 | `staff_ranking` 的 fr_avg/reply_rate | 周期值改为宽表 SUM + fact 表精确分位 |
|
||||||
|
| 异常检测雏形 | `anomaly`(昨日 vs 前 7 日均值偏离>50%) | `management/anomalies.py` 扩展为规则集:回复恶化(fact P90)、待回复增长(B2)、活动骤降/过载(宽表) |
|
||||||
|
| 驾驶舱页面 | `DashboardView.vue` 的图表模式、`useChart`、筛选栏、骨架屏/空态分语 | 新建 ManagementDashboardView,复用全部 composables/utils;旧页并存过渡后重定向 |
|
||||||
|
| 流失/该回未回/敏感词 | 三个现有页面 | 不动,作为下钻目标 + 驾驶舱行动入口聚合 |
|
||||||
|
| 数据截至/新鲜度 | `app/analytics/health.py` 的 data_as_of 逻辑 | 管理页「数据截至/完整性」角标直接复用 |
|
||||||
|
| 调度 | `analytics_tick`(4h:staff 汇总+敏感扫描) | 追加第三步:fact 滚动重算 + 宽表滚动重算 |
|
||||||
|
| 回填/自愈 | `full_sync_all.sh` 第 11/12 步 | 追加第 13/14 步(fact/宽表全量重建)+ 两条回填 CLI |
|
||||||
|
| 员工事实下钻 | `/staff-chat`、`/chat`、`/groups`、`/calls` | 不动,管理页只做链接跳转 |
|
||||||
|
| CSV 导出 | `csvExport.ts`(全量翻页+中文 BOM) | 直接复用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据库设计:6 张新表
|
||||||
|
|
||||||
|
### 3.1 `first_reply_fact` 首响事实表(本设计新增)
|
||||||
|
|
||||||
|
**用途**:任意时间窗精确 P50/P90、2h/24h 回复率分子、指标解释抽屉的构成明细下钻证据。
|
||||||
|
|
||||||
|
**算法(跨日精确串口径,区别于 msg_daily_stats 的日切近似)**:
|
||||||
|
|
||||||
|
- 串 = 同一会话内客户连续消息(is_send=False)不被员工消息(is_send=True)打断的段;is_send=None 跳过;
|
||||||
|
- 每个被回串一个样本:`fr_ms = 员工首条回复 ts − 串末条客户消息 ts`;
|
||||||
|
- 样本归属 = 串末条消息所在**上海自然日**(跨夜串不再丢失);
|
||||||
|
- 只写 `peer_kind='friend'`(1:1)行——群串语义不成立且占串数大头,不写;
|
||||||
|
- 模块同时输出**每日串聚合**(threads/answered/fr_sum,同按串末条归属日)供宽表消费——回复率分子分母同源(§1.1 #5);
|
||||||
|
- 按会话全历史计算,幂等模式=按会话 delete+insert。
|
||||||
|
|
||||||
|
**表结构**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
唯一键: (channel, owner_account_id, peer_id, thread_end_ts, reply_ts)
|
||||||
|
列: day int 串末条所在上海日 yyyymmdd(冗余物化,索引用)
|
||||||
|
thread_end_ts BigInteger 串末条客户消息 ts(ms)
|
||||||
|
reply_ts BigInteger 员工首条回复 ts(ms)
|
||||||
|
fr_ms BigInteger
|
||||||
|
索引: (day, channel) 趋势/窗口查询;唯一键自身覆盖会话下钻
|
||||||
|
```
|
||||||
|
|
||||||
|
**生成链路(不挂写钩子)**:
|
||||||
|
|
||||||
|
- 4h tick:对近 3 天有消息的会话(从 msg_daily_stats 近 3 天行取 pairs)做全历史重算;
|
||||||
|
- 夜间全量:full_sync_all.sh 第 13 步,遍历全部 friend 会话(复用 `recompute_all` 的 pairs 遍历模式);
|
||||||
|
- 回填 CLI:`python -m app.sync.cli first-reply-facts`,分批可重跑可记水位;
|
||||||
|
- 量级:日均几百行、全年几十万行,远小于 message 表。
|
||||||
|
|
||||||
|
### 3.2 `employee_metric_daily` 员工指标宽表(v1 表的收敛版)
|
||||||
|
|
||||||
|
**口径总则**:消息串类列 = 1:1(friend-only)口径,从 msg_daily_stats 自算;与现有看板(friend+room 混合)的数字差异属口径不同。
|
||||||
|
|
||||||
|
```text
|
||||||
|
PK: (day, channel, user_id, metric_version)
|
||||||
|
|
||||||
|
消息计数列(msg_daily_stats 自算,friend 过滤 + account_map 归并;与串算法无关):
|
||||||
|
msg_sent, msg_recv
|
||||||
|
串类列(fact 计算模块同源输出,跨日精确、按串末条归属日——与回复率分子同口径):
|
||||||
|
cust_threads, threads_answered, fr_sum_ms
|
||||||
|
负载列(msg_daily_stats,friend/room 分行天然区分):
|
||||||
|
active_friends(1:1 distinct 客户), active_rooms(distinct 群)
|
||||||
|
首响日值(趋势图用,从 fact 算):
|
||||||
|
first_reply_p50_ms, first_reply_p90_ms
|
||||||
|
生命周期 4 列(距今天数口径,当日持有客户状态分布,按 customer 当前归属):
|
||||||
|
new_customer_count, cooling_customer_count,
|
||||||
|
dormant_customer_count, reactivated_customer_count
|
||||||
|
元数据:
|
||||||
|
data_complete, computed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**通话小表 `employee_call_daily`**(§1.2 #9 拆出,无渠道维):
|
||||||
|
|
||||||
|
```text
|
||||||
|
PK: (day, user_id)
|
||||||
|
列: call_count, call_seconds, answered_call_count, call_followup_count, computed_at
|
||||||
|
数据源: phone_call 按 user_id+start_call_time 日聚合;接通=call_duration>0;
|
||||||
|
followup=通话后 24h 内该员工任一账号对该客户有消息(Task 0 核实 join 键)
|
||||||
|
```
|
||||||
|
|
||||||
|
**相对 v1 的删减与更名**:
|
||||||
|
|
||||||
|
- 6 列可派生不落:`reply_2h/24h_count`、`unreplied_2h/24h_count`(fact 表 fr_ms≤阈值计数 + cust_threads 派生)、`first_reply_count/first_reply_sum_ms`(≡ threads_answered/fr_sum_ms);
|
||||||
|
- `effective_sessions` 不单列:v1 §6.3 工作负载视图的「有效会话」≡ cust_threads(1:1 有客户消息串的天级会话数),页面字段名从 cust_threads 映射;
|
||||||
|
- `activated_customer_count` 一期不做:「有效激活」定义是 v1 §21 #4 未决口径(几轮互动/几天窗口),随二期频次基线生命周期一起定义;
|
||||||
|
- `customer_threads` 更名为 `cust_threads`(与 msg_daily_stats/stat_daily_staff 现有列名对齐)。
|
||||||
|
|
||||||
|
**metric_version 语义**:只影响派生列(生命周期/通话跟进的算法口径);消息计数与串类事实列与版本无关。默认值 `v1`,算法演进时新版本并行落表不覆盖旧数据。
|
||||||
|
|
||||||
|
**时效说明**:消息计数列的源数据(msg_daily_stats)随写钩子近实时更新,串类列的源数据(fact)随 4h/夜间重算更新——两类列的上游时效不同,但宽表行统一随 4h/夜间重算落库;computed_at 取宽表生成时间,data_complete 标注 fact 是否已覆盖当日。
|
||||||
|
|
||||||
|
**评分用周期值的计算来源**(不在宽表存储):周期 P50/P90、2h/24h 回复率一律从 fact 表精确计算(SQL 窗口函数),宽表日值仅供趋势展示。
|
||||||
|
|
||||||
|
### 3.3 `employee_score_config` / `employee_score_snapshot` / `management_followup`
|
||||||
|
|
||||||
|
按 v1 §11.1 原样实施,无修改:
|
||||||
|
|
||||||
|
- config:version 唯一、status、effective_from/to、config_json(维度/指标/权重/方向/阈值/样本门槛),激活前权重总和与阈值校验;
|
||||||
|
- snapshot:snapshot_batch_id、period_type/period、score_config_version、metric_version、total_score、grade、score_status(scored/insufficient_sample/incomplete_data)、dimension_scores_json、metric_details_json、data_as_of、frozen_at/frozen_by、supersedes_id;同批次员工唯一;冻结后业务层禁改;
|
||||||
|
- followup:source_type/code/period/value、reason、action_type、owner_manager_id、status(open/in_progress/resolved/cancelled)、due_at、result、employee_feedback;索引 `(employee_user_id, status, due_at)`、`(owner_manager_id, status)`。
|
||||||
|
|
||||||
|
### 3.4 迁移要求
|
||||||
|
|
||||||
|
- 一个 Alembic migration 建 5 张表;migration 只建表和索引,**不扫描消息**;
|
||||||
|
- 数据回填走独立 CLI 分批执行(可暂停/重跑/记水位),先在本地 Docker 快照验证耗时与一致性;
|
||||||
|
- downgrade 只删本次新增对象;SQLite(测试)/MySQL(生产)双方言;
|
||||||
|
- 升级前确认当前 head(服务器注意:backend 容器**不自动跑 alembic**,须手动 `exec backend alembic upgrade head`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 计算链路与调度
|
||||||
|
|
||||||
|
```text
|
||||||
|
message 入库(写钩子,全路径覆盖——现状,零改动)
|
||||||
|
→ msg_daily_stats [summary.py]
|
||||||
|
→ stat_daily_staff [staff_summary.py,现状]
|
||||||
|
|
||||||
|
message(按会话全历史,跨日精确算法) [新 storage/first_reply_facts.py]
|
||||||
|
→ first_reply_fact
|
||||||
|
│
|
||||||
|
└─→ employee_metric_daily [新 storage/employee_metrics.py:
|
||||||
|
msg 计数(msg_daily_stats,friend) + 串类/分位(fact 同源)
|
||||||
|
+ customer 生命周期]
|
||||||
|
phone_call → employee_call_daily [通话小表,无渠道维,§1.2 #9]
|
||||||
|
→ 期聚合/四对比(上期/个人基线4周/团队中位数)
|
||||||
|
→ 试算评分 + 异常规则 [management/]
|
||||||
|
→ 周/月快照(不可变) [management/snapshots]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **4h**:`analytics_tick` 追加第三步:fact 滚动重算(近 3 天活跃会话)→ 宽表重算(当日+滚动 3 天);失败只记日志不中断(沿用现有异常隔离模式);
|
||||||
|
- **夜间**:full_sync_all.sh 第 13 步 fact 全量重建、第 14 步宽表全量重建(顺序:必须在 msg_daily_stats/stat_daily_staff 之后);
|
||||||
|
- **生命周期历史快照**:补同步触及历史日期时不能只重算那几天(那天的「距上次互动天数」依赖更早历史),统一由夜间全量按会话日序列重放覆盖;4h tick 只算当日;
|
||||||
|
- **通话后跟进**(call_followup_count):3,190 条通话量级小,逐通话窄查 24h 内员工对该客户的消息(join 键以 Task 0 核实结果为准),聚合任务内完成;
|
||||||
|
- **性能原则**:管理页面禁止实时扫描 96 万条原始消息;趋势和员工表读宽表+fact;原始消息只用于小范围下钻。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 口径决议表(管理域 vs 现有看板)
|
||||||
|
|
||||||
|
| 指标 | 管理域口径 | 现有看板口径 | 差异原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 消息量/串/首响均值 | 1:1(friend-only) | friend+room 混合 | 服务质量语义要求 1:1 |
|
||||||
|
| 首响分位 | 跨日精确串(fact) | 日切近似(跨夜丢失) | P90 正确性 |
|
||||||
|
| 降温客户 | 距今天数区间(同 churn 页) | 同 | 一期统一,频次基线降温二期 |
|
||||||
|
| 活跃员工 | 宽表口径(幽灵员工已过滤,§1.3 #10) | `_active_staff` 含映射不上的账号并读侧标注 | 评分场景不接受幽灵员工 |
|
||||||
|
| 交互天数/日桶 | Asia/Shanghai 自然日 | 同 | 一致 |
|
||||||
|
|
||||||
|
三方差异(管理页 vs 数据看板 vs 云客原版)在 metric-definitions 端点与页面口径说明中如实标注,不做静默差异。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 设计(`/api/management/*`)
|
||||||
|
|
||||||
|
沿用 v1 §12.3 的 11 个端点与通用响应约定(比例 0~1、时长带单位后缀、空值≠0、稳定 error_code、data_as_of+metric_version+sample_status 必返),实现落点:
|
||||||
|
|
||||||
|
| 端点 | 关键实现落点 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /dashboard` | 一次返回 KPI/双趋势/生命周期/维度分布/异常/行动聚合;data_as_of 复用 health.py |
|
||||||
|
| `GET /employees` | 服务端分页排序;默认按异常严重度排(不做总分榜);每员工带四对比值+sample_status |
|
||||||
|
| `GET /employees/{user_id}` | 首屏只回头部+维度分+趋势摘要+异常+辅导摘要 |
|
||||||
|
| `GET /employees/{user_id}/metrics?section=` | service/customer/workload/calls_groups 四页签懒加载;行动/风险页签一期不建 |
|
||||||
|
| `GET /employees/{user_id}/drilldown?metric_code=` | **白名单**:`first_reply_p90`→fact 逐串明细(含跳聊天定位)、`unreplied`→复用 B2、`cooling_customers`→客户列表带筛选、`call_followup`→通话列表。禁止前端传 SQL 字段/表达式 |
|
||||||
|
| `GET /metric-definitions` | 指标元数据(口径/单位/方向/样本门槛/公式/版本),含 §5 差异标注 |
|
||||||
|
| `GET/POST /reviews` | 草稿幂等生成(同 period+config 已有 draft 返回既有批次);`data_complete=false` 拒绝冻结 |
|
||||||
|
| `POST /reviews/{batch_id}/freeze` | 仅 draft→frozen;MVP 不做 published(RBAC 前置) |
|
||||||
|
| `GET/POST/PATCH /followups` | 状态机白名单流转 |
|
||||||
|
|
||||||
|
**周期分位数**:SQL 窗口函数 `PERCENT_RANK() OVER (PARTITION BY user_id ORDER BY fr_ms)` 取最接近 0.5/0.9 分位的行,双方言一致。
|
||||||
|
|
||||||
|
**一期评分**:综合分 = 可用维度(客户经营 30 + 服务质量 25 + 有效负载 15 = 70%)按 `100/70` 归一化 + **「试算」角标**;行动执行/风险维度显示「待数据」,不补 0 不补满分(v1 §10.1 默认)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 前端设计
|
||||||
|
|
||||||
|
- **路由**:`/management/dashboard`、`/management/employees`、`/management/employees/:userId`(activeMenu 高亮父级)、`/management/reviews`;MainLayout 新增「经营管理」分组(二期菜单不渲染、不放空页面);`/analytics/dashboard` 过渡期并存、稳定后重定向;
|
||||||
|
- **目录**:`views/management/` 四页 + `components/management/`(ManagementFilterBar/ManagementKpiCard/ComparisonValue/DataAsOfBadge/ScoreBar/ScoreBreakdown/EmployeeMetricsTable/MetricDefinitionDrawer/MetricDrilldownDrawer/AnomalyList/FollowupDrawer)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `api/management.ts`;
|
||||||
|
- **复用清单**:useChart(维度分布用分组条形图,BarChart 已注册,**不引雷达图**)、axios client 的 token/401 处理、`utils/format.ts`、`csvExport.ts`、seq 守卫防旧响应覆盖、DashboardView 的骨架屏/空态分语模式;
|
||||||
|
- **展示规范**:样本不足显示 `--` 与「样本不足」(非 0 分);「越低越好」指标反转趋势色;1366px 主表可用;导出与当前筛选排序一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 任务拆分(v1 Task 0-13 修正版)
|
||||||
|
|
||||||
|
| 任务 | 内容 | 相对 v1 的变化 | 估时 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Task 0 | 口径冻结:回测员工名单(3-5 名覆盖微信为主/WA 为主/群为主/高负载/低样本)+ 默认口径记录成文档(配置落库在 Task 3 种子 CLI,本任务不改代码)+ **五项核实**(phone_call 表结构与客户标识 join 键、call_followup 多账号语义——员工任一账号跟进即算、customer 归属字段与换人规则、员工主数据来源、群占比抽样量化混合口径偏差) | 默认值已确认,工作量缩减 | 0.5 天 |
|
||||||
|
| Task 1 | 5 张表模型 + migration + 模型冒烟测试 | 6→5 张表;核实项前置完成 | 1-1.5 天 |
|
||||||
|
| Task 2 | fact 独立计算模块(跨日精确算法,同源输出每日串聚合供宽表)+ 宽表生成(消息计数 friend 自算+串类取 fact+生命周期+通话)+ 生命周期共享函数提炼(analytics 调用点不动)+ 两条回填 CLI | **删除「_thread_stats 兼容扩展」**(不再需要);验收含对账判据(§1.1 #4/#5)+ 写钩子零改动确认 | 2.5-3.5 天 |
|
||||||
|
| Task 3 | scoring.py:归一化/四对比/样本保护 + 配置校验 + **默认配置 v1.0 种子落库**(受控 CLI,Task 11 快照的前置依赖) | 含 100/70 归一试算逻辑 | 2 天 |
|
||||||
|
| Task 4 | anomalies.py 规则集 | 数据源:fact(P90 恶化)、B2(待回复增长)、宽表(骤降/过载) | 1 天 |
|
||||||
|
| Task 5 | 管理 API(dashboard/employees/detail/metrics/drilldown/metric-definitions) | 窗口函数分位 + 性能目标 | 2-3 天 |
|
||||||
|
| Task 6 | 前端公共组件与路由(api/management.ts、useManagementFilters、components、router、MainLayout) | 按文档 | 1.5-2 天 |
|
||||||
|
| Task 7 | ManagementDashboardView(KPI/双趋势/生命周期/异常榜/下钻) | 复用图表模式,估时较 v1 略降 | 2-2.5 天 |
|
||||||
|
| Task 8 | EmployeeAnalyticsView(一期 4 视图+分页排序+指标抽屉+员工对比) | 行动/风险视图不建 | 2-3 天 |
|
||||||
|
| Task 9 | EmployeeAnalyticsDetailView(概览/服务质量/客户经营/工作负载/通话与群五页签) | 按文档 | 3-4 天 |
|
||||||
|
| Task 10 | followups 闭环(查询/创建/更新/状态流转测试) | 按文档 | 1.5-2 天 |
|
||||||
|
| Task 11 | 考核快照(草稿/详情/冻结/重算替代/列表/CSV) | 按文档 | 2-3 天 |
|
||||||
|
| Task 12 | 调度接入:analytics_tick 第三步 + full_sync 第 13/14 步 + 失败隔离 + 手动重跑入口 | 步数 12→14;**服务器 scheduler 镜像重建遗留**(9d3cc8e 部署时未重建)一并处理 | 1-1.5 天 |
|
||||||
|
| Task 13 | 回测校准 + 上线顺序控制(本地→服务器)+ 试运行记录 | 功能开关→部署顺序控制 | 2-3 天 |
|
||||||
|
|
||||||
|
**批次划分**(每批独立验收,不夹带无关重构):
|
||||||
|
|
||||||
|
| 批次 | 任务 | 可见结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | Task 1-2 | 表+fact+宽表可在测试与脚本中查询,回填可重跑 |
|
||||||
|
| B | Task 3-5 | OpenAPI 可验证全部只读管理接口 |
|
||||||
|
| C | Task 6-7 | 新导航+驾驶舱可用,异常两次点击到事实 |
|
||||||
|
| D | Task 8-9 | 员工总览+详情可比较可下钻 |
|
||||||
|
| E | Task 10-11 | 辅导闭环+冻结周期结果 |
|
||||||
|
| F | Task 12-13 | 持续运行+内部试运行 |
|
||||||
|
|
||||||
|
**总工期**:单链 18-24 个工作日;前后端并行 12-16 天。口径确认(已完成)、数据回测和试运行不可省略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试与验收(v1 §16 基础上新增判别项)
|
||||||
|
|
||||||
|
**后端单元/API 测试**(新增文件按 v1 §16.1 清单):
|
||||||
|
|
||||||
|
- **跨夜串判别**(P1#1 防线):seed 客户 23:00 消息+员工次日 9:00 回复,断言 fact 产生 fr_ms≈10h 的行——正是日切口径丢失的场景;
|
||||||
|
- **口径判别**(P1#2 防线):seed 同员工 friend+room 两类会话,断言宽表 1:1 列不含 room 串;
|
||||||
|
- **分位数精确性**:构造已知 fr_ms 集合,断言周期 P50/P90 精确值;窗口函数 SQLite/MySQL 双方言一致;
|
||||||
|
- **fact 超集断言**(P1#4):fact ⊇ 日切被回串,差集=跨夜串,不做相等断言;
|
||||||
|
- **分子分母同源**(P1#5):任意窗口 reply_rate ≤ 1;宽表 threads_answered ≡ fact 行数(同窗口);存在多日未回串时宽表 cust_threads < 日切口径值(分母不再膨胀);
|
||||||
|
- **fact 回填幂等**:重跑不重不漏;
|
||||||
|
- **快照不可变**:冻结后 PATCH 拒绝(后端强制);
|
||||||
|
- 评分边界:正/反向指标、阈值边界、权重合计、样本不足不评分、空值≠0、缺失维度不默认 0/100;
|
||||||
|
- 下钻白名单:非法 metric_code 拒绝;
|
||||||
|
- 幽灵员工过滤:映射不上的 owner 不进宽表。
|
||||||
|
|
||||||
|
**数据核验**(3-5 名回测员工,覆盖微信为主/WA 为主/群为主/高负载/低样本):抽查 3 日期消息与活跃客户、20 个首响串(对照 fact 明细)、10 个待回复客户、5 条通话后跟进、生命周期变化、评分展开明细;对账点增加宽表消息计数列(msg_sent/msg_recv)↔ msg_daily_stats friend 聚合——**串类列禁止相等断言**(口径差异见 §1.1 #4/#5)。
|
||||||
|
|
||||||
|
**性能验收**(v1 §16.5 全部目标基础上追加):员工列表服务质量视图 30 天窗(含 fact 窗口函数分位)p95 ≤ 1.5s。
|
||||||
|
|
||||||
|
**前端**:`npm run build` + Vitest(格式化/趋势方向/样本状态/筛选 query/旧响应不覆盖)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 部署注记(写入 DEPLOY.md)
|
||||||
|
|
||||||
|
1. 服务器 backend 容器**不自动跑 alembic**——升级带迁移必须手动 `sudo docker exec <backend> alembic upgrade head`;
|
||||||
|
2. 两条回填 CLI(`first-reply-facts` / `employee-metrics`)部署后执行一次,DEPLOY.md §7 注记(含耗时参考与避抓取窗口建议);
|
||||||
|
3. scheduler 镜像重建(含 9d3cc8e 遗留):`cd /opt/yunke && sudo docker compose -f docker-compose.server.yml --env-file .env up -d --build scheduler`——重建前 fr 三列/第 11-14 步不自愈;
|
||||||
|
4. fact 表索引创建量级小(几十万行),无需特殊避峰;
|
||||||
|
5. 上线顺序:本地 Docker 全量回填对账 → 本地试用 → 服务器升级+回填 → 管理者只读试用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 沿用 v1 不变的部分(索引)
|
||||||
|
|
||||||
|
以下内容直接按 v1 执行,本文不重复:
|
||||||
|
|
||||||
|
- 页面内容细则:v1 §5-§9(驾驶舱布局/KPI 卡清单、员工总览六视图、员工详情页签、快照页、二三期概要);
|
||||||
|
- 评分标准化公式与等级:v1 §10.2/§10.4;
|
||||||
|
- 后端模块目录与分层职责:v1 §12.1/§12.2(其中 storage 两模块以本文 §3/§4 为准);
|
||||||
|
- API 通用响应约定全文:v1 §12.4;
|
||||||
|
- 前端可视化规范全文:v1 §13.4;
|
||||||
|
- 验收标准:v1 §19;
|
||||||
|
- 灰度顺序/影子考核/回滚:v1 §20(其中「功能开关」按本文 §1.3 #11 替换为上线顺序控制)。
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
# 云客经营管理后台·批次B(评分/异常/管理API)实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在批次A数据底座之上交付只读管理 API——期聚合查询、五维试算评分、确定性异常规则、指标元数据、6 个 `/api/management/*` 端点,OpenAPI 可验证;前端(批次C)紧随其后。
|
||||||
|
|
||||||
|
**Architecture:** 新建 `app/management/` 业务层(metric_definitions / employee_metrics 期聚合 / scoring / anomalies),`app/api/routers/management.py` 只做参数校验与编排(照 analytics 惯例:SessionDep + Annotated 参数 + dict 返回)。**本期只做 6 个只读端点**(dashboard / employees / employee detail / metrics sections / drilldown 白名单 / metric-definitions);reviews 与 followups 写端点属批次E。依据:[v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §1(决策记录)/§6(API 设计)/§8(Task 3-5);批次A已交付全部数据表与回填。
|
||||||
|
|
||||||
|
**Tech Stack:** 同批次A(FastAPI + SQLAlchemy async;测试内存 SQLite;生产 MySQL 8.4)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行者必读:批次A交付物与既定口径
|
||||||
|
|
||||||
|
1. **数据表就绪**(迁移 b7c8d9e0f1a2,本地已回填):`first_reply_fact`(68,476 行,PK 含 reply_msg_id)、`employee_metric_daily`(12,371 行,metric_version='v1',串类列与 fact 同源)、`employee_call_daily`(1,163 行)、`employee_score_config`(空,本批次 B0 种子)。
|
||||||
|
2. **复用模块**:`app/storage/first_reply_facts.py`(fact 表直接查询)、`app/analytics/lifecycle.py`(阈值常量)、`app/analytics/metrics.py` 的 `action_counts`(dashboard 行动入口聚合)、`app/analytics/health.py` 的 `data_as_of`。
|
||||||
|
3. **周期口径**:start/end 为 yyyymmdd 上海自然日(与 analytics custom scope 同校验:缺一 422、日历非法 422、start>end 422、跨度 ≤366 天);上期=等长紧邻前窗;个人基线=紧邻当前窗之前的 28 天窗。
|
||||||
|
4. **评分默认(全部标「试算」,config 里可调)**:维度权重 客户经营30/服务25/行动执行20(pending_data)/有效负载15/合规风险10(pending_data);可用维度归一化 ×100/70;样本门槛 min=30 串/soft=10 串(周期内);等级 A≥90/B≥80/C≥70/D≥60/E。
|
||||||
|
5. **鉴权**:app 级 `require_auth` 全局生效,management 路由无需额外处理;`GET` only。
|
||||||
|
6. **测试命令**:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -v`;提交中文 conventional;master 直提(项目惯例);仓库有并行会话活动——`git status` 后只 add 自己的文件。
|
||||||
|
7. **前端不做**(批次C另出计划);`schemas_management.py` 留批次E写端点用(analytics 惯例 dict 返回,本期一致);错误响应沿用 analytics 的 `HTTPException(422/404, message)` 风格(v1 §12.4 的稳定 error_code 为有意偏差,前端按 HTTP 状态码分支)。
|
||||||
|
|
||||||
|
## 文件地图
|
||||||
|
|
||||||
|
| 文件 | 职责 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| `yunke-server/app/management/__init__.py` | 包标记(空) | 新建 |
|
||||||
|
| `yunke-server/app/management/metric_definitions.py` | 指标元数据注册表 + 默认评分配置 DEFAULT_SCORE_CONFIG | 新建 |
|
||||||
|
| `yunke-server/app/management/employee_metrics.py` | 期聚合查询(宽表 SUM/fact 窗口函数分位/期末生命周期/通话/distinct 活跃/四对比) | 新建 |
|
||||||
|
| `yunke-server/app/management/scoring.py` | 标准化/维度分/归一化试算/样本保护/等级 | 新建 |
|
||||||
|
| `yunke-server/app/management/anomalies.py` | 4 条确定性规则 → v1 §5.4 异常卡片 | 新建 |
|
||||||
|
| `yunke-server/app/sync/cli.py` | `score-config` 种子子命令 | 修改 |
|
||||||
|
| `yunke-server/app/api/routers/management.py` | 6 只读端点 | 新建 |
|
||||||
|
| `yunke-server/app/api/main.py` | include_router | 修改 |
|
||||||
|
| `yunke-server/tests/test_management_scoring.py` 等 4 个测试文件 | 判别测试 | 新建 |
|
||||||
|
| `docs/superpowers/plans/2026-09-08-management-batch-a-verification.md` | 批次B冒烟数据追加 | 修改(B5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B0: 指标元数据 + 默认评分配置 + 种子 CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/app/management/__init__.py`(空文件)
|
||||||
|
- Create: `yunke-server/app/management/metric_definitions.py`
|
||||||
|
- Modify: `yunke-server/app/sync/cli.py`(`score-config` 子命令)
|
||||||
|
- Test: `yunke-server/tests/test_management_scoring.py`(本任务先写配置/种子部分)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写测试(先失败)**
|
||||||
|
|
||||||
|
`tests/test_management_scoring.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""管理域评分配置/引擎判别测试(批次B)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.db.models import EmployeeScoreConfig
|
||||||
|
from app.management.metric_definitions import (
|
||||||
|
DEFAULT_SCORE_CONFIG,
|
||||||
|
METRIC_DEFINITIONS,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_weights_sum_and_structure():
|
||||||
|
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||||
|
assert sum(d["weight"] for d in dims.values()) == 100
|
||||||
|
# 一期 pending_data 的维度不参与试算
|
||||||
|
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||||
|
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||||
|
assert dims["action"]["status"] == "pending_data"
|
||||||
|
assert dims["risk"]["status"] == "pending_data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_normalization_declared():
|
||||||
|
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_rejects_bad_weight_sum():
|
||||||
|
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||||
|
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||||
|
problems = validate_config(bad)
|
||||||
|
assert any("权重" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_definitions_cover_scored_metrics():
|
||||||
|
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||||
|
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||||
|
for code in dim.get("metrics", {}):
|
||||||
|
md = METRIC_DEFINITIONS[code]
|
||||||
|
assert md["name"] and md["unit"] and md["direction"] in (1, -1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_and_idempotent(make_session):
|
||||||
|
from app.sync.cli import _seed_score_config # 纯会话函数(见 Step 3)
|
||||||
|
s = make_session
|
||||||
|
v1 = await _seed_score_config(s)
|
||||||
|
await s.commit()
|
||||||
|
assert v1 == "v1.0"
|
||||||
|
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||||
|
assert len(rows) == 1 and rows[0].status == "active"
|
||||||
|
# 幂等:已存在同版本返回 None 不新建
|
||||||
|
assert await _seed_score_config(s) is None
|
||||||
|
await s.commit()
|
||||||
|
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认失败**(ModuleNotFoundError: app.management)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现 metric_definitions.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||||
|
|
||||||
|
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||||
|
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||||
|
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||||
|
新配置版本(employee_score_config),不静默改数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||||
|
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||||
|
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||||
|
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||||
|
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||||
|
"caliber": "仅私聊"},
|
||||||
|
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||||
|
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||||
|
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||||
|
"caliber": "≡first_reply_fact行数(同源)"},
|
||||||
|
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "fr_sum_ms/threads_answered"},
|
||||||
|
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||||
|
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||||
|
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||||
|
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||||
|
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||||
|
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||||
|
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||||
|
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "期末单日持有状态,非期内求和"},
|
||||||
|
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||||
|
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||||
|
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||||
|
"caliber": "≥30天且历史≥5条"},
|
||||||
|
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||||
|
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||||
|
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||||
|
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||||
|
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "call_followup_count/answered_call",
|
||||||
|
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||||
|
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||||
|
"caliber": "试算口径:期末单日存量"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||||
|
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||||
|
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||||
|
# band : 区间内 80..100,低于下限不评分(None),超上限不再加分
|
||||||
|
DEFAULT_SCORE_CONFIG: dict = {
|
||||||
|
"version": "v1.0",
|
||||||
|
"name": "一期试算(3维可用)",
|
||||||
|
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||||
|
"trial": True,
|
||||||
|
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||||
|
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||||
|
"dimensions": {
|
||||||
|
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||||
|
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||||
|
}},
|
||||||
|
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||||
|
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||||
|
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||||
|
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||||
|
"floor_ms": 14_400_000}, # 30min..4h
|
||||||
|
}},
|
||||||
|
"action": {"weight": 20, "name": "行动执行",
|
||||||
|
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||||
|
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||||
|
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||||
|
"low": 5, "high": 80,
|
||||||
|
"basis": "period_distinct"},
|
||||||
|
"call_band": {"type": "band", "metric": "call_count",
|
||||||
|
"low": 1, "high": 200, "basis": "period_total",
|
||||||
|
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||||
|
}},
|
||||||
|
"risk": {"weight": 10, "name": "合规风险",
|
||||||
|
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: dict) -> list[str]:
|
||||||
|
"""配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。"""
|
||||||
|
problems: list[str] = []
|
||||||
|
dims = config.get("dimensions", {})
|
||||||
|
total = sum(d.get("weight", 0) for d in dims.values())
|
||||||
|
if total != 100:
|
||||||
|
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||||
|
for key, dim in dims.items():
|
||||||
|
if dim.get("status") == "pending_data":
|
||||||
|
continue
|
||||||
|
for code, spec in dim.get("metrics", {}).items():
|
||||||
|
src = spec.get("metric", code)
|
||||||
|
if src not in METRIC_DEFINITIONS:
|
||||||
|
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||||
|
return problems
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: cli.py 加 `score-config` 子命令**
|
||||||
|
|
||||||
|
parser(sensitive-scan 与 first-reply-facts 之间任意位置):
|
||||||
|
|
||||||
|
```python
|
||||||
|
p_score = sub.add_parser(
|
||||||
|
"score-config",
|
||||||
|
help="Seed the default score config (idempotent).",
|
||||||
|
description=(
|
||||||
|
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||||
|
"employee_score_config as active. Skips if the version "
|
||||||
|
"already exists. Batch B."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
命令函数与分发(照 _cmd_daily_stats 惯例):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _seed_score_config(session) -> str | None:
|
||||||
|
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。"""
|
||||||
|
from app.db.models import EmployeeScoreConfig
|
||||||
|
from app.management.metric_definitions import (
|
||||||
|
DEFAULT_SCORE_CONFIG,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||||
|
if problems:
|
||||||
|
raise ValueError(f"score config invalid: {problems}")
|
||||||
|
exists = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(EmployeeScoreConfig.id).where(
|
||||||
|
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists is not None:
|
||||||
|
return None
|
||||||
|
session.add(
|
||||||
|
EmployeeScoreConfig(
|
||||||
|
version=DEFAULT_SCORE_CONFIG["version"],
|
||||||
|
name=DEFAULT_SCORE_CONFIG["name"],
|
||||||
|
status="active",
|
||||||
|
config_json=DEFAULT_SCORE_CONFIG,
|
||||||
|
created_by="seed-cli",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return DEFAULT_SCORE_CONFIG["version"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
engine, session_maker = await _init_db(settings.db_url)
|
||||||
|
try:
|
||||||
|
async with session_maker() as session:
|
||||||
|
version = await _seed_score_config(session)
|
||||||
|
await session.commit()
|
||||||
|
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
```
|
||||||
|
|
||||||
|
(cli.py 头部补 `import sqlalchemy as sa` 若无。)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 测试通过 + 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_scoring.py -v # 5 passed
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m app.sync.cli score-config # 真机种子一次
|
||||||
|
git add yunke-server/app/management/ yunke-server/app/sync/cli.py yunke-server/tests/test_management_scoring.py
|
||||||
|
git commit -m "feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B1: 期聚合查询服务
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/app/management/employee_metrics.py`
|
||||||
|
- Test: `yunke-server/tests/test_management_period_metrics.py`
|
||||||
|
|
||||||
|
核心函数(完整实现):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||||
|
|
||||||
|
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||||
|
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||||
|
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from collections import namedtuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
FirstReplyFact,
|
||||||
|
MsgDailyStats,
|
||||||
|
Staff,
|
||||||
|
)
|
||||||
|
from app.storage.staff_summary import account_map
|
||||||
|
from app.storage.summary import day_bounds_ms
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||||
|
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||||
|
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||||
|
"reply_2h_count", "reply_24h_count",
|
||||||
|
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||||
|
"active_rooms_avg", # 日均
|
||||||
|
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||||
|
"reactivated_customer", # 期内求和
|
||||||
|
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _day_range(start: int, end: int) -> list[int]:
|
||||||
|
"""闭区间日键列表(纯 Python,上海自然日语义由调用方保证)。"""
|
||||||
|
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||||
|
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
out, cur = [], _d(start)
|
||||||
|
while cur <= _d(end):
|
||||||
|
out.append(_k(cur))
|
||||||
|
cur += dt.timedelta(days=1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||||
|
"""等长紧邻前窗。"""
|
||||||
|
n = len(_day_range(start, end))
|
||||||
|
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||||
|
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
pe = _d(start) - dt.timedelta(days=1)
|
||||||
|
ps = pe - dt.timedelta(days=n - 1)
|
||||||
|
return _k(ps), _k(pe)
|
||||||
|
|
||||||
|
|
||||||
|
def baseline_window(start: int) -> tuple[int, int]:
|
||||||
|
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||||
|
def _d(k): return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||||
|
def _k(d): return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
be = _d(start) - dt.timedelta(days=1)
|
||||||
|
bs = be - dt.timedelta(days=27)
|
||||||
|
return _k(bs), _k(be)
|
||||||
|
```
|
||||||
|
|
||||||
|
`period_metrics(session, start, end, channel, user_ids=None) -> dict[str, PeriodMetrics]` 实现要点(写全代码进计划):
|
||||||
|
|
||||||
|
1. **宽表 SUM**(`WHERE day BETWEEN s AND e AND channel=? AND metric_version='v1' AND user_id IN (...)` GROUP BY user_id):msg_sent/msg_recv/cust_threads/threads_answered/fr_sum_ms/active_rooms(SUM→avg=SUM/天数)、reactivated(SUM)、`days_with_data=COUNT(*)`;**期末存量**:另查 `day=(SELECT MAX(day)≤e 同 user/channel)` 的 new/cooling/dormant 行( correlated 或两段查询——用 `sa.select(...).where(day<=end).order_by(day.desc())` per user 取首行,员工 41 人循环可接受;或一次查询取 (user, MAX(day)) 再 join)。
|
||||||
|
2. **fact 分位与回复计数**(单条 SQL,窗口函数,双方言):
|
||||||
|
```python
|
||||||
|
sub = (
|
||||||
|
sa.select(
|
||||||
|
FirstReplyFact.owner_account_id.label("owner"),
|
||||||
|
FirstReplyFact.fr_ms.label("fr"),
|
||||||
|
sa.func.row_number().over(
|
||||||
|
partition_by=FirstReplyFact.owner_account_id, # 列表达式,非别名——MySQL 窗口规范不认 SELECT 别名
|
||||||
|
order_by=FirstReplyFact.fr_ms,
|
||||||
|
).label("rn"),
|
||||||
|
sa.func.count().over(
|
||||||
|
partition_by=FirstReplyFact.owner_account_id
|
||||||
|
).label("n"),
|
||||||
|
).where(FirstReplyFact.day.between(start, end),
|
||||||
|
FirstReplyFact.channel == channel)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
# 每 owner:rn=ceil(0.5*n) 行的 fr 即 p50;rn=ceil(0.9*n) 即 p90;
|
||||||
|
# 回复计数:SUM(fr<=2h)/SUM(fr<=24h) 另用条件聚合直查(不用窗口)。
|
||||||
|
```
|
||||||
|
owner→user 经 account_map(channel)。p50/p90 取最接近秩(`rn == (n+1)//2` 与 `rn == max(1, (9*n+9)//10)`——**用「最接近秩」公式并在测试里锁死**:ceil(q·n) 最近邻,与批次A `_percentile` 口径一致)。
|
||||||
|
3. **reply 计数**:`SELECT owner, SUM(fr_ms<=7200000), SUM(fr_ms<=86400000) FROM first_reply_fact WHERE day BETWEEN ... GROUP BY owner`(MySQL 布尔求和;SQLite 同义 `SUM(CASE WHEN ...)`——用 case 写法双方言安全)。
|
||||||
|
4. **distinct 活跃客户**:msg_daily_stats friend 行 in window → peer 集合按 account_map 归并到 user → len。
|
||||||
|
5. **通话**:employee_call_daily SUM(无渠道维;channel 参数只影响消息侧——**通话四列在 channel 查询里也返回全渠道值,响应里标注**)。
|
||||||
|
6. `team_median`:对每指标取全体 user 的中位数(scoring 层用)。
|
||||||
|
7. **channel=all**:router 层把 "all" 翻译成 None 传入(照 analytics 惯例,哨兵字符串不进 SQL);channel=None 时两条渠道的 account_map 合并、宽表/fact 查询去渠道过滤、分位与活跃跨渠道合并到同一 user。
|
||||||
|
|
||||||
|
**测试**(seed 宽表/fact/通话行 + Staff/account,构造已知分位数集合断言 p50/p90 精确值、期末存量取 ≤e 最近行、distinct 活跃跨日去重、prev/baseline 窗口日期算术含月末)——完整测试代码进计划(约 6 个用例)。
|
||||||
|
|
||||||
|
- [ ] Steps: 测试先失败 → 实现 → 通过 → 全量回归 → 提交
|
||||||
|
`feat(management): 期聚合查询——fact窗口函数精确分位/期末生命周期存量/distinct活跃/前窗基线窗口`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B2: 评分引擎 scoring.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/app/management/scoring.py`
|
||||||
|
- Test: 追加 `tests/test_management_scoring.py`
|
||||||
|
|
||||||
|
核心(完整代码进计划):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize(spec: dict, value: float | None) -> float | None:
|
||||||
|
"""单指标 0..100;样本不足/数据缺 → None(不评分,不默认 0/100)。"""
|
||||||
|
t = spec["type"]
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if t == "target":
|
||||||
|
if value <= spec["floor"]:
|
||||||
|
return 0.0
|
||||||
|
if value >= spec["target"]:
|
||||||
|
return 100.0
|
||||||
|
return (value - spec["floor"]) / (spec["target"] - spec["floor"]) * 100
|
||||||
|
if t == "reverse":
|
||||||
|
if value <= spec["target_ms"]:
|
||||||
|
return 100.0
|
||||||
|
if value >= spec["floor_ms"]:
|
||||||
|
return 0.0
|
||||||
|
return (spec["floor_ms"] - value) / (spec["floor_ms"] - spec["target_ms"]) * 100
|
||||||
|
if t == "band":
|
||||||
|
if value < spec["low"]:
|
||||||
|
return None # 负载不足→不评分(样本保护语义)
|
||||||
|
return 100.0 if value <= spec["high"] else 80.0 # 超上限 80 不再加
|
||||||
|
raise ValueError(t)
|
||||||
|
|
||||||
|
|
||||||
|
def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | None,
|
||||||
|
team: dict[str, float]) -> dict:
|
||||||
|
"""返回 {total_score, grade, score_status, trial, dimensions{...},
|
||||||
|
metrics{code: {value, prev, baseline, median, sample_size,
|
||||||
|
sample_status, score, formula_note}}}。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
规则(写死进测试):
|
||||||
|
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct(B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
|
||||||
|
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
|
||||||
|
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
|
||||||
|
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
|
||||||
|
- grade 按 grade_bounds;insufficient → grade None + status。
|
||||||
|
- `trial=True` 恒返(一期 3 维)。
|
||||||
|
|
||||||
|
**测试**(≥8 用例,完整代码进计划):target 边界(floor/target/中点 50)、reverse 两端与中间、band 三段、threads=0 → None 非 0、insufficient_sample 整员工、pending 维度不参与(100/70 归一后 A 线仍可达)、试算标记、全部指标有明细含四对比位。
|
||||||
|
|
||||||
|
- [ ] Steps: TDD → 提交 `feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/完整明细`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B3: 异常规则 anomalies.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/app/management/anomalies.py`
|
||||||
|
- Test: `yunke-server/tests/test_management_anomalies.py`
|
||||||
|
|
||||||
|
4 条确定性规则(输入=period_metrics 当前+基线+团队中位数;输出=v1 §5.4 卡片列表,完整代码进计划):
|
||||||
|
|
||||||
|
| rule_code | 触发 | severity | drilldown |
|
||||||
|
|---|---|---|---|
|
||||||
|
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
|
||||||
|
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
|
||||||
|
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
|
||||||
|
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
|
||||||
|
|
||||||
|
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
|
||||||
|
|
||||||
|
**测试**(≥6 用例):每规则触发/不触发边界、基线 None 不触发、severity 升级阈值、卡片结构字段齐全。
|
||||||
|
|
||||||
|
- [ ] Steps: TDD → 提交 `feat(management): 确定性异常规则×4——P90恶化/回复率骤降/活动骤降/过载`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B4: 管理 API 6 端点
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/app/api/routers/management.py`
|
||||||
|
- Modify: `yunke-server/app/api/main.py`(import + include_router(management.router))
|
||||||
|
- Test: `yunke-server/tests/test_management_api.py`
|
||||||
|
|
||||||
|
路由骨架(完整代码进计划,照 analytics 惯例):
|
||||||
|
|
||||||
|
```python
|
||||||
|
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||||
|
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 端点 | 行为要点 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /dashboard` | 参数 channel/start/end/compare(previous\|personal_baseline\|team_median,默认 previous)。组装:kpis(活跃客户/活跃员工/待回复[复用 analytics unreplied total]/通话量/生命周期期末分布)+ communication_trend(宽表按日 SUM msg+distinct friends 近 trend 天)+ service_trend(fr 日值 p50/p90 宽表列按日 + reply_2h_rate 派生)+ lifecycle(期末存量分布)+ dimension_summary(全员 3 维分布计数)+ anomalies(B3 全员)+ action_summary(复用 analytics action_counts)+ data_as_of(复用 health)。单次响应聚合,内部并发可用 asyncio.gather |
|
||||||
|
| `GET /employees` | 参数 channel/start/end/view/keyword/status/sort_by/sort_order/page/page_size(≤100 默认 20)。行=staff 身份(name/is_on_job) + PeriodMetrics + 四对比 + score_employee 结果摘要(total/grade/sample_status/trial) + anomaly_count + anomaly_weight。默认排序=**anomaly_severity**(权重和 critical=2/warning=1 desc,同分再比 count desc——对应 v2 §6「按异常严重度排序」,非单纯计数);keyword 匹配 staff.name/user_id |
|
||||||
|
| `GET /employees/{user_id}` | 404 校验(staff 无此 user_id);头部身份+账号清单(wechat/whatsapp account 表)+宽表总览+评分明细+异常+基线摘要 |
|
||||||
|
| `GET /employees/{user_id}/metrics?section=` | section ∈ overview\|service\|customer\|workload\|calls(一期 5 个,v2 §6);每 section 返回该域 PeriodMetrics 明细+四对比+trend(宽表按日序列) |
|
||||||
|
| `GET /employees/{user_id}/drilldown?metric_code=&start=&end=&channel=&page=&page_size=` | **白名单**:`first_reply_p90|first_reply_p50`→fact 逐串明细(含 thread_end/reply ts/fr_ms+跳聊天参数 owner/talker);`unreplied`→调 analytics unreplied(user 过滤);`cooling_customers|dormant_customers`→customer 表 owner 归属+lifecycle 判定列清单;`call_followup`→phone_call 行清单。白名单外 422 |
|
||||||
|
| `GET /metric-definitions` | METRIC_DEFINITIONS + DEFAULT_SCORE_CONFIG 摘要(权重/试算标记/样本门槛)+ 三方口径差异说明 |
|
||||||
|
|
||||||
|
**API 测试**(完整代码进计划,make_client fixture,≥12 用例):参数默认值/422(custom 缺参、跨度>366、metric_code 白名单外、page_size>100)/404(员工不存在)/响应结构关键字段(data_as_of、metric_version、sample_status、trial 标记)/employees 分页排序/dashboard 各段齐全/渠道过滤生效。
|
||||||
|
|
||||||
|
- [ ] Steps: TDD → main.py 注册 → OpenAPI 冒烟(`/docs` 可见 6 端点)→ 全量回归 → 提交
|
||||||
|
`feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task B5: 真机冒烟 + 性能初测 + 记录
|
||||||
|
|
||||||
|
- [ ] 本地起后端(先 `docker stop yunke-backend-1 yunke-frontend-1` 若在跑;`cd yunke-server && .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000` 后台);
|
||||||
|
- [ ] curl 六端点(带 token,从 .env API_TOKEN;无 token 模式则直接)真机各一次,记录响应体积与耗时(目标:dashboard p95≤1.5s、employees≤1.5s——v2 §9 性能线);
|
||||||
|
- [ ] 抽查:某回测员工(王云青 369D257E…)30 天窗 employees 行——P90/回复率/维度分人工合理性检查(对照核实文档对账数字);
|
||||||
|
- [ ] 结果(耗时表+抽查截图级数字)追加到批次A核实文档「批次B冒烟」小节;
|
||||||
|
- [ ] 提交 `docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次B验收清单
|
||||||
|
|
||||||
|
- [ ] `score-config` 种子幂等,配置过校验,v1.0 active;
|
||||||
|
- [ ] 期聚合:p50/p90 精确分位(窗口函数)与期末存量口径有判别测试;
|
||||||
|
- [ ] 评分:三类标准化边界/样本保护/100-70 归一/试算标记全锁测试;
|
||||||
|
- [ ] 异常:4 规则触发边界 + 卡片结构完整;
|
||||||
|
- [ ] 6 端点:OpenAPI 可见、参数校验 422/404 齐全、白名单生效、dict 返回含 data_as_of/metric_version/sample_status;
|
||||||
|
- [ ] 全量测试绿(653 + 新增 ~30);
|
||||||
|
- [ ] 真机冒烟耗时达标并记录;
|
||||||
|
- [ ] 未动批次A任何存储层代码(宽表/fact 生成零改动)。
|
||||||
|
|
||||||
|
## 批次C-F
|
||||||
|
|
||||||
|
批次C:前端(api/management.ts + useManagementFilters + components/management/* + ManagementDashboardView + 路由/导航「经营管理」分组)——批次B合入后出计划,消费真实 API 形态。D:员工总览/详情页。E:followups+快照(写端点+schemas)。F:调度接入(analytics_tick 第三步/full_sync 13-14 步/scheduler 镜像重建)+回测试运行。
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# 云客经营管理后台·批次C(前端导航+管理驾驶舱)实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付经营管理后台的第一个可视化页面——管理驾驶舱(`/management/dashboard`)+「经营管理」导航分组;管理者 5 分钟看整体、异常两次点击进事实。
|
||||||
|
|
||||||
|
**Architecture:** 前端纯增量:`api/management.ts`(六端点 typed client,契约=批次B终评冻结面)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `components/management/` 六个组件 + `ManagementDashboardView.vue`。复用 useChart/api client/format utils/seq 守卫惯例;不引新 UI 框架、不引雷达图(维度分布用条形图)。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §4/§5/§7/§13 与批次B契约清单;员工总览/详情页是批次D。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue3 + TS + Element Plus + ECharts(useChart 按需已有 Line/Bar)+ vitest(若仓库已配)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行者必读
|
||||||
|
|
||||||
|
1. **后端就绪**(本地 8000 已跑或按 DEPLOY 起):六端点见 `/tmp` 之外的真实路径 `/api/management/*`;**契约以批次B终评清单为准**(None≠0;`scope.channel` 回显原值;drilldown 白名单六值;异常卡片 12 字段;`config_source=db`)。
|
||||||
|
2. **前端惯例**:路由扁平 + `meta.title` + 登录守卫(public 才放行);API 走 `getJson/requestJson`(client.ts 已带 Bearer+baseURL=/api);图表用 `useChart`(容器稳定,不 v-if);请求序号 seq 守卫防旧响应覆盖(DashboardView 现有模式照抄);格式化放 `utils/format.ts` 不重复实现;el-select 空串 value 显示英文占位用 `'all'` 哨兵。
|
||||||
|
3. **不做**:员工总览/详情页、辅导、快照(D/E 批次);`/analytics/dashboard` 不重定向(并存过渡);二期菜单不渲染不放空页。
|
||||||
|
4. **验收基准**:v2 §5——5 分钟判总体;异常两次点击到事实;全部卡片显示数据截至与对比窗口;`npm run build` 过。
|
||||||
|
5. 仓库有并行会话活动——`git status` 后只 add 自己文件;提交中文 conventional;master 直提。
|
||||||
|
|
||||||
|
## 文件地图
|
||||||
|
|
||||||
|
| 文件 | 职责 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| `yunkes/src/api/management.ts` | 六端点 typed client | 新建 |
|
||||||
|
| `yunkes/src/composables/useManagementFilters.ts` | URL query ↔ 筛选(channel/start/end/compare + 预设窗) | 新建 |
|
||||||
|
| `yunkes/src/components/management/DataAsOfBadge.vue` | 数据截至/版本/试算角标 | 新建 |
|
||||||
|
| `yunkes/src/components/management/ManagementKpiCard.vue` | KPI 卡(主值+辅助+点击跳转) | 新建 |
|
||||||
|
| `yunkes/src/components/management/ManagementFilterBar.vue` | 筛选栏(渠道/时间预设/自定义/口径入口按钮) | 新建 |
|
||||||
|
| `yunkes/src/components/management/AnomalyList.vue` | 异常榜(severity 标签+数字+下钻事件) | 新建 |
|
||||||
|
| `yunkes/src/components/management/MetricDrilldownDrawer.vue` | 下钻抽屉(drilldown 端点行清单+聊天跳转) | 新建 |
|
||||||
|
| `yunkes/src/components/management/MetricDefsDrawer.vue` | 指标口径抽屉(metric-definitions 渲染) | 新建 |
|
||||||
|
| `yunkes/src/views/management/ManagementDashboardView.vue` | 驾驶舱页 | 新建 |
|
||||||
|
| `yunkes/src/router/index.ts` | /management/dashboard 路由 | 修改 |
|
||||||
|
| `yunkes/src/layouts/MainLayout.vue` | 「经营管理」分组 | 修改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task C0: 后端契约补全——drilldown 行补 channel 字段
|
||||||
|
|
||||||
|
**Files:** Modify `yunke-server/app/management/api_assembly.py` + `yunke-server/tests/test_management_api.py`
|
||||||
|
|
||||||
|
批次B已提交但未部署,前端冻结前补齐(评审发现:fr_fact_rows/lifecycle_customers 的行无 channel 键,前端 channel=all 时 WA 行会跳错聊天路由):
|
||||||
|
1. `fr_fact_rows` 每行加 `channel`(SELECT 已有该列,透传即可);
|
||||||
|
2. `lifecycle_customers` 每行加 `channel`(c.channel 透传);
|
||||||
|
3. 测试断言补两行(drilldown p90 与 cooling/dormant 的响应行含 channel)。
|
||||||
|
- [ ] `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -q`(23 passed)+ 全量 -q
|
||||||
|
- [ ] commit `fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)`
|
||||||
|
|
||||||
|
### Task C1: api/management.ts(typed client)
|
||||||
|
|
||||||
|
**Files:** Create `yunkes/src/api/management.ts`
|
||||||
|
|
||||||
|
完整代码(契约=批次B as-built;先对照后端 router 响应键核对再落笔):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
|
||||||
|
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
|
||||||
|
import { getJson } from './client'
|
||||||
|
import type { Channel } from './types'
|
||||||
|
|
||||||
|
export type MgmtChannel = 'all' | Channel
|
||||||
|
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
|
||||||
|
export type DrilldownCode =
|
||||||
|
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
|
||||||
|
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
|
||||||
|
|
||||||
|
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
|
||||||
|
export interface Kpis {
|
||||||
|
active_customers: number; active_employees: number
|
||||||
|
msg_total: number; cust_threads: number; threads_answered: number
|
||||||
|
call_count: number; call_seconds: number; answered_call: number
|
||||||
|
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
|
||||||
|
}
|
||||||
|
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
|
||||||
|
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
|
||||||
|
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
|
||||||
|
export interface AnomalyCard {
|
||||||
|
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
|
||||||
|
title: string; current_value: number; baseline_value: number | null; unit: string
|
||||||
|
sample_size: number; affected_count: number; reason: string
|
||||||
|
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
|
||||||
|
suggested_action: string
|
||||||
|
}
|
||||||
|
export interface DashboardResp {
|
||||||
|
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
|
||||||
|
kpis: Kpis
|
||||||
|
communication_trend: CommTrendPoint[]
|
||||||
|
service_trend: SvcTrendPoint[]
|
||||||
|
lifecycle: Kpis['lifecycle']
|
||||||
|
dimension_summary: DimSummary
|
||||||
|
anomalies: AnomalyCard[]
|
||||||
|
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
|
||||||
|
}
|
||||||
|
export interface EmployeeRow {
|
||||||
|
user_id: string; name: string | null; is_on_job: boolean | null
|
||||||
|
metrics: Record<string, number | null>
|
||||||
|
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
|
||||||
|
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
|
||||||
|
anomaly_count: number; anomaly_weight: number
|
||||||
|
}
|
||||||
|
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
|
||||||
|
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number }
|
||||||
|
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string | null; last_day: number; state: string }
|
||||||
|
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
|
||||||
|
export interface DrilldownResp { total: number; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
|
||||||
|
export interface MetricDefsResp {
|
||||||
|
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
|
||||||
|
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
|
||||||
|
caliber_notes: string
|
||||||
|
config_source: 'db' | 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
|
||||||
|
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
|
||||||
|
export const getEmployees = (params: Record<string, unknown>) =>
|
||||||
|
getJson<EmployeesResp>('/management/employees', params)
|
||||||
|
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
|
||||||
|
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
|
||||||
|
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
|
||||||
|
```
|
||||||
|
|
||||||
|
(员工详情/sections 两个端点的 client 函数批次D 再加——本期页面不消费,YAGNI;本期 client 为四函数。)
|
||||||
|
|
||||||
|
- [ ] 步骤:写文件 → `cd yunkes && npx vue-tsc --noEmit`(或 build)类型过 → 后端(8000,dev 5173 的 /api 代理已指向它)冒烟一次 getDashboard 类型与真实响应吻合(console 对比关键字段)→ commit `feat(management): 前端API client——管理端点typed契约(批次B冻结面)`
|
||||||
|
|
||||||
|
### Task C2: useManagementFilters composable
|
||||||
|
|
||||||
|
**Files:** Create `yunkes/src/composables/useManagementFilters.ts`(+ vitest 若仓库已配测试脚本,先 `grep '"test"' yunkes/package.json` 确认)
|
||||||
|
|
||||||
|
完整代码:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
|
||||||
|
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
|
||||||
|
export const CHANNELS = [
|
||||||
|
{ value: 'all', label: '全部渠道' },
|
||||||
|
{ value: 'wechat', label: '微信' },
|
||||||
|
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function ymd(d: Date): number { return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() }
|
||||||
|
function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x }
|
||||||
|
|
||||||
|
/** 预设 → [start,end] yyyymmdd(上海自然日由本地时区近似——与后端一致的公历日)。 */
|
||||||
|
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
|
||||||
|
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
switch (p) {
|
||||||
|
case 'last7': return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
|
||||||
|
case 'last30': return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
|
||||||
|
case 'thisMonth': return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
|
||||||
|
case 'lastMonth': {
|
||||||
|
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
|
||||||
|
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
|
||||||
|
return { start: ymd(first), end: ymd(last) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// custom 由调用方直传
|
||||||
|
return { start: ymd(t0), end: ymd(t0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManagementFilters() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
|
||||||
|
const v = route.query.channel
|
||||||
|
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
|
||||||
|
})
|
||||||
|
const start = computed(() => {
|
||||||
|
const v = Number(route.query.start)
|
||||||
|
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').start
|
||||||
|
})
|
||||||
|
const end = computed(() => {
|
||||||
|
const v = Number(route.query.end)
|
||||||
|
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').end
|
||||||
|
})
|
||||||
|
const compare = computed(() => {
|
||||||
|
const v = route.query.compare
|
||||||
|
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
|
||||||
|
})
|
||||||
|
|
||||||
|
function apply(patch: Record<string, string | number | undefined>) {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
|
||||||
|
}
|
||||||
|
router.replace({ query: q })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
|
||||||
|
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
|
||||||
|
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
|
||||||
|
else apply({ start: undefined, end: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel, start, end, compare, apply, applyPreset, presetRange }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] vitest(若已配):presetRange 五预设的日期算术(含月末/跨月)+ query 缺省回退 last30 + apply 写回 query。未配则手测并在报告注明。
|
||||||
|
- [ ] commit `feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵`
|
||||||
|
|
||||||
|
### Task C3: 六个基础组件
|
||||||
|
|
||||||
|
**Files:** Create `components/management/` 下六件。规格(完整 SFC 代码进实施,要点钉死):
|
||||||
|
|
||||||
|
1. **DataAsOfBadge**:props `{ dataAsOf: number | null; metricVersion?: string; trial?: boolean; configSource?: string }`;渲染 el-tag 组:数据截至(`data_as_of` ms→`YYYY-MM-DD HH:mm`,null 显示「无数据」灰 tag)+ 指标版本 + 试算(warning 色,trial=true 才显示)。格式化用 utils/format.ts 现有函数,无则组件内小 helper。
|
||||||
|
2. **ManagementKpiCard**:props `{ label: string; value: number | null; unit?: string; hint?: string; to?: string }`;主值大字(null 显示 `--`),hint 小字,有 to 则整卡 clickable `router.push`。不做趋势箭头(对比值批次D 表格里呈现)。
|
||||||
|
3. **ManagementFilterBar**:props modelValue 透传 filters composable 的值 + `@apply`;内容:el-radio-group 渠道(CHANNELS)+ el-select 预设(四预设+自定义)+ 自定义时 el-date-picker type=daterange(value-format YYYYMMDD 字符串转 number)+「指标口径」文字按钮(emit open-defs)+ 插槽放 DataAsOfBadge。改即 apply(防抖 300ms 不需要——change 事件天然低频)。
|
||||||
|
4. **AnomalyList**:props `{ anomalies: AnomalyCard[] }`;el-table 或列表卡片:severity(critical=红 danger tag「严重」/warning=橙 warning tag「关注」)、title、current vs baseline(unit 感知格式化:ms→时长/0-1→%/其他千分位)、sample_size、affected_count、suggested_action(灰小字);行 click → emit `drill(card)`。空态「暂无异常」。
|
||||||
|
5. **MetricDrilldownDrawer**:props `{ userId: string; card: AnomalyCard | null; start: number; end: number; channel: MgmtChannel }`(v-model:visible);打开时按 card.drilldown.query.metric_code 调 getEmployeeDrilldown:
|
||||||
|
- `first_reply_p90/p50` → 表格:客户(peer_id)/串末条时间/回复时间/首响时长(fr_ms 降序已由后端);行点击跳聊天:`/staff-chat/{owner_account_id}/single/{peer_id}`(channel=whatsapp 时 `/chat/whatsapp/{owner}/{peer}`)。
|
||||||
|
- `unreplied` → 表格 peer/等待时长/预览;note/truncated 提示。
|
||||||
|
- `cooling/dormant_customers` → 表格 客户/最后互动日/状态;行点击跳该客户聊天(同上映射)。
|
||||||
|
- `call_followup` → 表格 通话(时间/号码/时长)+ summary 卡。
|
||||||
|
分页 el-pagination(后端分页);员工姓名/标题显示 card.employee_user_id 对应名字由父传(dashboard anomalies 无 name——显示 user_id 短版即可,批次D 带名字)。
|
||||||
|
6. **MetricDefsDrawer**:v-model:visible;打开调 getMetricDefs;el-collapse 按 维度/单指标 两组渲染:每指标 name+unit+direction(越大越好/越小越好)+formula+caliber;顶部 score_config 摘要(版本/试算标记/样本门槛/各维度权重与 pending 状态)+ caliber_notes 段落。
|
||||||
|
|
||||||
|
- [ ] 步骤:六组件逐个写 + vue-tsc 过 → 冒烟渲染(C5 页面里真用)→ commit `feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标`
|
||||||
|
|
||||||
|
### Task C4: 路由 + 导航
|
||||||
|
|
||||||
|
- router/index.ts 在 analytics 四页之后加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
|
||||||
|
path: '/management/dashboard',
|
||||||
|
name: 'management-dashboard',
|
||||||
|
component: () => import('@/views/management/ManagementDashboardView.vue'),
|
||||||
|
meta: { title: '管理驾驶舱' },
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- MainLayout「数据分析」el-sub-menu 之前加「经营管理」分组(单项:管理驾驶舱 → goPlain('/management/dashboard'))——分组在最上(管理优先级高于查看)。
|
||||||
|
- 验证:登录态点菜单进页面、刷新保持、activeMenu 高亮正确。
|
||||||
|
|
||||||
|
- [ ] commit `feat(management): 经营管理导航分组+驾驶舱路由`
|
||||||
|
|
||||||
|
### Task C5: ManagementDashboardView
|
||||||
|
|
||||||
|
**Files:** Create `yunkes/src/views/management/ManagementDashboardView.vue`
|
||||||
|
|
||||||
|
结构(完整 SFC 进实施,要点钉死):
|
||||||
|
|
||||||
|
```text
|
||||||
|
<ManagementFilterBar>(含 DataAsOfBadge + 口径按钮)
|
||||||
|
六个 KPI 卡(el-row/gutter,1366px 两行三卡):
|
||||||
|
活跃客户(kpis.active_customers,hint=串/被回)→无跳转(批次D员工页)
|
||||||
|
活跃员工(kpis.active_employees,hint=期内有数据天数>0 口径)
|
||||||
|
消息量(msg_total,hint=1:1口径·与数据看板口径不同)
|
||||||
|
待回复(action_summary.unreplied_2h,红点>0)→/analytics/unreplied
|
||||||
|
即将流失(action_summary.churn_real_30d)→/analytics/churn
|
||||||
|
风险待处理(action_summary.sensitive_pending)→/analytics/sensitive
|
||||||
|
双图表(el-row 两列,useChart×2):
|
||||||
|
客户沟通趋势:Line msg_total + Bar call_cnt 双 y 轴(x=day)
|
||||||
|
服务质量趋势:双 y 轴——左轴 ms(Line fr_p50_ms/fr_p90_ms,tooltip 时长格式化)、
|
||||||
|
右轴 0-1(Line reply_2h_rate,%)——不注明双轴则率线贴底
|
||||||
|
——图内空数据/None 点处理:reply_2h_rate null 断点(connectNulls=false)
|
||||||
|
双条形图(el-row 两列):
|
||||||
|
生命周期分布:Bar new/cooling/dormant/reactivated 四类
|
||||||
|
员工维度分布:Bar dimension_summary 三维 avg_score(0-100)+pending 数 tooltip
|
||||||
|
异常榜(AnomalyList @drill → MetricDrilldownDrawer)
|
||||||
|
seq 守卫:filters 变化 → seq++ → 请求 → 回来校验 seq 才渲染(照 DashboardView 模式)
|
||||||
|
loading:el-skeleton 首屏;错误:el-alert + 重试按钮(保留上次数据)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- `watch([channel, start, end, compare], load)`;`onMounted(load)`;
|
||||||
|
- day 轴 `20260810`→`08-10` 短格式;
|
||||||
|
- **共享格式化**:把 DashboardView 里的 `fmtFr`(ms→时长)/`fmtPct`(0-1→%) 提升为 `utils/format.ts` 的 `formatMsDuration`/`formatRatio`(AnomalyList/抽屉/图表 tooltip 三处消费,不复制第三份);
|
||||||
|
- 异常榜点卡片 → drawer(metric_code 白名单外 type=employee 的卡片——activity_drop/overload 无 drilldown 端点,drawer 显示「员工分析批次D上线」占位说明——**两类 drilldown.type=employee 的卡片点击行为:显示 el-empty 占位**);
|
||||||
|
- 试算角标常显(config_source 透传)。
|
||||||
|
|
||||||
|
- [ ] 步骤:写页面 → `npm run build` 过 → 本地前后端真机走查(起 8000 后端 + `npm run dev` 5173,Playwright 或手动):六卡有数、双图渲染、异常卡点开抽屉、抽屉行跳聊天、口径抽屉渲染、刷新 URL 保持、1366px 布局不塌 → commit `feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实`
|
||||||
|
|
||||||
|
### Task C6: 收尾验收
|
||||||
|
|
||||||
|
- [ ] `cd yunkes && npm run build`(0 error);
|
||||||
|
- [ ] `npm run test --silent`(若配了 vitest)或记录未配;
|
||||||
|
- [ ] 验收清单核对(v2 §5.3/§19.1 驾驶舱子集):5 分钟判总体 ✓/异常两次点击进事实 ✓/口径入口 ✓/数据截至+对比窗口显示 ✓/渠道筛选 ✓/URL 保持 ✓/试算标记 ✓/空态与错误态 ✓;
|
||||||
|
- [ ] 全量后端测试仍绿(前端不动后端,确认无意外);
|
||||||
|
- [ ] commit(若剩零散修正)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次C验收清单
|
||||||
|
|
||||||
|
- [ ] 「经营管理」分组出现且仅含管理驾驶舱;
|
||||||
|
- [ ] 六 KPI 卡数字与后端响应一致(抽查待回复/风险与 analytics 页对得上);
|
||||||
|
- [ ] 双趋势图渲染 30 点、None 断点正确;
|
||||||
|
- [ ] 异常卡 critical 红标在前;点击 metric 类卡片→抽屉→行→聊天定位(两次点击到事实);
|
||||||
|
- [ ] employee 类异常卡占位不报错;
|
||||||
|
- [ ] 口径抽屉含试算标记与三方口径差异说明;
|
||||||
|
- [ ] URL query 刷新/分享保持筛选;
|
||||||
|
- [ ] `npm run build` 过;后端 722 测试不受影响。
|
||||||
|
|
||||||
|
## 批次D-F
|
||||||
|
|
||||||
|
D:员工总览+详情页(消费 employees/detail/sections 端点+指标解释抽屉);E:辅导闭环+考核快照(写端点);F:调度接入+dashboard 性能修复(unreplied 索引)+部署上线。
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# 云客经营管理后台·批次D(员工总览+员工详情)实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 补上管理闭环最大断点——员工总览(41 人多维一张表)与员工详情(五维/页签/四对比/事实下钻),打通「异常→员工全景→事实」链路;附带驾驶舱 KPI 环比与时间预设补全(体验级小项)。
|
||||||
|
|
||||||
|
**Architecture:** 纯前端为主(消费批次B已就绪的 employees/detail/metrics sections/drilldown 端点)+ 两处小后端增强(dashboard kpis 加 prev 块;无新端点)。新页 `EmployeeAnalyticsView`/`EmployeeAnalyticsDetailView` + 对比抽屉;复用 C3 六组件(FilterBar/DataAsOfBadge/AnomalyList/MetricDrilldownDrawer/MetricDefsDrawer)与 useManagementFilters。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §6/§7/§13 + 批次B契约清单。
|
||||||
|
|
||||||
|
**Tech Stack:** 同批次C(Vue3+TS+Element Plus+useChart+vitest);后端 FastAPI 小改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行者必读
|
||||||
|
|
||||||
|
1. **消费的端点 as-built 形态**(写 client 前已核对 management.py:321-443):
|
||||||
|
- `GET /employees`(行结构见批次C client 的 EmployeeRow——已含 metrics/comparisons/score 摘要/anomaly_count/weight);
|
||||||
|
- `GET /employees/{id}`:`{user_id,name,is_on_job,accounts[{channel,account_id,nick_name,user_name}],metrics(22字段dict 含 user_id 键),score{total_score,grade,score_status,trial,dimensions{name,weight,score,status,metrics{code:{value,score,sample_size,sample_status,formula_note}}},metrics_detail{code:{value,prev,baseline,median,sample_size,sample_status,score,unit,name}}},anomalies[],prev(dict|null),baseline(dict|null),scope,data_as_of,metric_version,config_source}`;
|
||||||
|
- `GET /employees/{id}/metrics?section=`:`{section,metrics(该域字段dict),trend[{day,cols...}](无数据日计数0/分位None),comparisons(源指标名键),scope,data_as_of,metric_version}`;
|
||||||
|
- drilldown/metric-definitions 已有 client。
|
||||||
|
2. **前端惯例**同批次C必读(seq 守卫/useChart/URL query/csvExport.ts 全量翻页+中文 BOM/命名路由跳转)。
|
||||||
|
3. **不做**:辅导/快照(E);员工对比不新开 API(勾选 2-5 行客户端拼数据);「指标解释抽屉」= 复用 MetricDefsDrawer(点击列头 ? 图标打开)——不做每指标单独解释面板(v1 §6.5 的完整版二期)。
|
||||||
|
4. 仓库并行会话照旧;中文 conventional 提交;master 直提。
|
||||||
|
|
||||||
|
## 文件地图
|
||||||
|
|
||||||
|
| 文件 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| `yunkes/src/api/management.ts` | 补 getEmployeeDetail/getEmployeeMetrics + types(D0) |
|
||||||
|
| `yunkes/src/composables/useManagementFilters.ts` | 预设补 今日/昨日/本周/上周(D0) |
|
||||||
|
| `yunkes/src/views/management/EmployeeAnalyticsView.vue` | 新建(D1) |
|
||||||
|
| `yunkes/src/components/management/EmployeeCompareDrawer.vue` | 新建(D1,对比抽屉) |
|
||||||
|
| `yunkes/src/views/management/EmployeeAnalyticsDetailView.vue` | 新建(D2) |
|
||||||
|
| `yunkes/src/router/index.ts` + `MainLayout.vue` | 两路由+两菜单(D1/D2 各自) |
|
||||||
|
| `yunke-server/app/api/routers/management.py` + `api_assembly.py` | dashboard kpis 加 prev 块(D3) |
|
||||||
|
| `yunkes/src/components/management/ManagementKpiCard.vue` + `AnomalyList.vue` + 驾驶舱页 | delta 显示 + 员工跳详情(D3) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task D0: client 补全 + 预设扩展
|
||||||
|
|
||||||
|
- [ ] `api/management.ts` 追加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
|
||||||
|
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
|
||||||
|
// formula_note 在 dimensions[dim].metrics[code] 上(scoring.py 维度级明细),不在 metrics_detail
|
||||||
|
export interface EmployeeDetailResp {
|
||||||
|
user_id: string; name: string | null; is_on_job: boolean | null
|
||||||
|
accounts: EmployeeAccount[]
|
||||||
|
metrics: Record<string, number | string | null>
|
||||||
|
score: {
|
||||||
|
total_score: number | null; grade: string | null; score_status: string; trial: boolean
|
||||||
|
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
|
||||||
|
metrics_detail: Record<string, ScoreMetricDetail>
|
||||||
|
}
|
||||||
|
anomalies: AnomalyCard[]
|
||||||
|
prev: Record<string, number | string | null> | null
|
||||||
|
baseline: Record<string, number | string | null> | null
|
||||||
|
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
|
||||||
|
}
|
||||||
|
export interface SectionResp {
|
||||||
|
section: string; metrics: Record<string, number | null>
|
||||||
|
trend: Record<string, number | null>[]
|
||||||
|
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
|
||||||
|
scope: MgmtScope; data_as_of: number | null; metric_version: string
|
||||||
|
}
|
||||||
|
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
|
||||||
|
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
|
||||||
|
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
|
||||||
|
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
|
||||||
|
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
|
||||||
|
```
|
||||||
|
|
||||||
|
(写入前 curl 真机抽查 detail 响应键核对——同批次C C1 惯例,以真实响应为准修。)
|
||||||
|
|
||||||
|
- [ ] `useManagementFilters.ts` 预设补:`Preset` 加 `'today' | 'yesterday' | 'thisWeek' | 'lastWeek'`;`presetRange` 四分支(today=当天单日窗;yesterday=昨日单日;thisWeek=本周一..今天;lastWeek=上周一..周日,**周一为一周起点**);vitest 补 5 例(today/yesterday/thisWeek 周中+周一/lastWeek 跨月)。FilterBar 的预设选项组同步加(el-select options),分组显示:常用(今日/昨日/近7/近30)/日历(本周/上周/本月/上月)/自定义。
|
||||||
|
- [ ] vue-tsc + `npm run test`(23+ 过)+ commit `feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周`
|
||||||
|
|
||||||
|
### Task D1: 员工总览页
|
||||||
|
|
||||||
|
**Files:** Create `EmployeeAnalyticsView.vue` + `EmployeeCompareDrawer.vue`;改 router(`/management/employees`)+ MainLayout(管理驾驶舱下加「员工分析」菜单项)。
|
||||||
|
|
||||||
|
**页面结构**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ManagementFilterBar(复用;compare 选择器在此页透出——el-select 对比方式:上期/个人基线/团队中位数→apply({compare}))
|
||||||
|
工具行:keyword el-input(placeholder 姓名/ID搜索) | status el-select(全部/在职/离线) | 视图 el-radio-group×4
|
||||||
|
(management=概览/workload=负载/service=服务/customer=客户经营——切换列组)| 指标口径按钮 | 导出 CSV | DataAsOfBadge
|
||||||
|
主表 el-table(服务端排序 sort-change→query;行选择框多选 2-5 人→「对比」按钮开抽屉):
|
||||||
|
固定列:姓名(链接进详情)+ID 小字、在职 tag
|
||||||
|
management 视图列:综合分(total_score+grade tag+trial*)、异常(anomaly_count 红数字+weight)、活跃客户、消息量、串/被回、维度三列(customer/service/workload score 各 0-100 或「--」)
|
||||||
|
workload 视图列:活跃客户、活跃群(日均)、消息收/发、通话量/秒、串
|
||||||
|
service 视图列:首响P50/P90(formatMsDuration)、2h回复率、24h回复率(formatRatio)、被回/串
|
||||||
|
customer 视图列:新客/降温/沉睡/回流(期末+期内注)、活跃占比
|
||||||
|
列头 ? 图标→打开 MetricDefsDrawer(复用)
|
||||||
|
空数据/样本不足单元格显示「--」灰字(sample_status=insufficient 时 total 亦然)
|
||||||
|
分页 el-pagination(服务端 page/page_size 20;total)
|
||||||
|
```
|
||||||
|
|
||||||
|
**行为**:query 真源(view/keyword/status/sort_by/sort_order/page 全进 URL);seq 守卫;**服务端排序仅四列**(后端 sort_by 白名单:anomaly_severity[默认]/total_score/active_friends/cust_threads——其余列头不显示排序箭头,tooltip「该列不支持服务端排序」);`sort_by=total_score` 时 null 排最后由后端保证;compare 参数仅客户端显示态(employees 端点无此参数,行恒带全部四对比值);**CSV**:扩展 `utils/csvExport.ts` 支持可选前置注释行(`exportListToCsv(..., preamble?: string[])`——含口径说明如「1:1口径·窗口 20260810-20260908·试算」)+ fetchPage 适配器(/employees 返回 `{rows,total}`,适配为 csvExport 期望的 `{items,total}`),导出当前视图列全量翻页。
|
||||||
|
|
||||||
|
**EmployeeCompareDrawer**:props `{ rows: EmployeeRow[] }`(勾选行已有数据,不另发请求);内容:五维分对比条(3 可用维度每人一行,横向 bar 0-100)+ 关键指标表(活跃客户/P90/2h率/异常数 每人一列)+ 顶部说明「同口径同时窗对比,不产生优劣结论」;>5 人禁开。
|
||||||
|
|
||||||
|
**路由/菜单**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ path: '/management/employees', name: 'management-employees',
|
||||||
|
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
|
||||||
|
meta: { title: '员工分析' } },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] vue-tsc/test/build + 真机初查(表格渲染 41 人/切视图列变/排序翻页/keyword/CSV 打开内容对)+ commit `feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV导出`
|
||||||
|
|
||||||
|
### Task D2: 员工详情页
|
||||||
|
|
||||||
|
**Files:** Create `EmployeeAnalyticsDetailView.vue`;router 加 `/management/employees/:userId`(props:true;**activeMenu 需高亮父菜单「员工分析」**——MainLayout 的 el-menu :default-active 绑定的是路径,嵌套路由时用 route.meta 或 watch 设 index——看 MainLayout 现有 active 处理方式,最简方案:菜单 index 用路径匹配 + 详情页 route path 前缀匹配高亮,若现结构不支持则 meta.activeMenu='/management/employees' 并在 el-menu :default-active 计算)。
|
||||||
|
|
||||||
|
**页面结构**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
头部卡:姓名(+在职 tag)+ID | 账号清单(渠道 tag+account_id+昵称,点击跳 staff-chat/通讯录按渠道)
|
||||||
|
| 本期试算分大数字+grade tag+score_status(insufficient 时灰注「样本不足·不参与考核」)+trial*
|
||||||
|
| DataAsOfBadge | 返回列表链接
|
||||||
|
五维条(v1 §7.2 概览的核心):横向五条 bar——3 可用维度实分数值,action/risk 灰「待数据」占位
|
||||||
|
(ScoreBreakdown 简版:直接在页内画,不新建组件,el-progress/自定义 div)
|
||||||
|
el-tabs 五页签(懒加载:激活才调 getEmployeeMetrics):
|
||||||
|
概览: 六数卡取自**detail.metrics**(msg/活跃/串/通话——section 端点字段有限,期值以 detail 为准)
|
||||||
|
+ 四对比小表(comparisons 直渲染,源指标名行)
|
||||||
|
服务质量: 数卡 P50/P90 + 2h/24h回复率(**客户端从 metrics.reply_2h_count/cust_threads 派生,
|
||||||
|
与 derive_metrics 同式**——section 趋势无每日率序列) + 趋势图=**首响 P50/P90 双线(ms 轴)
|
||||||
|
±串数(次轴可选)**——不做率右轴(员工级无每日率数据,率的团队级序列在驾驶舱)
|
||||||
|
客户经营: 期末四态数卡(取自 detail.metrics) + 降温/沉睡下钻按钮(→DrilldownDrawer lifecycle 分支)
|
||||||
|
工作负载: 数卡取 detail.metrics(收发/活跃客户/群/通话量) + 趋势图用 section=workload 的
|
||||||
|
active_friends/active_rooms 双线(trend 列为日值 active_rooms)
|
||||||
|
通话: 通话四数(detail.metrics) + call_followup 下钻按钮
|
||||||
|
**页签下钻按钮与 MetricDrilldownDrawer 的衔接**:抽屉是 card 驱动——按钮合成最小伪卡片
|
||||||
|
`{title:'首响P90明细', drilldown:{type:'metric',query:{metric_code:'first_reply_p90',user_id}}}` 传入
|
||||||
|
异常区:AnomalyList(复用,@drill→MetricDrilldownDrawer 复用)
|
||||||
|
评分明细折叠面板(el-collapse「评分怎么算出来的」): metrics_detail 表(code/名称/当前/上期/基线/中位/样本/得分)
|
||||||
|
+ prev/baseline 原始 PeriodMetrics 两个 JSON 折叠(调试用,灰字注明)
|
||||||
|
```
|
||||||
|
|
||||||
|
**行为**:query 真源(channel/start/end/compare/section 进 URL,页签切换 replace);seq 守卫每页签独立(切页签取消旧渲染);404 员工→el-result 错误页+返回链接;「创建辅导记录」按钮**占位禁用**(tooltip「批次E上线」)。
|
||||||
|
|
||||||
|
- [ ] vue-tsc/test/build + 真机初查(王云青详情:五维条/五页签懒加载/异常/评分明细展开/账号跳聊天)+ commit `feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用`
|
||||||
|
|
||||||
|
### Task D3: 驾驶舱 KPI 环比 + 异常跳员工
|
||||||
|
|
||||||
|
**后端**(`management.py` dashboard + `api_assembly.py`):①dashboard 端点 kpis 加 `prev_kpis` 块(active_customers/active_employees/msg_total/cust_threads——dashboard 走 assemble_employees(with_prev=False),需加一次 prev 窗 period_metrics 全员调用,B5 实测三窗合计 ~0.3s,加一窗 +0.1s 量级)+ `msg_delta`((cur-prev)/prev,prev=0→null)。lifecycle/通话不加环比(期末/全渠道口径环比意义弱,注明)。②**异常卡补 `employee_name`**(assemble_employees 手里已有全员 name——组装 anomalies 时带上,一行);同步 AnomalyCard type。测试补:dashboard 响应含 prev_kpis 且数字=prev 窗聚合 + anomalies[0].employee_name 非空。
|
||||||
|
**前端**:KpiCard 加 `delta?: number | null` prop(↑绿↓红,方向语义由调用方定——涨=好传正绿;不传不显示);驾驶舱三卡接 delta(活跃客户/活跃员工/消息量);AnomalyList 的 title 下加员工行(`employee_name` 链接→`/management/employees/{user_id}`)+ 新增 `showEmployee?: boolean` prop(默认 true;D2 员工详情页复用时传 false 免自链接)。
|
||||||
|
- [ ] 后端 pytest(test_management_api.py 补 prev_kpis 断言)+ 前端走查三卡箭头/异常跳员工 + commit `feat(management): 驾驶舱KPI环比(prev_kpis+delta箭头)+异常卡跳员工详情`
|
||||||
|
|
||||||
|
### Task D4: 真机走查 + 验收
|
||||||
|
|
||||||
|
- [ ] 全链路走查(Playwright):驾驶舱→异常卡→**员工详情**(新链路!)→页签→下钻→聊天;员工总览→对比抽屉→CSV;1366px;刷新保持;
|
||||||
|
- [ ] 验收对照(v1 §19.1 员工子集):能查看所有员工多维状态 ✓/任一分数展开为指标公式样本事实 ✓(评分明细面板)/四对比 ✓/样本不足不评分显示 ✓/异常两次点击进事实 ✓/默认排序非总分榜 ✓;
|
||||||
|
- [ ] 四门禁(vue-tsc/vitest/build/后端 pytest);
|
||||||
|
- [ ] commit 收尾零散 + 走查记录追加核实文档「批次D走查」小节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次D验收清单
|
||||||
|
|
||||||
|
- [ ] 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释;
|
||||||
|
- [ ] 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/三处下钻按钮进抽屉/账号跳聊天;
|
||||||
|
- [ ] 驾驶舱:三 KPI 有环比箭头/异常卡可跳员工详情(闭环打通);
|
||||||
|
- [ ] 时间预设全套(今日/昨日/近7/近30/本周/上周/本月/上月/自定义);
|
||||||
|
- [ ] URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐。
|
||||||
|
|
||||||
|
## 批次E-F
|
||||||
|
|
||||||
|
E:followups 辅导闭环(写端点+schemas_management+详情页按钮激活)+ 考核快照(生成/冻结/列表/导出)。F:调度接入(analytics_tick 第三步+full_sync 13/14 步+scheduler 镜像重建还 9d3cc8e 旧账)+ dashboard 性能修复(unreplied 索引三选一)+ 服务器部署+回测试运行。
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 云客经营管理后台·批次F-调度子批(管理指标接入定时链)实施计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 管理域三张派生表(first_reply_fact / employee_metric_daily / employee_call_daily)进入生产定时链——4h 滚动 + 夜间全量自愈,数据每天活起来(同步审计 2026-09-10 结论 #5 的管理域部分)。
|
||||||
|
|
||||||
|
**Architecture:** 两处接线:`scheduler.py _analytics_tick` 加**独立事务**的管理滚动段(近 3 天,失败不回滚既有 staff/敏感词段);`yunke-server/scripts/full_sync_all.sh` 在 11/12 步后加 13/14 步(fact 全量 → 宽表+通话全量)+ 终局快照补三表行数。CLI 已有(first-reply-facts / employee-metrics),无新端点。**边界:不碰审计文档 P0 的脚本加固/实体补抓(并行会话领地)**。
|
||||||
|
|
||||||
|
**Tech Stack:** 同批次A(APScheduler + SQLAlchemy async + bash)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行者必读
|
||||||
|
|
||||||
|
1. **上游函数签名(as-built)**:`first_reply_facts.pairs_active_in_days(session, days) / recompute_pairs(session, pairs)`;`storage/employee_metrics.recompute_metric_days(session, days) / recompute_call_days(session, days)`;`staff_summary.rolling_days(today, 3)`(返回当日+前2天列表)。全部幂等 delete+insert。
|
||||||
|
2. **隔离语义(v2 Task 12:指标失败不中断主流程)**:tick 内管理段用**独立 session+commit**——失败只回滚管理段自身,staff/敏感词段已提交不受影响(当前实现是单 try 单事务,改造为两段)。
|
||||||
|
3. **锁竞争既有结论**(tick docstring 已记录):与夜间 full_sync 重叠时 DELETE+INSERT 可能锁等待,非致命,幂等下轮自愈——管理段沿用同一语义,docstring 注明。
|
||||||
|
4. 脚本在 `yunke-server/scripts/full_sync_all.sh`(注意路径已从根 scripts/ 迁移);测试先例 `tests/test_scheduler_analytics_tick.py`。
|
||||||
|
5. 并行会话活动照旧;中文 conventional 提交。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
### Task F1: _analytics_tick 管理段 + 测试
|
||||||
|
|
||||||
|
**Files:** Modify `yunke-server/app/sync/scheduler.py` + `tests/test_scheduler_analytics_tick.py`
|
||||||
|
|
||||||
|
改造 `_analytics_tick`(:703)为两段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _analytics_tick(self) -> None:
|
||||||
|
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
|
||||||
|
|
||||||
|
两段独立事务:段1 staff+敏感词;段2 管理域(fact 近3天活跃会话 + 宽表/
|
||||||
|
通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连段1 已
|
||||||
|
提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器,不抢
|
||||||
|
``_page_lock``;与夜间 full_sync 13/14 步重叠时的锁竞争语义同段1
|
||||||
|
(非致命,幂等下轮自愈)。
|
||||||
|
"""
|
||||||
|
# ── 段1:staff 滚动 + 敏感词增量(既有,原样保留) ──
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
|
||||||
|
try:
|
||||||
|
async with self._session_maker() as session:
|
||||||
|
from app.storage.employee_metrics import (
|
||||||
|
recompute_call_days,
|
||||||
|
recompute_metric_days,
|
||||||
|
)
|
||||||
|
from app.storage.first_reply_facts import (
|
||||||
|
pairs_active_in_days,
|
||||||
|
recompute_pairs,
|
||||||
|
)
|
||||||
|
from app.storage.staff_summary import rolling_days
|
||||||
|
from app.storage.summary import day_key
|
||||||
|
|
||||||
|
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
|
||||||
|
pairs = await pairs_active_in_days(session, rd)
|
||||||
|
f = await recompute_pairs(session, pairs)
|
||||||
|
w = await recompute_metric_days(session, rd)
|
||||||
|
c = await recompute_call_days(session, rd)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
|
||||||
|
f, w, c,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("management metrics tick failed")
|
||||||
|
```
|
||||||
|
|
||||||
|
(导入合并/去重按文件现状微调;today 变量两段各自取或共享只读。注:`recompute_metric_days` 内部会再查一次 `pairs_active_in_days`(与本段显式调用重复但廉价)——非冗余待修,fact 段需要显式拿 pairs 计数记日志。)
|
||||||
|
|
||||||
|
**测试追加**(照既有文件 mock 模式):
|
||||||
|
1. tick 调用顺序:staff recompute_days → 敏感 scan → pairs_active_in_days(rolling3) → recompute_pairs → recompute_metric_days → recompute_call_days;
|
||||||
|
2. **段2 失败隔离**:recompute_metric_days 抛异常 → staff recompute_days 与敏感 scan 仍被调用且各自 commit 成功、tick 不抛、异常被记(caplog);
|
||||||
|
3. rolling 窗口传参 = rolling_days(today,3)。
|
||||||
|
|
||||||
|
- [ ] `pytest tests/test_scheduler_analytics_tick.py -v` + 全量 -q → commit `feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天`
|
||||||
|
|
||||||
|
### Task F2: full_sync_all.sh 13/14 步 + 快照行数
|
||||||
|
|
||||||
|
**Files:** Modify `yunke-server/scripts/full_sync_all.sh`
|
||||||
|
|
||||||
|
第 12 步之后(COMPLETE 横幅之前)加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
|
||||||
|
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
|
||||||
|
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
|
||||||
|
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
|
||||||
|
$PY -m app.sync.cli first-reply-facts
|
||||||
|
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
|
||||||
|
# 算法各自直算,放 fact 后保持派生链顺序语义)
|
||||||
|
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
|
||||||
|
$PY -m app.sync.cli employee-metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
终局快照 TABLES 追加三行(放 daily stats rows 后):
|
||||||
|
|
||||||
|
```python
|
||||||
|
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
|
||||||
|
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
|
||||||
|
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] `bash -n yunke-server/scripts/full_sync_all.sh` 语法过 + commit `feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数`
|
||||||
|
|
||||||
|
### Task F3: 本地 scheduler 容器重建 + 真机验证 + 归档
|
||||||
|
|
||||||
|
1. **真机验证滚动段**(直接跑 tick 逻辑,不等 4h):临时脚本或 `python -c` 用 scheduler 的 session_maker 调 `_analytics_tick()`(或手动等价:按 rolling 3 天调三个 recompute + commit)——记录耗时(预期 fact 活跃会话数十秒级内)+ 三表当日行数变化(recompute 后 updated 行 freshness);
|
||||||
|
2. **幂等复跑**:连跑两次行数一致;
|
||||||
|
3. **本地 scheduler 容器重建**(新代码进夜间链):`docker ps` 确认 scheduler 容器名 → `docker compose -f docker-compose.yml up -d --build scheduler`——**范围仅本地**(服务器 scheduler 镜像 2026-09-09 已重建过,服务器侧等部署时随 DEPLOY.md 流程走,本任务不碰);重建前后 `docker exec <scheduler> python -c "import app.sync.scheduler"` 冒烟;(已知后果不阻塞:phone_call 尚未进 4h/每日页面同步链[审计P0-5,并行会话领地]——通话小表滚动重算在补齐前只是从静态源幂等重生成);
|
||||||
|
4. 归档:耗时/行数/容器重建记录 → 核实文档「批次F-调度」小节 + commit。
|
||||||
|
|
||||||
|
- [ ] commit `docs(management): 批次F调度接入验证——滚动段耗时/幂等/容器重建(清9d3cc8e旧账)`
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] tick 两段隔离测试过(段2 异常不牵连段1);
|
||||||
|
- [ ] 夜间脚本 13/14 步 + 快照三表行数;
|
||||||
|
- [ ] 滚动段真机耗时记录(预期 <60s)+ 幂等;
|
||||||
|
- [ ] 本地 scheduler 容器已重建(新代码),夜间 02:00 链将含管理域;
|
||||||
|
- [ ] 全量测试绿;
|
||||||
|
- [ ] 未触碰审计 P0 领地(脚本加固/实体补抓——并行会话负责)。
|
||||||
|
|
||||||
|
## 后续(不在本子批)
|
||||||
|
|
||||||
|
E:辅导+快照;F 余项:dashboard 性能修复(unreplied 索引)、winsorize 决策、服务器部署(届时服务器 scheduler 镜像重建走 DEPLOY.md)。
|
||||||
@@ -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 个工作日(估),测试随批写。
|
||||||
+10
-1
@@ -11,6 +11,11 @@ FROM python:3.12-slim
|
|||||||
# cryptography.``libmariadb`` is what asyncmy links against on Debian.
|
# cryptography.``libmariadb`` is what asyncmy links against on Debian.
|
||||||
# Playwright 系统依赖:Chromium headless 需要的共享库(libnss3/libatk/libx11
|
# Playwright 系统依赖:Chromium headless 需要的共享库(libnss3/libatk/libx11
|
||||||
# 等,playwright install-deps 的等价精简集,2026-08-21 部署阻塞点#1)。
|
# 等,playwright install-deps 的等价精简集,2026-08-21 部署阻塞点#1)。
|
||||||
|
# 国内构建加速(阿里云 ECS 上 apt/pip 直连官方源慢或超时;对本地构建同样
|
||||||
|
# 可用,镜像源无地域限制)。bookworm/trixie 的源都在 debian.sources 里;
|
||||||
|
# 文件不存在(更老基础镜像)则跳过,不影响构建。
|
||||||
|
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -61,7 +66,11 @@ COPY migrations/ ./migrations/
|
|||||||
# Install the package + dev dependencies (the dev extras include aiosqlite +
|
# Install the package + dev dependencies (the dev extras include aiosqlite +
|
||||||
# pytest, which are tiny and convenient for in-container smoke checks). Pin
|
# pytest, which are tiny and convenient for in-container smoke checks). Pin
|
||||||
# the install to the local source so the package import path resolves.
|
# the install to the local source so the package import path resolves.
|
||||||
RUN pip install --no-cache-dir -e ".[dev]" \
|
# 国内构建加速:pip 走阿里云源;Chromium 二进制(~160MB)走 npmmirror,
|
||||||
|
# 否则 ECS 上从 playwright CDN 下载很慢。
|
||||||
|
ENV PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ -e ".[dev]" \
|
||||||
&& playwright install chromium --with-deps
|
&& playwright install chromium --with-deps
|
||||||
|
|
||||||
# Default command — overridden per-service in docker-compose. The API service
|
# Default command — overridden per-service in docker-compose. The API service
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""客户生命周期判定(管理域,批次A)——距今天数口径(v2 §1.1 #3 决议)。
|
||||||
|
|
||||||
|
阈值取已确认默认(v1 §21,用户拍板):新客 7 天、沉睡 30 天;历史互动
|
||||||
|
门槛 MIN_HISTORY_MSGS=5 对齐 analytics churn real 的 min_msgs=5。
|
||||||
|
analytics 现有页(churn/客户资产)**暂不切换**到此模块——两套数字的
|
||||||
|
差异在 metric-definitions 标注(v2 §5);后续统一时再接,避免动既有
|
||||||
|
测试。
|
||||||
|
|
||||||
|
本模块为纯叶子节点,禁止 import app.storage(防止 storage→analytics
|
||||||
|
消费时成环)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
NEW_DAYS = 7
|
||||||
|
DORMANT_DAYS = 30
|
||||||
|
MIN_HISTORY_MSGS = 5
|
||||||
|
|
||||||
|
STATES = ("new", "active", "cooling", "dormant", "reactivated")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_date(day: int) -> dt.date:
|
||||||
|
return dt.date(day // 10000, (day // 100) % 100, day % 100)
|
||||||
|
|
||||||
|
|
||||||
|
def day_add(day: int, delta_days: int) -> int:
|
||||||
|
"""yyyymmdd 键加减天数。"""
|
||||||
|
d = _to_date(day) + dt.timedelta(days=delta_days)
|
||||||
|
return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
|
||||||
|
|
||||||
|
def days_between(day_a: int, day_b: int) -> int:
|
||||||
|
"""b − a 的天数(输入 yyyymmdd 键)。"""
|
||||||
|
return (_to_date(day_b) - _to_date(day_a)).days
|
||||||
|
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
first_day: int | None,
|
||||||
|
last_day: int | None,
|
||||||
|
prev_day: int | None,
|
||||||
|
as_of: int,
|
||||||
|
hist_msgs: int,
|
||||||
|
) -> str | None:
|
||||||
|
"""as_of 日的客户状态;输入均为互动日键(yyyymmdd,上海自然日)。
|
||||||
|
|
||||||
|
- first_day: 该客户全历史首次互动日;last_day: ≤as_of 的最近互动日;
|
||||||
|
prev_day: last_day 之前的一次互动日(无则 None);hist_msgs: 历史总消息数。
|
||||||
|
- 返回 None = 未激活(first/last 缺)或历史不足的长期静默(不计入任何列)。
|
||||||
|
|
||||||
|
判定优先级:new → reactivated → active → cooling → dormant。
|
||||||
|
"""
|
||||||
|
if first_day is None or last_day is None:
|
||||||
|
return None
|
||||||
|
if first_day >= day_add(as_of, -(NEW_DAYS - 1)):
|
||||||
|
return "new"
|
||||||
|
if (
|
||||||
|
last_day == as_of
|
||||||
|
and prev_day is not None
|
||||||
|
and days_between(prev_day, last_day) > DORMANT_DAYS
|
||||||
|
):
|
||||||
|
return "reactivated"
|
||||||
|
gap = days_between(last_day, as_of)
|
||||||
|
if gap < NEW_DAYS:
|
||||||
|
return "active"
|
||||||
|
if gap < DORMANT_DAYS:
|
||||||
|
return "cooling"
|
||||||
|
if hist_msgs >= MIN_HISTORY_MSGS:
|
||||||
|
return "dormant"
|
||||||
|
return None
|
||||||
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
|
||||||
@@ -19,15 +19,47 @@ reach it without knowing the token — that exemption is encoded here in
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
# Request paths that bypass auth even when a token is configured. Keep this
|
# Request paths that bypass auth even when a token is configured. Keep this
|
||||||
# minimal so the protected surface is easy to reason about — currently just the
|
# minimal so the protected surface is easy to reason about — the liveness
|
||||||
# liveness probe.
|
# probe and the login endpoint itself (the caller has no token yet).
|
||||||
_PUBLIC_PATHS = frozenset({"/api/health"})
|
_PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/login"})
|
||||||
|
|
||||||
|
# ---- Brute-force guard for POST /api/auth/login ------------------------- #
|
||||||
|
# In-memory per-IP failure tracker: MAX failures within WINDOW seconds locks
|
||||||
|
# the IP out for LOCKOUT seconds. Process-local by design (single-backend
|
||||||
|
# deployment; a restart simply clears the slate).
|
||||||
|
_LOGIN_MAX_FAILURES = 5
|
||||||
|
_LOGIN_FAILURE_WINDOW_S = 15 * 60
|
||||||
|
_LOGIN_LOCKOUT_S = 15 * 60
|
||||||
|
# ip -> list of monotonic timestamps of recent failed logins
|
||||||
|
_login_failures: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Best-effort client IP for the lockout key (test transport has none)."""
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _login_locked_out(ip: str) -> bool:
|
||||||
|
"""True when ``ip`` burned through MAX failures inside the window."""
|
||||||
|
now = time.monotonic()
|
||||||
|
stamps = [t for t in _login_failures.get(ip, []) if now - t < _LOGIN_FAILURE_WINDOW_S]
|
||||||
|
_login_failures[ip] = stamps
|
||||||
|
return len(stamps) >= _LOGIN_MAX_FAILURES
|
||||||
|
|
||||||
|
|
||||||
|
def _record_login_failure(ip: str) -> None:
|
||||||
|
_login_failures.setdefault(ip, []).append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_login_failures(ip: str) -> None:
|
||||||
|
_login_failures.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
async def require_auth(request: Request) -> None:
|
async def require_auth(request: Request) -> None:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Includes:
|
|||||||
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
|
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
|
||||||
* ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`).
|
* ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`).
|
||||||
* ``GET /api/staff-stats`` (see :mod:`app.api.routers.staff_stats`).
|
* ``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``
|
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
|
(comma-separated ``CORS_ORIGINS`` env var). Empty (default) falls back to the dev
|
||||||
@@ -42,10 +43,13 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api.auth import require_auth
|
from app.api.auth import require_auth
|
||||||
from app.api.routers import (
|
from app.api.routers import (
|
||||||
|
analytics,
|
||||||
calls,
|
calls,
|
||||||
customer,
|
customer,
|
||||||
export,
|
export,
|
||||||
groups,
|
groups,
|
||||||
|
login,
|
||||||
|
management,
|
||||||
media,
|
media,
|
||||||
messages,
|
messages,
|
||||||
moments,
|
moments,
|
||||||
@@ -82,6 +86,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(customer.router)
|
app.include_router(customer.router)
|
||||||
|
app.include_router(login.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(organization.router)
|
app.include_router(organization.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
@@ -96,6 +101,8 @@ app.include_router(search.router)
|
|||||||
app.include_router(sessions.router)
|
app.include_router(sessions.router)
|
||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(voice_clips.router)
|
app.include_router(voice_clips.router)
|
||||||
|
app.include_router(analytics.router)
|
||||||
|
app.include_router(management.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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
|
||||||
@@ -7,6 +7,12 @@ Read-only query over the mirrored ``customer`` table. Supports filtering by
|
|||||||
nickname / remark / friend_id / phone, ``%``/``_`` escaped as literals — the
|
nickname / remark / friend_id / phone, ``%``/``_`` escaped as literals — the
|
||||||
通讯录 search box). Results are ordered by ``last_chat_time desc`` (nulls
|
通讯录 search box). Results are ordered by ``last_chat_time desc`` (nulls
|
||||||
last) then ``friend_id`` so the most-recently-active contacts surface first.
|
last) then ``friend_id`` so the most-recently-active contacts surface first.
|
||||||
|
|
||||||
|
**owner_account_id 维度走 ``customer_account_link``**(2026-09-08):customer
|
||||||
|
档案 PK=friend_id 全局单行、归属 last-wins,共享客户在其他员工名下会漏
|
||||||
|
(实测 36 vs 12);link 表每个「渠道×账号×客户」一行,员工维度列表/计数与
|
||||||
|
云客 per-employee 口径一致。带 department/user/add_time/last_chat 等其余
|
||||||
|
过滤时回退档案路径(组合场景罕见,保持旧行为)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,7 +25,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import CustomerOut, Page
|
from app.api.schemas import CustomerOut, Page
|
||||||
from app.db.models import Customer, Staff, WechatAccount
|
from app.db.models import Customer, CustomerAccountLink, Staff, WechatAccount
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["customers"])
|
router = APIRouter(prefix="/api", tags=["customers"])
|
||||||
|
|
||||||
@@ -61,6 +67,74 @@ async def customers_declared_total(session: SessionDep) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_owner_links(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
channel: str | None,
|
||||||
|
owner_account_id: str,
|
||||||
|
friend_id: str | None,
|
||||||
|
q: str | None,
|
||||||
|
) -> Page[CustomerOut]:
|
||||||
|
"""员工维度客户列表:读 ``customer_account_link``(渠道×账号×客户一行)。
|
||||||
|
|
||||||
|
排序/搜索语义与档案路径一致(last_chat_time desc、四列模糊);link 自带
|
||||||
|
per-relation 快照(备注/收发计数按员工各一份,与云客 per-employee 列表
|
||||||
|
相同)。CustomerOut 的档案专属列(raw_json/version/send_today 等)为 None。
|
||||||
|
"""
|
||||||
|
conditions = [CustomerAccountLink.owner_account_id == owner_account_id]
|
||||||
|
if channel is not None:
|
||||||
|
conditions.append(CustomerAccountLink.channel == channel)
|
||||||
|
if friend_id is not None:
|
||||||
|
conditions.append(CustomerAccountLink.friend_id == friend_id)
|
||||||
|
if q:
|
||||||
|
# LIKE 通配符转义(同档案路径):昵称/备注/微信号/手机号四列任一命中
|
||||||
|
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
like = f"%{escaped}%"
|
||||||
|
conditions.append(
|
||||||
|
sa.or_(
|
||||||
|
CustomerAccountLink.friend_nickname.like(like, escape="\\"),
|
||||||
|
CustomerAccountLink.friend_remark.like(like, escape="\\"),
|
||||||
|
CustomerAccountLink.friend_id.like(like, escape="\\"),
|
||||||
|
CustomerAccountLink.phone.like(like, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(sa.func.count())
|
||||||
|
.select_from(CustomerAccountLink)
|
||||||
|
.where(*conditions)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
# LEFT JOIN 档案带 raw_json(2026-09-09):WA 客户列表大量列只存在于
|
||||||
|
# raw_json(userName/salePhone/createTime 等员工侧字段),link 快照没存;
|
||||||
|
# raw_json 按 friend_id 全局一份,挂谁名下内容相同。注:共享客户的员工
|
||||||
|
# 侧字段是「最后写入员工」的记录,可能非本员工(客户自身字段不受影响)。
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(CustomerAccountLink, Customer.raw_json, Customer.version)
|
||||||
|
.outerjoin(Customer, Customer.friend_id == CustomerAccountLink.friend_id)
|
||||||
|
.where(*conditions)
|
||||||
|
.order_by(
|
||||||
|
sa.nullslast(CustomerAccountLink.last_chat_time.desc()),
|
||||||
|
CustomerAccountLink.friend_id.asc(),
|
||||||
|
)
|
||||||
|
.limit(page_size)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
items = []
|
||||||
|
for link, raw_json, version in rows:
|
||||||
|
out = CustomerOut.model_validate(link)
|
||||||
|
out.raw_json = raw_json
|
||||||
|
if version is not None:
|
||||||
|
out.version = version
|
||||||
|
items.append(out)
|
||||||
|
return Page(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers")
|
@router.get("/customers")
|
||||||
async def list_customers(
|
async def list_customers(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
@@ -76,6 +150,34 @@ async def list_customers(
|
|||||||
q: Annotated[str | None, Query()] = None,
|
q: Annotated[str | None, Query()] = None,
|
||||||
) -> Page[CustomerOut]:
|
) -> Page[CustomerOut]:
|
||||||
"""List customers with optional filters and pagination."""
|
"""List customers with optional filters and pagination."""
|
||||||
|
# 员工维度优先走 link 表(共享好友每账号一行,见模块 docstring);该账号
|
||||||
|
# link 还无数据时回退档案路径(过渡安全:回填前页面不至于空;也兼容
|
||||||
|
# 只写档案的理论路径)。与其余仅档案路径支持的过滤组合时同样回退。
|
||||||
|
if (
|
||||||
|
owner_account_id is not None
|
||||||
|
and department_id is None
|
||||||
|
and user_id is None
|
||||||
|
and add_time_since is None
|
||||||
|
and last_chat_since is None
|
||||||
|
):
|
||||||
|
link_exists = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(sa.func.count())
|
||||||
|
.select_from(CustomerAccountLink)
|
||||||
|
.where(CustomerAccountLink.owner_account_id == owner_account_id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
if link_exists:
|
||||||
|
return await _list_owner_links(
|
||||||
|
session,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
channel=channel,
|
||||||
|
owner_account_id=owner_account_id,
|
||||||
|
friend_id=friend_id,
|
||||||
|
q=q,
|
||||||
|
)
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
conditions.append(Customer.channel == channel)
|
conditions.append(Customer.channel == channel)
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
||||||
|
GET /api/export/chat-html-zip — 勾选的多个 1:1 会话打成一个 zip(每会话一个 HTML)。
|
||||||
|
|
||||||
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||||
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
||||||
作为 attachment 返回下载。
|
作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from fastapi import APIRouter, Depends, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.db.models import Message
|
from app.db.models import Customer, CustomerAccountLink, Message
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
|
|
||||||
@@ -170,3 +174,110 @@ async def export_chat_html(
|
|||||||
media_type="text/html; charset=utf-8",
|
media_type="text/html; charset=utf-8",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 员工客户页批量「导出聊天记录」:一次勾选的会话数上限(防误点全选把服务端打爆;
|
||||||
|
# 50 个会话 × 每会话 EXPORT_MAX_ROWS 已是内存上界内的最坏情况)。
|
||||||
|
ZIP_MAX_CONVS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_zip_name(name: str, fallback: str) -> str:
|
||||||
|
"""会话显示名 → zip 内文件名:去路径/Windows 非法字符,限长,空回退。"""
|
||||||
|
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip("._ ")
|
||||||
|
return cleaned[:40] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat-html-zip")
|
||||||
|
async def export_chat_html_zip(
|
||||||
|
session: SessionDep,
|
||||||
|
channel: Annotated[str, Query()],
|
||||||
|
owner_account_id: Annotated[str, Query()],
|
||||||
|
talker_ids: Annotated[str, Query()] = "",
|
||||||
|
start: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
end: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
) -> Response:
|
||||||
|
"""勾选客户的 1:1 聊天批量导出为一个 zip(每会话一个 HTML,复用单导出渲染器)。
|
||||||
|
|
||||||
|
``talker_ids`` 逗号分隔的 friend_id 列表;文件名取客户备注/昵称(缺省回退
|
||||||
|
friend_id),加序号前缀保唯一;单会话行数沿用 EXPORT_MAX_ROWS 截断告警。
|
||||||
|
"""
|
||||||
|
ids = [t.strip() for t in talker_ids.split(",") if t.strip()]
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(status_code=422, detail="talker_ids 不能为空")
|
||||||
|
if len(ids) > ZIP_MAX_CONVS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 文件名显示名:批量取一次(备注 > 昵称 > friend_id)。优先
|
||||||
|
# customer_account_link per-owner 快照——共享客户的档案行可能挂在别的
|
||||||
|
# 员工名下,按 owner 查档案会查空、zip 文件名回退成 wxid(2026-09-09,
|
||||||
|
# 同 sessions._meta 修复);link 没覆盖的回退档案。
|
||||||
|
link_rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
CustomerAccountLink.friend_id,
|
||||||
|
CustomerAccountLink.friend_nickname,
|
||||||
|
CustomerAccountLink.friend_remark,
|
||||||
|
).where(
|
||||||
|
CustomerAccountLink.channel == channel,
|
||||||
|
CustomerAccountLink.owner_account_id == owner_account_id,
|
||||||
|
CustomerAccountLink.friend_id.in_(ids),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
names = {
|
||||||
|
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||||
|
for r in link_rows
|
||||||
|
}
|
||||||
|
missing = [t for t in ids if t not in names]
|
||||||
|
if missing:
|
||||||
|
cust_rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Customer.friend_id,
|
||||||
|
Customer.friend_nickname,
|
||||||
|
Customer.friend_remark,
|
||||||
|
).where(
|
||||||
|
Customer.channel == channel,
|
||||||
|
Customer.owner_account_id == owner_account_id,
|
||||||
|
Customer.friend_id.in_(missing),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
for r in cust_rows:
|
||||||
|
names[r.friend_id] = (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for i, tid in enumerate(ids, 1):
|
||||||
|
q = sa.select(Message).where(
|
||||||
|
Message.channel == channel,
|
||||||
|
Message.owner_account_id == owner_account_id,
|
||||||
|
Message.talker_id == tid,
|
||||||
|
# DM:限定 room_id 为空,避免误捞群消息(同单导出)
|
||||||
|
(Message.room_id == None) | (Message.room_id == ""), # noqa: E711
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
q = q.where(Message.timestamp >= start)
|
||||||
|
if end is not None:
|
||||||
|
q = q.where(Message.timestamp <= end)
|
||||||
|
q = q.order_by(
|
||||||
|
sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()
|
||||||
|
).limit(EXPORT_MAX_ROWS)
|
||||||
|
rows = list((await session.execute(q)).scalars().all())
|
||||||
|
capped = len(rows) >= EXPORT_MAX_ROWS
|
||||||
|
warning = (
|
||||||
|
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||||||
|
f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>"
|
||||||
|
if capped
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
html = _render_chat_html(rows, channel, owner_account_id, tid, warning=warning)
|
||||||
|
safe = _safe_zip_name(names.get(tid) or tid, f"conv{i}")
|
||||||
|
zf.writestr(f"{i:02d}_{safe}.html", html)
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="chat-records-{channel}-{owner_account_id}.zip"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
|
``owner_user_id`` column) and ``q`` (substring match on ``room_name``,
|
||||||
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
|
``%``/``_`` escaped as literals — the 通讯录群 tab search box), ordered by
|
||||||
``last_chat_time desc`` (nulls last) so recently-active groups surface first.
|
``last_chat_time desc`` (nulls last) so recently-active groups surface first.
|
||||||
|
微信群统计扩展(2026-09-09,云客「微信群统计」同款):``msg_start``/``msg_end``
|
||||||
|
统计时段(窗内输出沟通消息数/新增/退出成员数聚合)、``last_start``/
|
||||||
|
``last_end`` 最新消息时间段、``group_remark`` 群备注子串;行补员工微信/
|
||||||
|
员工信息列(wechat_account/staff 关联)。
|
||||||
* ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
|
* ``GET /api/groups/{room_id}/members`` -> ``list[GroupMemberOut]`` — the
|
||||||
members of a single group (small set, no pagination needed).
|
members of a single group (small set, no pagination needed).
|
||||||
"""
|
"""
|
||||||
@@ -19,7 +23,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import GroupMemberOut, GroupOut, Page
|
from app.api.schemas import GroupMemberOut, GroupOut, Page
|
||||||
from app.db.models import ChatGroup, GroupMember
|
from app.db.models import (
|
||||||
|
ChatGroup,
|
||||||
|
ChatGroupMemberStats,
|
||||||
|
GroupMember,
|
||||||
|
MsgDailyStats,
|
||||||
|
Staff,
|
||||||
|
WechatAccount,
|
||||||
|
)
|
||||||
|
from app.storage.summary import day_key
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["groups"])
|
router = APIRouter(prefix="/api", tags=["groups"])
|
||||||
|
|
||||||
@@ -38,6 +50,11 @@ async def list_groups(
|
|||||||
user_id: Annotated[str | None, Query()] = None,
|
user_id: Annotated[str | None, Query()] = None,
|
||||||
room_id: Annotated[str | None, Query()] = None,
|
room_id: Annotated[str | None, Query()] = None,
|
||||||
q: Annotated[str | None, Query()] = None,
|
q: Annotated[str | None, Query()] = None,
|
||||||
|
group_remark: Annotated[str | None, Query()] = None,
|
||||||
|
last_start: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
last_end: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
msg_start: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
msg_end: Annotated[int | None, Query(ge=0)] = None,
|
||||||
) -> Page[GroupOut]:
|
) -> Page[GroupOut]:
|
||||||
"""List chat groups with optional ``channel`` / ``owner_account_id`` /
|
"""List chat groups with optional ``channel`` / ``owner_account_id`` /
|
||||||
``user_id`` / ``room_id`` / ``q`` filters.
|
``user_id`` / ``room_id`` / ``q`` filters.
|
||||||
@@ -47,6 +64,13 @@ async def list_groups(
|
|||||||
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
|
filter on ``GET /api/customers``. ``room_id`` is an exact-match lookup
|
||||||
(composite PK: the same room may return one row per owner; chat page
|
(composite PK: the same room may return one row per owner; chat page
|
||||||
group-name resolution). ``q`` is a substring match on ``room_name``.
|
group-name resolution). ``q`` is a substring match on ``room_name``.
|
||||||
|
|
||||||
|
微信群统计参数(2026-09-09):``last_start``/``last_end`` 过滤
|
||||||
|
``last_chat_time`` 范围(最新消息时间段);``msg_start``/``msg_end``
|
||||||
|
统计时段——窗内聚合三列(``window_msg_count`` 沟通消息数=
|
||||||
|
msg_daily_stats 按天求和、``window_added``/``window_lost`` 成员进出=
|
||||||
|
chat_group_member_stats 按天求和,上线日起有数据);``group_remark``
|
||||||
|
群备注子串。员工微信/员工信息列经 wechat_account/staff 关联补齐。
|
||||||
"""
|
"""
|
||||||
conditions = []
|
conditions = []
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
@@ -57,10 +81,17 @@ async def list_groups(
|
|||||||
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
|
# LIKE 通配符转义: %/_ 按字面匹配(镜像 customers/messages 的 q 处理)
|
||||||
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\"))
|
conditions.append(ChatGroup.room_name.like(f"%{escaped}%", escape="\\"))
|
||||||
|
if group_remark:
|
||||||
|
escaped_r = group_remark.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
conditions.append(ChatGroup.group_remark.like(f"%{escaped_r}%", escape="\\"))
|
||||||
if room_id is not None:
|
if room_id is not None:
|
||||||
conditions.append(ChatGroup.room_id == room_id)
|
conditions.append(ChatGroup.room_id == room_id)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
conditions.append(ChatGroup.owner_user_id == user_id)
|
conditions.append(ChatGroup.owner_user_id == user_id)
|
||||||
|
if last_start is not None:
|
||||||
|
conditions.append(ChatGroup.last_chat_time >= last_start)
|
||||||
|
if last_end is not None:
|
||||||
|
conditions.append(ChatGroup.last_chat_time <= last_end)
|
||||||
|
|
||||||
total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
|
total_stmt = sa.select(sa.func.count()).select_from(ChatGroup)
|
||||||
if conditions:
|
if conditions:
|
||||||
@@ -70,16 +101,116 @@ async def list_groups(
|
|||||||
list_stmt = sa.select(ChatGroup)
|
list_stmt = sa.select(ChatGroup)
|
||||||
if conditions:
|
if conditions:
|
||||||
list_stmt = list_stmt.where(*conditions)
|
list_stmt = list_stmt.where(*conditions)
|
||||||
list_stmt = (
|
if (channel or "wechat") == "wechat":
|
||||||
list_stmt.order_by(
|
# 云客「微信群统计」同款排序(2026-09-10 真机终判):owner_account_id
|
||||||
sa.nullslast(ChatGroup.last_chat_time.desc()),
|
# (=微信号/wxid 混存,如 Nancyjade < csf7169708 < wxid_01gv…——大写
|
||||||
ChatGroup.room_id.asc(),
|
# 在小写前=区分大小写)**二进制字节序升序**分组,组内 room_id 字符串
|
||||||
|
# 升序。字节序按方言:MySQL 用 CAST(col AS BINARY)(collate("BINARY")
|
||||||
|
# 会被 SQLAlchemy 反引号成标识符);SQLite 用 COLLATE BINARY(其 CAST
|
||||||
|
# AS BINARY 语义不符)。WA 保持最近活跃优先。
|
||||||
|
dialect = session.bind.dialect.name if session.bind is not None else ""
|
||||||
|
if dialect == "mysql":
|
||||||
|
owner_order = sa.literal_column(
|
||||||
|
"CAST(chat_group.owner_account_id AS BINARY)"
|
||||||
|
).asc()
|
||||||
|
else:
|
||||||
|
owner_order = ChatGroup.owner_account_id.collate("BINARY").asc()
|
||||||
|
list_stmt = (
|
||||||
|
list_stmt.order_by(
|
||||||
|
owner_order,
|
||||||
|
ChatGroup.room_id.asc(),
|
||||||
|
)
|
||||||
|
.limit(page_size)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
list_stmt = (
|
||||||
|
list_stmt.order_by(
|
||||||
|
sa.nullslast(ChatGroup.last_chat_time.desc()),
|
||||||
|
ChatGroup.room_id.asc(),
|
||||||
|
)
|
||||||
|
.limit(page_size)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
)
|
)
|
||||||
.limit(page_size)
|
|
||||||
.offset((page - 1) * page_size)
|
|
||||||
)
|
|
||||||
rows = (await session.execute(list_stmt)).scalars().all()
|
rows = (await session.execute(list_stmt)).scalars().all()
|
||||||
items = [GroupOut.model_validate(row) for row in rows]
|
|
||||||
|
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
|
||||||
|
acc_map: dict[str, tuple[str | None, str | None, str | None]] = {}
|
||||||
|
owners = {r.owner_account_id for r in rows if r.owner_account_id}
|
||||||
|
if owners:
|
||||||
|
for wx, nick, alias, raw in (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
WechatAccount.wx_id, WechatAccount.nick_name,
|
||||||
|
WechatAccount.alias, WechatAccount.raw_json,
|
||||||
|
).where(WechatAccount.wx_id.in_(owners))
|
||||||
|
)).all():
|
||||||
|
avatar = None
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
h = raw.get("headImageUrl")
|
||||||
|
avatar = h if isinstance(h, str) else None
|
||||||
|
acc_map[wx] = (nick, alias, avatar)
|
||||||
|
staff_map: dict[str, tuple[str | None, str | None]] = {}
|
||||||
|
uids = {r.owner_user_id for r in rows if r.owner_user_id}
|
||||||
|
if uids:
|
||||||
|
for uid, name, phone in (await session.execute(
|
||||||
|
sa.select(Staff.user_id, Staff.name, Staff.phone)
|
||||||
|
.where(Staff.user_id.in_(uids))
|
||||||
|
)).all():
|
||||||
|
staff_map[uid] = (name, phone)
|
||||||
|
|
||||||
|
# ---- 统计时段窗内聚合(msg_daily_stats 沟通消息数 + member_stats 进出) ----
|
||||||
|
msg_cnt: dict[tuple[str, str], int] = {}
|
||||||
|
added_map: dict[tuple[str, str], int] = {}
|
||||||
|
lost_map: dict[tuple[str, str], int] = {}
|
||||||
|
if msg_start is not None and msg_end is not None and rows:
|
||||||
|
d0, d1 = day_key(msg_start), day_key(msg_end)
|
||||||
|
pairs = [(r.owner_account_id, r.room_id) for r in rows]
|
||||||
|
owners_in = {p[0] for p in pairs}
|
||||||
|
rooms_in = {p[1] for p in pairs}
|
||||||
|
for owner, room, cnt in (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.owner_account_id, MsgDailyStats.peer_id,
|
||||||
|
sa.func.coalesce(sa.func.sum(
|
||||||
|
MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.channel == (channel or "wechat"),
|
||||||
|
MsgDailyStats.peer_kind == "room",
|
||||||
|
MsgDailyStats.owner_account_id.in_(owners_in),
|
||||||
|
MsgDailyStats.peer_id.in_(rooms_in),
|
||||||
|
MsgDailyStats.day >= d0, MsgDailyStats.day <= d1,
|
||||||
|
).group_by(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||||
|
)).all():
|
||||||
|
msg_cnt[(owner, room)] = int(cnt)
|
||||||
|
for owner, room, a, l in (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id,
|
||||||
|
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.added), 0),
|
||||||
|
sa.func.coalesce(sa.func.sum(ChatGroupMemberStats.lost), 0),
|
||||||
|
).where(
|
||||||
|
ChatGroupMemberStats.channel == (channel or "wechat"),
|
||||||
|
ChatGroupMemberStats.owner_account_id.in_(owners_in),
|
||||||
|
ChatGroupMemberStats.room_id.in_(rooms_in),
|
||||||
|
ChatGroupMemberStats.day >= d0, ChatGroupMemberStats.day <= d1,
|
||||||
|
).group_by(ChatGroupMemberStats.owner_account_id, ChatGroupMemberStats.room_id)
|
||||||
|
)).all():
|
||||||
|
added_map[(owner, room)] = int(a)
|
||||||
|
lost_map[(owner, room)] = int(l)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
out = GroupOut.model_validate(r)
|
||||||
|
acc = acc_map.get(r.owner_account_id or "")
|
||||||
|
if acc:
|
||||||
|
out.staff_nickname, out.staff_wx_alias, out.staff_avatar = acc
|
||||||
|
st = staff_map.get(r.owner_user_id or "")
|
||||||
|
if st:
|
||||||
|
out.staff_name, out.staff_phone = st
|
||||||
|
if msg_start is not None and msg_end is not None:
|
||||||
|
key = (r.owner_account_id or "", r.room_id)
|
||||||
|
out.window_msg_count = msg_cnt.get(key, 0)
|
||||||
|
out.window_added = added_map.get(key, 0)
|
||||||
|
out.window_lost = lost_map.get(key, 0)
|
||||||
|
items.append(out)
|
||||||
return Page(items=items, total=total, page=page, page_size=page_size)
|
return Page(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""POST /api/auth/login — username/password sign-in for the SPA.
|
||||||
|
|
||||||
|
Exchanges admin credentials (``Settings.admin_user`` / ``admin_password``,
|
||||||
|
env-overridable) for the shared ``api_token`` so browser users never have to
|
||||||
|
handle the raw token string. The endpoint is exempt from the shared-token
|
||||||
|
middleware (see ``app.api.auth._PUBLIC_PATHS``) and rate-limited per IP by the
|
||||||
|
brute-force guard in ``app.api.auth``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.auth import (
|
||||||
|
_client_ip,
|
||||||
|
_login_locked_out,
|
||||||
|
_record_login_failure,
|
||||||
|
_reset_login_failures,
|
||||||
|
)
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginBody, request: Request) -> dict[str, str]:
|
||||||
|
settings = get_settings()
|
||||||
|
ip = _client_ip(request)
|
||||||
|
|
||||||
|
if _login_locked_out(ip):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="尝试次数过多,请 15 分钟后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
credentials_ok = secrets.compare_digest(
|
||||||
|
body.username.encode("utf-8"), settings.admin_user.encode("utf-8")
|
||||||
|
) and secrets.compare_digest(
|
||||||
|
body.password.encode("utf-8"), settings.admin_password.encode("utf-8")
|
||||||
|
)
|
||||||
|
if not credentials_ok:
|
||||||
|
# Same generic message for unknown user / wrong password (no
|
||||||
|
# user enumeration).
|
||||||
|
_record_login_failure(ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
)
|
||||||
|
|
||||||
|
_reset_login_failures(ip)
|
||||||
|
# Empty token when auth is disabled — the SPA stores it and the request
|
||||||
|
# interceptor simply skips the empty value.
|
||||||
|
return {"token": settings.api_token}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
"""GET /api/management/* —— 经营管理后台只读端组(批次B Task B4)。
|
||||||
|
|
||||||
|
六个端点:dashboard(总览)/ employees(列表,一期四视图共用行集)/
|
||||||
|
employees/{user_id}(详情)/ employees/{user_id}/metrics(分段指标+趋势)/
|
||||||
|
employees/{user_id}/drilldown(白名单下钻)/ metric-definitions(口径字典)。
|
||||||
|
|
||||||
|
惯例同 analytics.py:channel=all 在 router 层翻译为 None 传服务层;
|
||||||
|
start/end yyyymmdd 缺一/日历非法/start>end/跨度>366 天皆 422(_window);
|
||||||
|
重查询与行构造在 app.management.api_assembly,本文件只做薄编排。
|
||||||
|
所有查询共用单 AsyncSession——**顺序 await,不并发**(见 assembly 模块)。
|
||||||
|
"""
|
||||||
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.analytics import health as health_svc
|
||||||
|
from app.analytics import metrics as metrics_svc
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.db.models import Staff, WechatAccount, WhatsappAccount
|
||||||
|
from app.management import api_assembly as asm
|
||||||
|
from app.management.anomalies import detect_anomalies
|
||||||
|
from app.management.employee_metrics import (
|
||||||
|
baseline_window,
|
||||||
|
day_range,
|
||||||
|
period_metrics,
|
||||||
|
prev_window,
|
||||||
|
)
|
||||||
|
from app.management.metric_definitions import METRIC_DEFINITIONS
|
||||||
|
from app.storage.employee_metrics import METRIC_VERSION
|
||||||
|
from app.storage.staff_summary import account_map
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
|
||||||
|
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
|
||||||
|
CompareParam = Annotated[
|
||||||
|
Literal["previous", "personal_baseline", "team_median"], Query()
|
||||||
|
]
|
||||||
|
ViewParam = Annotated[
|
||||||
|
Literal["management", "workload", "service", "customer"], Query()
|
||||||
|
]
|
||||||
|
StatusParam = Annotated[Literal["all", "on_job", "offline"], Query()]
|
||||||
|
SortByParam = Annotated[
|
||||||
|
Literal["anomaly_severity", "total_score", "active_friends", "cust_threads"],
|
||||||
|
Query(),
|
||||||
|
]
|
||||||
|
SortOrderParam = Annotated[Literal["asc", "desc"], Query()]
|
||||||
|
SectionParam = Annotated[
|
||||||
|
Literal["overview", "service", "customer", "workload", "calls"], Query()
|
||||||
|
]
|
||||||
|
|
||||||
|
# drilldown 白名单(外 422 "unknown metric_code")
|
||||||
|
_DRILLDOWN_CODES = frozenset({
|
||||||
|
"first_reply_p50", "first_reply_p90", "unreplied",
|
||||||
|
"cooling_customers", "dormant_customers", "call_followup",
|
||||||
|
})
|
||||||
|
|
||||||
|
# unreplied 适配:上游无私有 owner 过滤,取全量决赛圈结果再按 owner 过滤。
|
||||||
|
# 500 = analytics.metrics._FINALIZE_ALL_MAX 同值(候选 ≤500 时单次调用即
|
||||||
|
# 拿到全部已过滤 items;超过则上游本就按候选分页,响应标 truncated)。
|
||||||
|
_UNREPLIED_FETCH = 500
|
||||||
|
|
||||||
|
# 三方口径差异说明(硬编码,与 METRIC_DEFINITIONS.caliber 互补的总述)
|
||||||
|
CALIBER_NOTES = (
|
||||||
|
"管理域指标为 1:1 私聊口径且跨日精确(消息串按会话全历史计算、"
|
||||||
|
"归属串末条所在自然日);现有看板为私聊+群聊混合、日切口径"
|
||||||
|
"(跨夜串重复计/丢失,回复率分子分母同源近似)。"
|
||||||
|
"通话指标(通话数/时长/接通/跟进)无消息渠道维度——"
|
||||||
|
"voice_call 与 wa_voice_call 合并计数,不随 channel 参数过滤。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 各 section 的期指标字段(自 PeriodMetrics 挑)与宽表/通话表趋势列
|
||||||
|
_SECTION_FIELDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"overview": ("days_with_data", "msg_sent", "msg_recv", "active_friends",
|
||||||
|
"active_rooms_avg"),
|
||||||
|
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
|
||||||
|
"fr_p50_ms", "fr_p90_ms", "reply_2h_count", "reply_24h_count"),
|
||||||
|
"customer": ("new_customer", "cooling_customer", "dormant_customer",
|
||||||
|
"reactivated_customer", "end_active_friends"),
|
||||||
|
"workload": ("active_friends", "active_rooms_avg", "cust_threads"),
|
||||||
|
"calls": ("call_count", "call_seconds", "answered_call", "call_followup"),
|
||||||
|
}
|
||||||
|
_SECTION_TREND_COLS: dict[str, tuple[str, ...]] = {
|
||||||
|
"overview": ("msg_sent", "msg_recv", "active_friends"),
|
||||||
|
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
|
||||||
|
"first_reply_p50_ms", "first_reply_p90_ms"),
|
||||||
|
"customer": ("new_customer_count", "cooling_customer_count",
|
||||||
|
"dormant_customer_count", "reactivated_customer_count"),
|
||||||
|
"workload": ("active_friends", "active_rooms"),
|
||||||
|
"calls": ("call_count", "call_seconds", "answered_call_count",
|
||||||
|
"call_followup_count"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _window(start: int | None, end: int | None) -> tuple[int, int]:
|
||||||
|
"""管理端点公共窗口校验(照 analytics custom scope 惯例)。"""
|
||||||
|
if start is None or end is None:
|
||||||
|
raise HTTPException(422, "start and end are required")
|
||||||
|
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, "span must be <= 366 days")
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _translate(channel: str) -> str | None:
|
||||||
|
return None if channel == "all" else channel
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_staff(session: AsyncSession, user_id: str) -> Staff:
|
||||||
|
staff = (await session.execute(
|
||||||
|
sa.select(Staff).where(Staff.user_id == user_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if staff is None:
|
||||||
|
raise HTTPException(404, f"employee {user_id} not found")
|
||||||
|
return staff
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def management_dashboard(
|
||||||
|
session: SessionDep,
|
||||||
|
channel: AllChannelParam = "all",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
|
compare: CompareParam = "previous",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""经营管理总览:KPI + 双趋势 + 生命周期 + 维度分布 + 异常汇总 +
|
||||||
|
行动入口计数,单往返。compare 仅回显(对比数值在 employees 行内)。"""
|
||||||
|
start, end = _window(start, end)
|
||||||
|
ch = _translate(channel)
|
||||||
|
config, config_source = await asm.load_score_config(session)
|
||||||
|
assembled = await asm.assemble_employees(
|
||||||
|
session, config, start, end, ch, with_prev=False
|
||||||
|
)
|
||||||
|
pms = [a["cur"] for a in assembled.values()]
|
||||||
|
|
||||||
|
# ---- 趋势两查:宽表全员逐日 + 通话小表逐日 + fact 逐日 ----
|
||||||
|
wide = await asm.wide_daily_rows(session, start, end, ch)
|
||||||
|
wide_by_day = {d["day"]: d for d in wide}
|
||||||
|
threads_by_day = {d["day"]: d["cust_threads"] for d in wide}
|
||||||
|
calls = await asm.call_daily_rows(session, start, end)
|
||||||
|
call_by_day = {d["day"]: d["call_count"] for d in calls}
|
||||||
|
comm_trend = []
|
||||||
|
for day in day_range(start, end):
|
||||||
|
d = wide_by_day.get(day)
|
||||||
|
comm_trend.append({
|
||||||
|
"day": day,
|
||||||
|
"msg_total": (d["msg_sent"] + d["msg_recv"]) if d else 0,
|
||||||
|
# 团队合计口径:当日各员工宽表 active_friends 求和
|
||||||
|
"active_customers": d["active_friends"] if d else 0,
|
||||||
|
"call_cnt": call_by_day.get(day, 0),
|
||||||
|
})
|
||||||
|
service_trend = await asm.service_daily_trend(
|
||||||
|
session, start, end, ch, threads_by_day
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycle = {
|
||||||
|
"new": sum(p.new_customer for p in pms),
|
||||||
|
"cooling": sum(p.cooling_customer for p in pms),
|
||||||
|
"dormant": sum(p.dormant_customer for p in pms),
|
||||||
|
"reactivated": sum(p.reactivated_customer for p in pms),
|
||||||
|
}
|
||||||
|
kpis = {
|
||||||
|
# 全员 active_friends 求和:跨员工可能重复计客户(共享客户)
|
||||||
|
"active_customers": sum(p.active_friends for p in pms),
|
||||||
|
"active_employees": sum(1 for p in pms if p.days_with_data > 0),
|
||||||
|
"msg_total": sum(p.msg_sent + p.msg_recv for p in pms),
|
||||||
|
"cust_threads": sum(p.cust_threads for p in pms),
|
||||||
|
"threads_answered": sum(p.threads_answered for p in pms),
|
||||||
|
"call_count": sum(p.call_count for p in pms),
|
||||||
|
"call_seconds": sum(p.call_seconds for p in pms),
|
||||||
|
"answered_call": sum(p.answered_call for p in pms),
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 前窗环比基线(批次D):prev 窗全员一次 period_metrics,聚合式与
|
||||||
|
# kpis 完全同式(活跃客户=Σactive_friends、活跃员工=天数>0 计数、
|
||||||
|
# msg_total=Σ(sent+recv)、cust_threads=Σ)。lifecycle(期末存量)与
|
||||||
|
# 通话(全渠道口径,表无渠道维)不加环比——期末/混合口径的环比意义弱。
|
||||||
|
ps, pe = prev_window(start, end)
|
||||||
|
prev_pms = list((await period_metrics(session, ps, pe, ch)).values())
|
||||||
|
prev_kpis = {
|
||||||
|
# 全员求和口径同 kpis(跨员工共享客户可能重复计)
|
||||||
|
"active_customers": sum(p.active_friends for p in prev_pms),
|
||||||
|
"active_employees": sum(1 for p in prev_pms if p.days_with_data > 0),
|
||||||
|
"msg_total": sum(p.msg_sent + p.msg_recv for p in prev_pms),
|
||||||
|
"cust_threads": sum(p.cust_threads for p in prev_pms),
|
||||||
|
}
|
||||||
|
# 前窗无消息(prev=0)不算环比——None 而非 0/负∞
|
||||||
|
prev_msg = prev_kpis["msg_total"]
|
||||||
|
msg_delta = (
|
||||||
|
round((kpis["msg_total"] - prev_msg) / prev_msg, 3)
|
||||||
|
if prev_msg > 0 else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 维度分布(全员 score_employee 的维度状态计数) ----
|
||||||
|
dim_summary: dict[str, dict] = {}
|
||||||
|
for a in assembled.values():
|
||||||
|
for key, dim in a["score"]["dimensions"].items():
|
||||||
|
e = dim_summary.setdefault(key, {
|
||||||
|
"name": dim.get("name"), "weight": dim.get("weight", 0),
|
||||||
|
"scored_count": 0, "insufficient_count": 0,
|
||||||
|
"pending_count": 0, "_scores": [],
|
||||||
|
})
|
||||||
|
if dim["status"] == "scored":
|
||||||
|
e["scored_count"] += 1
|
||||||
|
e["_scores"].append(dim["score"])
|
||||||
|
elif dim["status"] == "insufficient":
|
||||||
|
e["insufficient_count"] += 1
|
||||||
|
else: # pending / pending_data
|
||||||
|
e["pending_count"] += 1
|
||||||
|
for e in dim_summary.values():
|
||||||
|
scores = e.pop("_scores")
|
||||||
|
e["avg_score"] = (
|
||||||
|
sum(scores) / len(scores) if scores else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ----
|
||||||
|
# dashboard 卡片 enrich employee_name(staff 名,assemble 已在手)供前端
|
||||||
|
# 跳员工详情;员工详情端点的 anomalies 不带(自指无意义)
|
||||||
|
anomalies = [
|
||||||
|
{**a, "employee_name": data["name"]}
|
||||||
|
for data in assembled.values()
|
||||||
|
for a in data["anomalies"]
|
||||||
|
]
|
||||||
|
sev_rank = {"critical": 0, "warning": 1}
|
||||||
|
anomalies.sort(key=lambda a: (
|
||||||
|
sev_rank.get(a["severity"], 9),
|
||||||
|
-a.get("affected_count", 0),
|
||||||
|
a["employee_user_id"],
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope": {"channel": channel, "start": start, "end": end,
|
||||||
|
"compare": compare},
|
||||||
|
"data_as_of": await health_svc.data_as_of(session),
|
||||||
|
"metric_version": METRIC_VERSION,
|
||||||
|
"config_source": config_source,
|
||||||
|
"kpis": kpis,
|
||||||
|
"prev_kpis": prev_kpis,
|
||||||
|
"msg_delta": msg_delta,
|
||||||
|
"communication_trend": comm_trend,
|
||||||
|
"service_trend": service_trend,
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
"dimension_summary": dim_summary,
|
||||||
|
"anomalies": anomalies,
|
||||||
|
"action_summary": await metrics_svc.action_counts(session, channel=ch),
|
||||||
|
"caliber_notes": {
|
||||||
|
"active_customers": "kpis.active_customers 为全员周期 distinct "
|
||||||
|
"求和,跨员工共享客户可能重复计",
|
||||||
|
"trend_active_customers": "communication_trend.active_customers "
|
||||||
|
"为宽表日值团队合计(日口径求和)",
|
||||||
|
"reply_2h_rate": "分子=fact 当日 ≤2h 计数,分母=宽表当日串数",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees")
|
||||||
|
async def management_employees(
|
||||||
|
session: SessionDep,
|
||||||
|
channel: AllChannelParam = "all",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
|
view: ViewParam = "management",
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: StatusParam = "all",
|
||||||
|
sort_by: SortByParam = "anomaly_severity",
|
||||||
|
sort_order: SortOrderParam = "desc",
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""员工列表(一期 management/workload/service/customer 四视图共用
|
||||||
|
行集,view 仅回显)。默认 anomaly_severity 排序:含 critical 者 →
|
||||||
|
权重和 → 卡片数;total_score 排序 None 恒排最后。"""
|
||||||
|
start, end = _window(start, end)
|
||||||
|
ch = _translate(channel)
|
||||||
|
config, config_source = await asm.load_score_config(session)
|
||||||
|
assembled = await asm.assemble_employees(
|
||||||
|
session, config, start, end, ch, with_prev=True
|
||||||
|
)
|
||||||
|
items = list(assembled.items())
|
||||||
|
|
||||||
|
if ch is not None:
|
||||||
|
# 渠道过滤=账号归属(该渠道无账号的员工不进列表;有账号无数据仍
|
||||||
|
# 展示零值行)
|
||||||
|
owners_uids = set((await account_map(session, ch)).values())
|
||||||
|
items = [(u, d) for u, d in items if u in owners_uids]
|
||||||
|
if keyword:
|
||||||
|
kw = keyword.strip().lower()
|
||||||
|
items = [
|
||||||
|
(u, d) for u, d in items
|
||||||
|
if kw in (d["name"] or "").lower() or kw in u.lower()
|
||||||
|
]
|
||||||
|
if status == "on_job":
|
||||||
|
items = [(u, d) for u, d in items if d["is_on_job"] is True]
|
||||||
|
elif status == "offline":
|
||||||
|
items = [(u, d) for u, d in items if d["is_on_job"] is not True]
|
||||||
|
|
||||||
|
items.sort(key=lambda kv: kv[0]) # 决胜底座:user_id asc(稳定排序)
|
||||||
|
if sort_by == "anomaly_severity":
|
||||||
|
items.sort(key=lambda kv: (
|
||||||
|
any(a["severity"] == "critical" for a in kv[1]["anomalies"]),
|
||||||
|
kv[1]["anomaly_weight"], kv[1]["anomaly_count"],
|
||||||
|
), reverse=(sort_order == "desc"))
|
||||||
|
elif sort_by == "total_score":
|
||||||
|
# None(样本不足等)恒排最后,与升降序无关
|
||||||
|
if sort_order == "desc":
|
||||||
|
items.sort(key=lambda kv: (
|
||||||
|
kv[1]["score"]["total_score"] is None,
|
||||||
|
-(kv[1]["score"]["total_score"] or 0),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
items.sort(key=lambda kv: (
|
||||||
|
kv[1]["score"]["total_score"] is None,
|
||||||
|
kv[1]["score"]["total_score"] or 0,
|
||||||
|
))
|
||||||
|
else: # active_friends / cust_threads(PeriodMetrics 数值列)
|
||||||
|
if sort_order == "desc":
|
||||||
|
items.sort(key=lambda kv: -getattr(kv[1]["cur"], sort_by))
|
||||||
|
else:
|
||||||
|
items.sort(key=lambda kv: getattr(kv[1]["cur"], sort_by))
|
||||||
|
|
||||||
|
total = len(items)
|
||||||
|
page_rows = items[(page - 1) * page_size: page * page_size]
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"rows": [asm.build_employee_row(u, d, config) for u, d in page_rows],
|
||||||
|
"scope": {"channel": channel, "start": start, "end": end,
|
||||||
|
"view": view},
|
||||||
|
"data_as_of": await health_svc.data_as_of(session),
|
||||||
|
"metric_version": METRIC_VERSION,
|
||||||
|
"config_source": config_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees/{user_id}")
|
||||||
|
async def management_employee_detail(
|
||||||
|
session: SessionDep,
|
||||||
|
user_id: str,
|
||||||
|
channel: AllChannelParam = "all",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""员工详情:身份+账号清单+期指标全字段+评分完整明细(含逐指标
|
||||||
|
metrics_detail)+异常卡片+prev/baseline 原始 PeriodMetrics。"""
|
||||||
|
start, end = _window(start, end)
|
||||||
|
ch = _translate(channel)
|
||||||
|
staff = await _get_staff(session, user_id)
|
||||||
|
config, config_source = await asm.load_score_config(session)
|
||||||
|
|
||||||
|
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
|
||||||
|
bs, be = baseline_window(start)
|
||||||
|
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
|
||||||
|
ps, pe = prev_window(start, end)
|
||||||
|
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
|
||||||
|
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
|
||||||
|
prev_map.get(user_id))
|
||||||
|
if cur is None: # 防御:_get_staff 已保证在 staff,period_metrics 必含
|
||||||
|
raise HTTPException(404, f"employee {user_id} not found")
|
||||||
|
team = asm.team_derived_medians(
|
||||||
|
await period_metrics(session, start, end, ch)
|
||||||
|
)
|
||||||
|
score = asm.score_with_guards(config, cur, baseline=base, team=team,
|
||||||
|
prev=prev)
|
||||||
|
anomalies = detect_anomalies(
|
||||||
|
user_id, cur, base, n_days=len(day_range(start, end)), config=config
|
||||||
|
)
|
||||||
|
|
||||||
|
accounts: list[dict] = []
|
||||||
|
for wa in (await session.execute(
|
||||||
|
sa.select(WechatAccount).where(WechatAccount.user_id == user_id)
|
||||||
|
)).scalars():
|
||||||
|
accounts.append({
|
||||||
|
"channel": "wechat", "account_id": wa.wx_id,
|
||||||
|
"nick_name": wa.nick_name, "user_name": wa.user_name,
|
||||||
|
})
|
||||||
|
for wa in (await session.execute(
|
||||||
|
sa.select(WhatsappAccount).where(WhatsappAccount.user_id == user_id)
|
||||||
|
)).scalars():
|
||||||
|
accounts.append({
|
||||||
|
"channel": "whatsapp", "account_id": wa.uid,
|
||||||
|
"nick_name": wa.nick_name, "user_name": wa.user_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"name": staff.name,
|
||||||
|
"is_on_job": staff.is_on_job,
|
||||||
|
"accounts": accounts,
|
||||||
|
"metrics": cur._asdict(),
|
||||||
|
"score": score,
|
||||||
|
"anomalies": anomalies,
|
||||||
|
"prev": prev._asdict() if prev is not None else None,
|
||||||
|
"baseline": base._asdict() if base is not None else None,
|
||||||
|
"scope": {"channel": channel, "start": start, "end": end},
|
||||||
|
"data_as_of": await health_svc.data_as_of(session),
|
||||||
|
"metric_version": METRIC_VERSION,
|
||||||
|
"config_source": config_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees/{user_id}/metrics")
|
||||||
|
async def management_employee_metrics(
|
||||||
|
session: SessionDep,
|
||||||
|
user_id: str,
|
||||||
|
section: SectionParam = "overview",
|
||||||
|
channel: AllChannelParam = "all",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""员工分段指标:该域期指标 + 窗口逐日趋势 + 四对比
|
||||||
|
(current/prev/baseline/median,派生率走 scoring.derive_metrics 口径)。"""
|
||||||
|
start, end = _window(start, end)
|
||||||
|
ch = _translate(channel)
|
||||||
|
await _get_staff(session, user_id)
|
||||||
|
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
|
||||||
|
bs, be = baseline_window(start)
|
||||||
|
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
|
||||||
|
ps, pe = prev_window(start, end)
|
||||||
|
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
|
||||||
|
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
|
||||||
|
prev_map.get(user_id))
|
||||||
|
if cur is None:
|
||||||
|
raise HTTPException(404, f"employee {user_id} not found")
|
||||||
|
team = asm.team_derived_medians(
|
||||||
|
await period_metrics(session, start, end, ch)
|
||||||
|
)
|
||||||
|
|
||||||
|
if section == "calls":
|
||||||
|
src_rows = await asm.call_daily_rows(session, start, end,
|
||||||
|
user_id=user_id)
|
||||||
|
else:
|
||||||
|
src_rows = await asm.wide_daily_rows(session, start, end, ch,
|
||||||
|
user_id=user_id)
|
||||||
|
by_day = {r["day"]: r for r in src_rows}
|
||||||
|
cols = _SECTION_TREND_COLS[section]
|
||||||
|
trend = []
|
||||||
|
for day in day_range(start, end):
|
||||||
|
row = by_day.get(day)
|
||||||
|
point: dict[str, Any] = {"day": day}
|
||||||
|
for col in cols:
|
||||||
|
if row is not None:
|
||||||
|
point[col] = row.get(col)
|
||||||
|
else: # 无数据日:计数列补 0,分位列补 None
|
||||||
|
point[col] = None if col.startswith("first_reply") else 0
|
||||||
|
trend.append(point)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"section": section,
|
||||||
|
"metrics": {f: getattr(cur, f) for f in _SECTION_FIELDS[section]},
|
||||||
|
"trend": trend,
|
||||||
|
"comparisons": asm.derived_comparisons(cur, prev, base, team),
|
||||||
|
"scope": {"channel": channel, "start": start, "end": end,
|
||||||
|
"user_id": user_id},
|
||||||
|
"data_as_of": await health_svc.data_as_of(session),
|
||||||
|
"metric_version": METRIC_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees/{user_id}/drilldown")
|
||||||
|
async def management_employee_drilldown(
|
||||||
|
session: SessionDep,
|
||||||
|
user_id: str,
|
||||||
|
metric_code: str,
|
||||||
|
channel: AllChannelParam = "all",
|
||||||
|
start: YmdParam | None = None,
|
||||||
|
end: YmdParam | None = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""指标下钻(白名单六项,外 422):首响分位逐串证据 / 未回串 /
|
||||||
|
降温·沉睡客户名单 / 通话跟进列表。所有分支按员工 owner 账号过滤。"""
|
||||||
|
if metric_code not in _DRILLDOWN_CODES:
|
||||||
|
raise HTTPException(422, f"unknown metric_code: {metric_code}")
|
||||||
|
start, end = _window(start, end)
|
||||||
|
ch = _translate(channel)
|
||||||
|
await _get_staff(session, user_id)
|
||||||
|
scope = {"user_id": user_id, "metric_code": metric_code,
|
||||||
|
"channel": channel, "start": start, "end": end}
|
||||||
|
owners = await asm.user_owner_accounts(session, user_id, ch)
|
||||||
|
|
||||||
|
def _paged(items: list[dict]) -> dict[str, Any]:
|
||||||
|
ix = (page - 1) * page_size
|
||||||
|
return {"items": items[ix: ix + page_size], "total": len(items),
|
||||||
|
"page": page, "page_size": page_size, "scope": scope}
|
||||||
|
|
||||||
|
if metric_code in ("first_reply_p50", "first_reply_p90"):
|
||||||
|
if not owners:
|
||||||
|
return _paged([])
|
||||||
|
total, items = await asm.fr_fact_rows(
|
||||||
|
session, owners, ch, start, end, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
return {"items": items, "total": total, "page": page,
|
||||||
|
"page_size": page_size, "scope": scope}
|
||||||
|
|
||||||
|
if metric_code == "unreplied":
|
||||||
|
if not owners: # 无账号→必空,免进上游决赛圈点查
|
||||||
|
return _paged([])
|
||||||
|
# 上游无私有 owner 过滤:一次拉全量决赛圈结果(候选 ≤500 时完整),
|
||||||
|
# 按 owner 账号过滤后内存分页;候选超限标 truncated(过滤可能不全)
|
||||||
|
res = await metrics_svc.unreplied(
|
||||||
|
session, channel=ch, hours=24, page=1, page_size=_UNREPLIED_FETCH
|
||||||
|
)
|
||||||
|
owner_set = set(owners)
|
||||||
|
owned = [i for i in res["items"]
|
||||||
|
if i["owner_account_id"] in owner_set]
|
||||||
|
out = _paged(owned)
|
||||||
|
out["note"] = ("按该员工 owner 账号自全量未回清单过滤"
|
||||||
|
"(hours=24,与看板清单同源同判定)")
|
||||||
|
if res["total"] > len(res["items"]):
|
||||||
|
out["truncated"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
if metric_code in ("cooling_customers", "dormant_customers"):
|
||||||
|
want = ("cooling" if metric_code == "cooling_customers"
|
||||||
|
else "dormant")
|
||||||
|
if not owners:
|
||||||
|
return _paged([])
|
||||||
|
matches = await asm.lifecycle_customers(session, owners, end, want)
|
||||||
|
return _paged(matches)
|
||||||
|
|
||||||
|
# call_followup:窗口通话列表(start desc)+ 期聚合摘要
|
||||||
|
# (逐通话跟进标记复用批次A _followup_exists 太重,留批次C按需再加)
|
||||||
|
total, items = await asm.user_calls_page(
|
||||||
|
session, user_id, start, end, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
summary_rows = await asm.call_daily_rows(session, start, end,
|
||||||
|
user_id=user_id)
|
||||||
|
summary = {col: sum(r[col] for r in summary_rows)
|
||||||
|
for col in ("call_count", "call_seconds",
|
||||||
|
"answered_call_count", "call_followup_count")}
|
||||||
|
return {"items": items, "total": int(total), "page": page,
|
||||||
|
"page_size": page_size, "summary": summary, "scope": scope,
|
||||||
|
"note": "逐通话跟进标记留批次C;此处为窗口通话列表+期聚合"
|
||||||
|
"(followup 为接通后24h内有跟进的通话数)"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metric-definitions")
|
||||||
|
async def management_metric_definitions(
|
||||||
|
session: SessionDep,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""指标口径字典 + 当前评分配置(含 pending 维度)+ 三方口径差异说明。
|
||||||
|
config_source: "db"=激活配置 / "default"=未种子回退默认。"""
|
||||||
|
config, source = await asm.load_score_config(session)
|
||||||
|
return {
|
||||||
|
"definitions": METRIC_DEFINITIONS,
|
||||||
|
"score_config": {
|
||||||
|
"version": config.get("version"),
|
||||||
|
"name": config.get("name"),
|
||||||
|
"trial": config.get("trial"),
|
||||||
|
"normalization": config.get("normalization"),
|
||||||
|
"sample_guards": config.get("sample_guards"),
|
||||||
|
"grade_bounds": config.get("grade_bounds"),
|
||||||
|
"dimensions": config.get("dimensions"),
|
||||||
|
},
|
||||||
|
"config_source": source,
|
||||||
|
"caliber_notes": CALIBER_NOTES,
|
||||||
|
}
|
||||||
@@ -12,10 +12,20 @@ Read-only query over the mirrored ``message`` table. Filters:
|
|||||||
see ``_MSG_TYPE_FILTER``). Unknown values are rejected with 422.
|
see ``_MSG_TYPE_FILTER``). Unknown values are rejected with 422.
|
||||||
* ``q`` — substring match on ``text`` (``%``/``_`` escaped
|
* ``q`` — substring match on ``text`` (``%``/``_`` escaped
|
||||||
as literals).
|
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
|
Ordered by ``(timestamp, msg_id)`` in the requested direction. The index
|
||||||
``ix_message_channel_owner_talker_time`` covers the hot path
|
``ix_message_channel_owner_talker_time`` covers the 1:1 hot path
|
||||||
``(channel, owner_account_id, talker_id, timestamp)``.
|
``(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
|
from __future__ import annotations
|
||||||
@@ -23,7 +33,7 @@ from __future__ import annotations
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
@@ -70,8 +80,16 @@ async def list_messages(
|
|||||||
end: Annotated[int | None, Query(ge=0)] = None,
|
end: Annotated[int | None, Query(ge=0)] = None,
|
||||||
msg_type: Annotated[MsgTypeParam | None, Query()] = None,
|
msg_type: Annotated[MsgTypeParam | None, Query()] = None,
|
||||||
q: Annotated[str | 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]:
|
) -> 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 = []
|
conditions = []
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
conditions.append(Message.channel == channel)
|
conditions.append(Message.channel == channel)
|
||||||
@@ -91,20 +109,30 @@ async def list_messages(
|
|||||||
# LIKE 通配符转义: %/_ 按字面匹配
|
# LIKE 通配符转义: %/_ 按字面匹配
|
||||||
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
conditions.append(Message.text.like(f"%{escaped}%", escape="\\"))
|
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)
|
total_stmt = sa.select(sa.func.count()).select_from(Message)
|
||||||
if conditions:
|
if conditions:
|
||||||
total_stmt = total_stmt.where(*conditions)
|
total_stmt = total_stmt.where(*conditions)
|
||||||
total = (await session.execute(total_stmt)).scalar_one()
|
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)
|
list_stmt = sa.select(Message)
|
||||||
if conditions:
|
if conditions:
|
||||||
list_stmt = list_stmt.where(*conditions)
|
list_stmt = list_stmt.where(*conditions)
|
||||||
list_stmt = (
|
list_stmt = (
|
||||||
list_stmt.order_by(
|
list_stmt.order_by(sa.nullslast(ts_order), id_order)
|
||||||
sa.nullslast(Message.timestamp.asc()),
|
|
||||||
Message.msg_id.asc(),
|
|
||||||
)
|
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间),全本地 message 聚合。
|
"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间)。
|
||||||
|
|
||||||
镜像 staff_chat_stats 聚合模式(GROUP BY + case 计数 + Python 侧合并昵称/头像)。
|
**双路径(2026-09-03 会话列表超时修复)**:
|
||||||
today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区。
|
- ``msg_daily_stats`` 有行 → 读日粒度汇总(每会话「今日行 + MAX(last_ts)」,
|
||||||
|
毫秒级;旧实时全量聚合在重账号上实测 3~5s,128MB buffer pool 下更被推过
|
||||||
|
前端 15s 超时)。
|
||||||
|
- 空表(部署回填前守卫)→ 回退旧实时聚合,行为不变。
|
||||||
|
|
||||||
|
today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区;汇总层
|
||||||
|
``day_key`` 同口径。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -15,7 +22,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import SessionListOut, SessionOut
|
from app.api.schemas import SessionListOut, SessionOut
|
||||||
from app.db.models import ChatGroup, Customer, Message
|
from app.db.models import (
|
||||||
|
ChatGroup,
|
||||||
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
|
Message,
|
||||||
|
MsgDailyStats,
|
||||||
|
)
|
||||||
|
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["sessions"])
|
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||||
|
|
||||||
@@ -63,8 +77,59 @@ async def _group_rows(session: AsyncSession, owner: str, channel: str, today_sta
|
|||||||
return (await session.execute(stmt)).all()
|
return (await session.execute(stmt)).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 汇总路径:msg_daily_stats 日行(namedtuple 与旧 Row 同形,端点主体零改动)── #
|
||||||
|
|
||||||
|
_FriendRow = namedtuple("_FriendRow", ["talker_id", "last_chat_time", "today_count"])
|
||||||
|
_GroupRow = namedtuple("_GroupRow", ["room_id", "last_chat_time", "today_count"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _summary_rows(
|
||||||
|
session: AsyncSession, owner: str, channel: str, kind: str, today: int
|
||||||
|
) -> dict[str, tuple[int | None, int]]:
|
||||||
|
"""peer → (last_chat_time, today_count),来自日行聚合。
|
||||||
|
|
||||||
|
last = MAX(last_ts)(日行内时间单调,跨日最大即全史最后一条);
|
||||||
|
today = 当日行的 send+recv。
|
||||||
|
"""
|
||||||
|
base = (
|
||||||
|
MsgDailyStats.channel == channel,
|
||||||
|
MsgDailyStats.owner_account_id == owner,
|
||||||
|
MsgDailyStats.peer_kind == kind,
|
||||||
|
)
|
||||||
|
last = {
|
||||||
|
peer: last_ts
|
||||||
|
for peer, last_ts in (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(MsgDailyStats.peer_id, sa.func.max(MsgDailyStats.last_ts))
|
||||||
|
.where(*base)
|
||||||
|
.group_by(MsgDailyStats.peer_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
today_counts = {
|
||||||
|
peer: int(cnt or 0)
|
||||||
|
for peer, cnt in (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
sa.func.coalesce(sa.func.sum(MsgDailyStats.send_cnt + MsgDailyStats.recv_cnt), 0),
|
||||||
|
)
|
||||||
|
.where(*base, MsgDailyStats.day == today)
|
||||||
|
.group_by(MsgDailyStats.peer_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
return {peer: (last_ts, today_counts.get(peer, 0)) for peer, last_ts in last.items()}
|
||||||
|
|
||||||
|
|
||||||
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
|
async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]):
|
||||||
"""查 customer/chat_group 表合并昵称/头像;好友行额外带 delete_flag(is_del 过滤用)。
|
"""查 customer_account_link(主)/customer(兜底)/chat_group 合并昵称/头像。
|
||||||
|
|
||||||
|
好友名优先读 ``customer_account_link`` 的 per-owner 快照——共享客户的
|
||||||
|
档案行(customer 全局单行,last-wins)可能挂在别的员工名下,按 owner 查
|
||||||
|
档案会查空,会话列表就回退显示 wxid(2026-09-09 用户报「有昵称却显示
|
||||||
|
微信号」);link 表每账号一行,回填后都有昵称。link 没覆盖的(空表过渡/
|
||||||
|
老数据)回退档案路径。
|
||||||
|
|
||||||
friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为
|
friends 值为 ``(name, avatar, delete_flag)``;群无删除标记,groups 值仍为
|
||||||
``(name, avatar)``。
|
``(name, avatar)``。
|
||||||
@@ -73,12 +138,29 @@ async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: lis
|
|||||||
if friend_ids:
|
if friend_ids:
|
||||||
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
||||||
sa.select(
|
sa.select(
|
||||||
Customer.friend_id, Customer.friend_remark, Customer.friend_nickname,
|
CustomerAccountLink.friend_id, CustomerAccountLink.friend_remark,
|
||||||
Customer.real_name, Customer.head_image_url, Customer.delete_flag,
|
CustomerAccountLink.friend_nickname, CustomerAccountLink.real_name,
|
||||||
|
CustomerAccountLink.head_image_url, CustomerAccountLink.delete_flag,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CustomerAccountLink.channel == channel,
|
||||||
|
CustomerAccountLink.owner_account_id == owner,
|
||||||
|
CustomerAccountLink.friend_id.in_(friend_ids),
|
||||||
)
|
)
|
||||||
.where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(friend_ids))
|
|
||||||
)).all():
|
)).all():
|
||||||
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
||||||
|
# link 未覆盖的好友回退档案(按 owner 过滤;共享好友在此仍可能查空,
|
||||||
|
# 上层以 wxid 兜底显示)
|
||||||
|
missing = [fid for fid in friend_ids if fid not in friends]
|
||||||
|
if missing:
|
||||||
|
for fid, remark, nick, real, avatar, delete_flag in (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Customer.friend_id, Customer.friend_remark, Customer.friend_nickname,
|
||||||
|
Customer.real_name, Customer.head_image_url, Customer.delete_flag,
|
||||||
|
)
|
||||||
|
.where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(missing))
|
||||||
|
)).all():
|
||||||
|
friends[fid] = (remark or nick or real or fid, avatar, delete_flag)
|
||||||
groups: dict[str, tuple[str, str | None]] = {}
|
groups: dict[str, tuple[str, str | None]] = {}
|
||||||
if room_ids:
|
if room_ids:
|
||||||
for rid, name, avatar in (await session.execute(
|
for rid, name, avatar in (await session.execute(
|
||||||
@@ -105,8 +187,14 @@ async def list_sessions(
|
|||||||
``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。
|
``pinned``:filehelper 会话置顶标记(对齐云客置顶区),其余好友/群恒 False。
|
||||||
"""
|
"""
|
||||||
today_start = _today_start_ms()
|
today_start = _today_start_ms()
|
||||||
f_rows = await _friend_rows(session, owner_account_id, channel, today_start)
|
if await _summary_has_rows(session):
|
||||||
g_rows = await _group_rows(session, owner_account_id, channel, today_start)
|
f_map = await _summary_rows(session, owner_account_id, channel, "friend", day_key(today_start))
|
||||||
|
g_map = await _summary_rows(session, owner_account_id, channel, "room", day_key(today_start))
|
||||||
|
f_rows = [_FriendRow(talker_id=p, last_chat_time=t, today_count=c) for p, (t, c) in f_map.items()]
|
||||||
|
g_rows = [_GroupRow(room_id=p, last_chat_time=t, today_count=c) for p, (t, c) in g_map.items()]
|
||||||
|
else:
|
||||||
|
f_rows = await _friend_rows(session, owner_account_id, channel, today_start)
|
||||||
|
g_rows = await _group_rows(session, owner_account_id, channel, today_start)
|
||||||
fnames, gnames = await _meta(
|
fnames, gnames = await _meta(
|
||||||
session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows]
|
session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 表实时聚合)。
|
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细。
|
||||||
|
|
||||||
|
**双路径(2026-09-03 全时段窗 6.9s 超时修复)**:``msg_daily_stats`` 有行时
|
||||||
|
读日粒度汇总(窗口对齐到自然日,毫秒级);空表回退旧实时聚合,行为不变。
|
||||||
|
窗口语义随之统一为「上海自然日」(旧 SQL 日期函数跟随 DB 会话时区,UTC
|
||||||
|
容器下与今日数口径割裂——顺带修复)。
|
||||||
|
|
||||||
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
|
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
|
||||||
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
|
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
|
||||||
跨库日期提取(codebase 首例)镜像 app/storage/repos.py 的方言检测写法。
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections import namedtuple
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -15,7 +20,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
from app.api.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
||||||
from app.db.models import Customer, Message
|
from app.db.models import Customer, CustomerAccountLink, Message, MsgDailyStats
|
||||||
|
from app.storage.summary import day_key, has_rows as _summary_has_rows
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
||||||
|
|
||||||
@@ -81,23 +87,100 @@ async def _aggregate(session: AsyncSession, owner: str, start: int, end: int) ->
|
|||||||
return agg_rows, last_map
|
return agg_rows, last_map
|
||||||
|
|
||||||
|
|
||||||
|
# ── 汇总路径:msg_daily_stats 日行(namedtuple 与旧 Row 同形)────────────── #
|
||||||
|
|
||||||
|
_AggRow = namedtuple(
|
||||||
|
"_AggRow", ["talker_id", "send_count", "receive_count", "interaction_days", "last_time"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _aggregate_summary(
|
||||||
|
session: AsyncSession, owner: str, start: int, end: int
|
||||||
|
) -> tuple[list, dict[str, bool | None]]:
|
||||||
|
"""窗口 → [day_key(start), day_key(end)] 自然日区间,按日行 Python 聚合。
|
||||||
|
|
||||||
|
interaction_days = 窗内有消息的自然日数;last_time/last_is_send 取窗内
|
||||||
|
最后一条(last_ts 最大的日行)。窗口对齐自然日是文档化的语义变化
|
||||||
|
(前端日期选择器本就按天取值)。
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
MsgDailyStats.day,
|
||||||
|
MsgDailyStats.send_cnt,
|
||||||
|
MsgDailyStats.recv_cnt,
|
||||||
|
MsgDailyStats.last_ts,
|
||||||
|
MsgDailyStats.last_is_send,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.channel == "wechat",
|
||||||
|
MsgDailyStats.owner_account_id == owner,
|
||||||
|
MsgDailyStats.peer_kind == "friend",
|
||||||
|
MsgDailyStats.day >= day_key(start),
|
||||||
|
MsgDailyStats.day <= day_key(end),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
per: dict[str, dict] = {}
|
||||||
|
for peer, _day, send, recv, last_ts, last_is in rows:
|
||||||
|
acc = per.setdefault(
|
||||||
|
peer, {"send": 0, "recv": 0, "days": 0, "last_ts": None, "last_is": None}
|
||||||
|
)
|
||||||
|
acc["send"] += int(send or 0)
|
||||||
|
acc["recv"] += int(recv or 0)
|
||||||
|
acc["days"] += 1
|
||||||
|
if acc["last_ts"] is None or (last_ts is not None and last_ts >= acc["last_ts"]):
|
||||||
|
acc["last_ts"] = last_ts
|
||||||
|
acc["last_is"] = last_is
|
||||||
|
|
||||||
|
agg_rows = [
|
||||||
|
_AggRow(talker_id=p, send_count=a["send"], receive_count=a["recv"],
|
||||||
|
interaction_days=a["days"], last_time=a["last_ts"])
|
||||||
|
for p, a in per.items()
|
||||||
|
]
|
||||||
|
last_map = {p: a["last_is"] for p, a in per.items()}
|
||||||
|
return agg_rows, last_map
|
||||||
|
|
||||||
|
|
||||||
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]:
|
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, tuple[str, str, str]]:
|
||||||
|
"""客户名/头像解析:优先 customer_account_link per-owner 快照,档案兜底。
|
||||||
|
|
||||||
|
共享客户的档案行(customer 全局单行,last-wins)可能挂在别的员工名下,
|
||||||
|
按 owner 查档案会查空、明细列表回退显示 wxid(2026-09-09,同 sessions
|
||||||
|
._meta 修复)。
|
||||||
|
"""
|
||||||
if not talker_ids:
|
if not talker_ids:
|
||||||
return {}
|
return {}
|
||||||
stmt = sa.select(
|
|
||||||
Customer.friend_id,
|
|
||||||
Customer.friend_remark,
|
|
||||||
Customer.friend_nickname,
|
|
||||||
Customer.real_name,
|
|
||||||
Customer.head_image_url,
|
|
||||||
).where(
|
|
||||||
Customer.channel == "wechat",
|
|
||||||
Customer.owner_account_id == owner,
|
|
||||||
Customer.friend_id.in_(talker_ids),
|
|
||||||
)
|
|
||||||
info: dict[str, tuple[str, str, str]] = {}
|
info: dict[str, tuple[str, str, str]] = {}
|
||||||
for fid, remark, nick, real, head in (await session.execute(stmt)).all():
|
link_stmt = sa.select(
|
||||||
|
CustomerAccountLink.friend_id,
|
||||||
|
CustomerAccountLink.friend_remark,
|
||||||
|
CustomerAccountLink.friend_nickname,
|
||||||
|
CustomerAccountLink.real_name,
|
||||||
|
CustomerAccountLink.head_image_url,
|
||||||
|
).where(
|
||||||
|
CustomerAccountLink.channel == "wechat",
|
||||||
|
CustomerAccountLink.owner_account_id == owner,
|
||||||
|
CustomerAccountLink.friend_id.in_(talker_ids),
|
||||||
|
)
|
||||||
|
for fid, remark, nick, real, head in (await session.execute(link_stmt)).all():
|
||||||
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
||||||
|
missing = [t for t in talker_ids if t not in info]
|
||||||
|
if missing:
|
||||||
|
stmt = sa.select(
|
||||||
|
Customer.friend_id,
|
||||||
|
Customer.friend_remark,
|
||||||
|
Customer.friend_nickname,
|
||||||
|
Customer.real_name,
|
||||||
|
Customer.head_image_url,
|
||||||
|
).where(
|
||||||
|
Customer.channel == "wechat",
|
||||||
|
Customer.owner_account_id == owner,
|
||||||
|
Customer.friend_id.in_(missing),
|
||||||
|
)
|
||||||
|
for fid, remark, nick, real, head in (await session.execute(stmt)).all():
|
||||||
|
info[fid] = (remark or nick or real or fid, real or "", head or "")
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
@@ -114,7 +197,10 @@ async def get_staff_chat_stats(
|
|||||||
if start is None:
|
if start is None:
|
||||||
start = end - 3 * _MS_PER_DAY # 默认近 3 天
|
start = end - 3 * _MS_PER_DAY # 默认近 3 天
|
||||||
|
|
||||||
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
|
if await _summary_has_rows(session):
|
||||||
|
agg_rows, last_map = await _aggregate_summary(session, owner_account_id, start, end)
|
||||||
|
else:
|
||||||
|
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
|
||||||
talker_ids = [r.talker_id for r in agg_rows]
|
talker_ids = [r.talker_id for r in agg_rows]
|
||||||
names = await _customer_names(session, owner_account_id, talker_ids)
|
names = await _customer_names(session, owner_account_id, talker_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut
|
from app.api.schemas import StaffStatsCountsOut, StaffStatsDetailOut
|
||||||
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount
|
from app.db.models import (
|
||||||
|
ChatGroup,
|
||||||
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
|
Message,
|
||||||
|
PhoneCall,
|
||||||
|
WechatAccount,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["staff-stats"])
|
router = APIRouter(prefix="/api", tags=["staff-stats"])
|
||||||
|
|
||||||
@@ -96,50 +103,57 @@ async def _new_customers(
|
|||||||
) -> tuple[int, list[dict[str, Any]]]:
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
|
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
|
||||||
|
|
||||||
|
读 ``customer_account_link``(2026-09-09):customer 档案全局单行、
|
||||||
|
归属 last-wins,共享客户在其他员工名下会漏计;link 每账号一行,与
|
||||||
|
云客按员工统计的口径一致(数字会比档案口径大)。
|
||||||
|
|
||||||
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
|
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
|
||||||
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
|
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
|
||||||
"""
|
"""
|
||||||
base = [Customer.channel == "wechat", Customer.owner_account_id == wxid]
|
base = [
|
||||||
|
CustomerAccountLink.channel == "wechat",
|
||||||
|
CustomerAccountLink.owner_account_id == wxid,
|
||||||
|
]
|
||||||
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
||||||
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
||||||
fallback = (
|
fallback = (
|
||||||
sa.select(
|
sa.select(
|
||||||
Customer.friend_id,
|
CustomerAccountLink.friend_id,
|
||||||
Customer.friend_nickname,
|
CustomerAccountLink.friend_nickname,
|
||||||
Customer.friend_remark,
|
CustomerAccountLink.friend_remark,
|
||||||
Customer.head_image_url,
|
CustomerAccountLink.head_image_url,
|
||||||
sa.func.min(Message.timestamp).label("first_ts"),
|
sa.func.min(Message.timestamp).label("first_ts"),
|
||||||
)
|
)
|
||||||
.outerjoin(
|
.outerjoin(
|
||||||
Message,
|
Message,
|
||||||
sa.and_(
|
sa.and_(
|
||||||
Message.channel == "wechat",
|
Message.channel == "wechat",
|
||||||
Message.owner_account_id == Customer.owner_account_id,
|
Message.owner_account_id == CustomerAccountLink.owner_account_id,
|
||||||
Message.talker_id == Customer.friend_id,
|
Message.talker_id == CustomerAccountLink.friend_id,
|
||||||
Message.timestamp.is_not(None),
|
Message.timestamp.is_not(None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where(*base, Customer.add_time.is_(None))
|
.where(*base, CustomerAccountLink.add_time.is_(None))
|
||||||
# GROUP BY 显式列出全部裸选列:friend_id 是 Customer 唯一 PK,其余列
|
# GROUP BY 显式列出全部裸选列:friend_id 是 link 复合 PK 的尾列,
|
||||||
# 函数依赖于它→分组结果不变;MySQL 8 FD 推断可容忍裸列,但显式列表
|
# (owner,friend) 已由 WHERE 钉死,其余列函数依赖于它→分组结果不变;
|
||||||
# 在 ONLY_FULL_GROUP_BY 下部署安全(SQLite 同样兼容)。
|
# MySQL ONLY_FULL_GROUP_BY 部署安全(SQLite 同样兼容)。
|
||||||
.group_by(
|
.group_by(
|
||||||
Customer.friend_id,
|
CustomerAccountLink.friend_id,
|
||||||
Customer.friend_nickname,
|
CustomerAccountLink.friend_nickname,
|
||||||
Customer.friend_remark,
|
CustomerAccountLink.friend_remark,
|
||||||
Customer.head_image_url,
|
CustomerAccountLink.head_image_url,
|
||||||
)
|
)
|
||||||
.having(sa.func.min(Message.timestamp) >= start)
|
.having(sa.func.min(Message.timestamp) >= start)
|
||||||
.having(sa.func.min(Message.timestamp) <= end)
|
.having(sa.func.min(Message.timestamp) <= end)
|
||||||
)
|
)
|
||||||
# 直接口径:add_time 本身在窗内。
|
# 直接口径:add_time 本身在窗内。
|
||||||
direct = sa.select(
|
direct = sa.select(
|
||||||
Customer.friend_id,
|
CustomerAccountLink.friend_id,
|
||||||
Customer.friend_nickname,
|
CustomerAccountLink.friend_nickname,
|
||||||
Customer.friend_remark,
|
CustomerAccountLink.friend_remark,
|
||||||
Customer.head_image_url,
|
CustomerAccountLink.head_image_url,
|
||||||
Customer.add_time,
|
CustomerAccountLink.add_time,
|
||||||
).where(*base, Customer.add_time >= start, Customer.add_time <= end)
|
).where(*base, CustomerAccountLink.add_time >= start, CustomerAccountLink.add_time <= end)
|
||||||
|
|
||||||
rows: list[dict[str, Any]] = [
|
rows: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -237,19 +251,43 @@ async def _red_transfer(
|
|||||||
talker_ids = [m.talker_id for m in msgs if m.talker_id]
|
talker_ids = [m.talker_id for m in msgs if m.talker_id]
|
||||||
nick_map: dict[str, str] = {}
|
nick_map: dict[str, str] = {}
|
||||||
if talker_ids:
|
if talker_ids:
|
||||||
|
# 昵称优先 link per-owner 快照(共享好友档案行可能挂别的员工名下,
|
||||||
|
# 2026-09-09 同 sessions._meta),档案兜底
|
||||||
nick_map = {
|
nick_map = {
|
||||||
fid: nick
|
fid: nick
|
||||||
for fid, nick in (
|
for fid, nick in (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sa.select(Customer.friend_id, Customer.friend_nickname).where(
|
sa.select(
|
||||||
Customer.channel == "wechat",
|
CustomerAccountLink.friend_id,
|
||||||
Customer.owner_account_id == wxid,
|
CustomerAccountLink.friend_nickname,
|
||||||
Customer.friend_id.in_(talker_ids),
|
).where(
|
||||||
|
CustomerAccountLink.channel == "wechat",
|
||||||
|
CustomerAccountLink.owner_account_id == wxid,
|
||||||
|
CustomerAccountLink.friend_id.in_(talker_ids),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
if nick
|
if nick
|
||||||
}
|
}
|
||||||
|
missing = [t for t in talker_ids if t not in nick_map]
|
||||||
|
if missing:
|
||||||
|
nick_map.update(
|
||||||
|
{
|
||||||
|
fid: nick
|
||||||
|
for fid, nick in (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Customer.friend_id, Customer.friend_nickname
|
||||||
|
).where(
|
||||||
|
Customer.channel == "wechat",
|
||||||
|
Customer.owner_account_id == wxid,
|
||||||
|
Customer.friend_id.in_(missing),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if nick
|
||||||
|
}
|
||||||
|
)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for m in msgs:
|
for m in msgs:
|
||||||
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
|
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
|
||||||
@@ -273,15 +311,20 @@ async def _flag_customers(
|
|||||||
flag_cond: sa.ColumnElement,
|
flag_cond: sa.ColumnElement,
|
||||||
*,
|
*,
|
||||||
detail: bool,
|
detail: bool,
|
||||||
|
model: type,
|
||||||
) -> tuple[int, list[dict[str, Any]]]:
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
|
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
|
||||||
|
|
||||||
|
``model`` 由调用方决定(两者列名同形):del 走 ``CustomerAccountLink``
|
||||||
|
(delete_flag per-relation,共享好友不漏),blocked 走 ``Customer``
|
||||||
|
档案(is_block 未镜像进 link,2026-09-09)。
|
||||||
|
|
||||||
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
|
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
|
||||||
"""
|
"""
|
||||||
conds = [Customer.channel == "wechat", Customer.owner_account_id == wxid, flag_cond]
|
conds = [model.channel == "wechat", model.owner_account_id == wxid, flag_cond]
|
||||||
count = (
|
count = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sa.select(sa.func.count()).select_from(Customer).where(*conds)
|
sa.select(sa.func.count()).select_from(model).where(*conds)
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -289,13 +332,13 @@ async def _flag_customers(
|
|||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sa.select(
|
sa.select(
|
||||||
Customer.friend_id,
|
model.friend_id,
|
||||||
Customer.friend_nickname,
|
model.friend_nickname,
|
||||||
Customer.friend_remark,
|
model.friend_remark,
|
||||||
Customer.updated_at,
|
model.updated_at,
|
||||||
)
|
)
|
||||||
.where(*conds)
|
.where(*conds)
|
||||||
.order_by(Customer.updated_at.desc(), Customer.friend_id.asc())
|
.order_by(model.updated_at.desc(), model.friend_id.asc())
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return count, [
|
return count, [
|
||||||
@@ -311,18 +354,26 @@ async def _flag_customers(
|
|||||||
async def _del_customers(
|
async def _del_customers(
|
||||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||||
) -> tuple[int, list[dict[str, Any]]]:
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。"""
|
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。
|
||||||
|
|
||||||
|
读 link(per-relation delete_flag,共享好友不漏计,2026-09-09)。
|
||||||
|
"""
|
||||||
return await _flag_customers(
|
return await _flag_customers(
|
||||||
session, wxid, Customer.delete_flag == 1, detail=detail
|
session, wxid, CustomerAccountLink.delete_flag == 1,
|
||||||
|
detail=detail, model=CustomerAccountLink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _blocked_customers(
|
async def _blocked_customers(
|
||||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||||
) -> tuple[int, list[dict[str, Any]]]:
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。"""
|
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。
|
||||||
|
|
||||||
|
保持档案口径:is_block 未镜像进 link 表,切了会漏;已知偏差=共享好友
|
||||||
|
在其他员工名下的拉黑不计入(待 link 补列后切换)。
|
||||||
|
"""
|
||||||
return await _flag_customers(
|
return await _flag_customers(
|
||||||
session, wxid, Customer.is_block.is_(True), detail=detail
|
session, wxid, Customer.is_block.is_(True), detail=detail, model=Customer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ class CustomerOut(BaseModel):
|
|||||||
last_chat_time: int | None = None
|
last_chat_time: int | None = None
|
||||||
add_time: int | None = None
|
add_time: int | None = None
|
||||||
message_count: int | None = None
|
message_count: int | None = None
|
||||||
|
# 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工
|
||||||
|
# 客户页「发送条数/接收条数」列用,2026-09-08)
|
||||||
|
send_num: int | None = None
|
||||||
|
receive_num: int | None = None
|
||||||
|
# 云客数据来源代码(customer_account_link 路径直读;档案路径为 None,
|
||||||
|
# 前端回退 raw_json.fromType)
|
||||||
|
from_type: int | None = None
|
||||||
version: str | None = None
|
version: str | None = None
|
||||||
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
||||||
# all-fields drawer can render every column the 云客 source page shows
|
# all-fields drawer can render every column the 云客 source page shows
|
||||||
@@ -192,6 +199,17 @@ class GroupOut(BaseModel):
|
|||||||
owner_avatar: str | None = None
|
owner_avatar: str | None = None
|
||||||
friend_count: int | None = None
|
friend_count: int | None = None
|
||||||
create_time: int | None = None # 建群时间 ms epoch
|
create_time: int | None = None # 建群时间 ms epoch
|
||||||
|
# 微信群统计页(2026-09-09,云客 getChatroomList 同款)
|
||||||
|
group_remark: str | None = None # 群备注
|
||||||
|
staff_nickname: str | None = None # 员工微信·昵称(wechat_account.nick_name)
|
||||||
|
staff_wx_alias: str | None = None # 员工微信·微信号(wechat_account.alias)
|
||||||
|
staff_avatar: str | None = None # 员工微信·头像
|
||||||
|
staff_name: str | None = None # 员工信息·姓名(staff.name)
|
||||||
|
staff_phone: str | None = None # 员工信息·手机(staff.phone)
|
||||||
|
# 统计时段窗内聚合(仅请求带 msg_start/msg_end 时非 None):
|
||||||
|
window_msg_count: int | None = None # 沟通消息数(窗内,msg_daily_stats)
|
||||||
|
window_added: int | None = None # 新增成员数(窗内,按天表求和)
|
||||||
|
window_lost: int | None = None # 退出成员数(窗内)
|
||||||
raw_json: dict[str, Any] | None = None
|
raw_json: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ class Settings(BaseSettings):
|
|||||||
# <token>`` or ``?token=<token>``. See app/api/auth.py.
|
# <token>`` or ``?token=<token>``. See app/api/auth.py.
|
||||||
api_token: str = ""
|
api_token: str = ""
|
||||||
|
|
||||||
|
# Username/password for the SPA login page (``POST /api/auth/login``).
|
||||||
|
# On success the login hands out the ``api_token`` above so the SPA can
|
||||||
|
# act as a Bearer client; defaults keep local dev zero-config. Change both
|
||||||
|
# in prod via ADMIN_USER / ADMIN_PASSWORD.
|
||||||
|
admin_user: str = "admin"
|
||||||
|
admin_password: str = "admin123"
|
||||||
|
|
||||||
# Comma-separated list of allowed CORS origins for the viewing API. Empty
|
# Comma-separated list of allowed CORS origins for the viewing API. Empty
|
||||||
# (default) -> only the dev fallback (``http://localhost:5173``) is allowed;
|
# (default) -> only the dev fallback (``http://localhost:5173``) is allowed;
|
||||||
# set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin
|
# set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin
|
||||||
|
|||||||
@@ -256,9 +256,52 @@ class Customer(Base):
|
|||||||
"owner_account_id",
|
"owner_account_id",
|
||||||
"friend_id",
|
"friend_id",
|
||||||
),
|
),
|
||||||
|
# /api/analytics/health MAX(updated_at) 新鲜度探针;无索引时全表扫。
|
||||||
|
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP(c3d4e5f6a7b8)。
|
||||||
|
Index("ix_customer_updated_at", "updated_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAccountLink(Base):
|
||||||
|
"""账号↔客户 多对多关联(customer_account_link,2026-09-08)。
|
||||||
|
|
||||||
|
``customer`` 表 PK=``friend_id`` 全局一客户一行,归属=最后抓取员工
|
||||||
|
(last-wins)——共享客户(同一人加多个员工)在其他员工名下「消失」:
|
||||||
|
实测 Lily 云客好友 36 vs 镜像页面 12,24 个全在别的员工名下。云客
|
||||||
|
的口径是**每个账号各算一个**(per-employee 列表连备注/收发计数都按
|
||||||
|
关系各存一份);本表每个「渠道×账号×客户」一行,由
|
||||||
|
:func:`app.storage.repos.upsert_customer` 随档案同事务维护,员工维度
|
||||||
|
的列表/计数(`/api/customers?owner_account_id=`)读这里。
|
||||||
|
|
||||||
|
快照列为员工客户页所需(昵称/备注/头像 per-relation);全字段档案
|
||||||
|
(raw_json/60 列)仍在 ``customer`` 表按 friend_id 关联。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "customer_account_link"
|
||||||
|
|
||||||
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||||
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
friend_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
# per-relation 展示快照(云客好友记录按员工各一份;备注是员工自己的)
|
||||||
|
friend_nickname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
friend_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
head_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
real_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# 云客 fromType 代码(数据来源列;int,前端 fromTypeLabel 映射)
|
||||||
|
from_type: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
# 同 customer.delete_flag 口径(int 0/1)
|
||||||
|
delete_flag: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
||||||
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
||||||
|
|
||||||
|
|
||||||
class ChatGroup(Base):
|
class ChatGroup(Base):
|
||||||
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
|
"""聊天群 — source: ``POST /wechatweb/chatroom/getChatroomList``.
|
||||||
|
|
||||||
@@ -301,6 +344,9 @@ class ChatGroup(Base):
|
|||||||
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
|
friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数
|
||||||
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
|
create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms epoch
|
||||||
|
# 群备注(微信群统计页「群备注」列;getChatroomList.remark /
|
||||||
|
# friend_list/es 的 friendWxRemark,2026-09-09)
|
||||||
|
group_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -314,9 +360,39 @@ class ChatGroup(Base):
|
|||||||
"owner_account_id",
|
"owner_account_id",
|
||||||
"room_id",
|
"room_id",
|
||||||
),
|
),
|
||||||
|
# /api/analytics/health MAX(updated_at) 新鲜度探针(c3d4e5f6a7b8)。
|
||||||
|
Index("ix_chat_group_updated_at", "updated_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGroupMemberStats(Base):
|
||||||
|
"""群成员进出按天统计(chat_group_member_stats,2026-09-09)。
|
||||||
|
|
||||||
|
云客「微信群统计」的新增/退出成员数是 ``getChatroomList`` 按
|
||||||
|
``startTime~endTime`` 任意窗实时算的;本地无成员进出事件流,改为每夜
|
||||||
|
抓一次「当日窗」的 addMembers/lossMembers 落一行,页面任意日窗=按天
|
||||||
|
求和。**上线日起积累**(历史日期无数据;chat_group.add_members/
|
||||||
|
loss_members 旧列保留作最近一次抓取窗的快照)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "chat_group_member_stats"
|
||||||
|
|
||||||
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||||
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
room_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
# yyyymmdd(上海自然日,同 msg_daily_stats 口径)
|
||||||
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
added: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
lost: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
||||||
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
||||||
|
|
||||||
|
|
||||||
class GroupMember(Base):
|
class GroupMember(Base):
|
||||||
"""群成员 — composite PK (channel, room_id, member_id).
|
"""群成员 — composite PK (channel, room_id, member_id).
|
||||||
|
|
||||||
@@ -429,6 +505,19 @@ class Message(Base):
|
|||||||
"room_id",
|
"room_id",
|
||||||
"timestamp",
|
"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>= 定位当天消息
|
# 今日收发聚合热路径:channel= + owner IN + timestamp>= 定位当天消息
|
||||||
# (customer 列表行内 send_today/receive_today)。上面两个索引把
|
# (customer 列表行内 send_today/receive_today)。上面两个索引把
|
||||||
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
|
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
|
||||||
@@ -441,6 +530,25 @@ class Message(Base):
|
|||||||
"owner_account_id",
|
"owner_account_id",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
),
|
),
|
||||||
|
# C1 敏感词增量扫描水位:MAX(fetched_at) / fetched_at > 水位 定位新入库
|
||||||
|
# 消息(按消息 timestamp 做水位会永久漏扫增量拉回的历史消息,spec §3.6)。
|
||||||
|
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
|
||||||
|
Index(
|
||||||
|
"ix_message_fetched_at",
|
||||||
|
"fetched_at",
|
||||||
|
),
|
||||||
|
# 该回未回决赛圈点查(owner+talker 定位末条消息)在 channel=None
|
||||||
|
# (全平台计数——管理驾驶舱 action_counts 复用)时无渠道前缀条件,
|
||||||
|
# 上面全部以 channel 打头的索引用不上,逐候选退化扫表+filesort:
|
||||||
|
# 2026-09-10 服务器实测 dashboard 34~37s 撞前端 15s 超时
|
||||||
|
# (ECONNABORTED);补无渠道前缀索引后 1.3~1.8s(100 万行在线建
|
||||||
|
# 索引 49s)。「模型+迁移双声明」防 alembic autogenerate 反 DROP。
|
||||||
|
Index(
|
||||||
|
"ix_message_owner_talker_time",
|
||||||
|
"owner_account_id",
|
||||||
|
"talker_id",
|
||||||
|
"timestamp",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -473,6 +581,9 @@ class Media(Base):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_media_type_channel", "media_type", "channel"),
|
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 +635,8 @@ class PhoneCall(Base):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_phone_call_user_time", "user_id", "start_call_time"),
|
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 +755,9 @@ class MomentsPost(Base):
|
|||||||
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
fetched_at: Mapped[dt.datetime] = _fetched_at()
|
||||||
updated_at: Mapped[dt.datetime] = _updated_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)
|
# 5. Operational / bookkeeping tables (local; no raw_json/version)
|
||||||
@@ -740,17 +856,446 @@ class SyncRun(Base):
|
|||||||
__table_args__ = (Index("ix_sync_run_entity_status", "entity_type", "status"),)
|
__table_args__ = (Index("ix_sync_run_entity_status", "entity_type", "status"),)
|
||||||
|
|
||||||
|
|
||||||
|
class MsgDailyStats(Base):
|
||||||
|
"""日粒度会话统计:每个「渠道×账号×会话×自然日」一行(2026-09-03)。
|
||||||
|
|
||||||
|
sessions(staff-chat 会话列表)与 staff-chat-stats(5 数字弹窗)的预聚
|
||||||
|
合层——这两端点原先每次请求对 owner 全量 message 做 GROUP BY(重账号
|
||||||
|
5.2 万条实测 3~7s,128MB buffer pool 下更被推过前端 15s 超时)。日行由
|
||||||
|
写路径维护:``repos.upsert_messages`` 落库后自动重算本批触及的
|
||||||
|
「会话×天」;夜间 ``recompute_all`` 全量重建自愈(裸 UPDATE/回填脚本
|
||||||
|
等绕过钩子的写入由它兜底)。
|
||||||
|
|
||||||
|
口径:
|
||||||
|
- ``day`` 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期函数——
|
||||||
|
from_unixtime 跟随 MySQL 会话时区,UTC 容器下与今日数口径割裂)。
|
||||||
|
- ``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"
|
||||||
|
|
||||||
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||||
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
# friend = 1:1 私聊(peer=talker_id) / room = 群(peer=room_id)
|
||||||
|
peer_kind: Mapped[str] = mapped_column(String(8), primary_key=True)
|
||||||
|
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
# yyyymmdd,如 20260903(上海自然日)
|
||||||
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
send_cnt: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
recv_cnt: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
# 当日最后一条消息(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()
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 经营管理后台(2026-09-08 v2 spec,批次A)——fact/宽表/通话小表 + 三张业务表
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class FirstReplyFact(Base):
|
||||||
|
"""首响事实表(管理域)——一行 = 一个被回的 1:1 客户消息串(v2 §3.1)。
|
||||||
|
|
||||||
|
跨日精确串口径:按会话**全历史**计算,fr_ms = 员工首条回复 ts − 串末条
|
||||||
|
客户消息 ts,day = 串末条所在上海自然日。区别于 msg_daily_stats 首响
|
||||||
|
三列的日切近似(跨夜串整条丢失,v2 §1.1 #1)。只写 1:1(friend)会话
|
||||||
|
——群串语义不成立。由回填 CLI / 4h 滚动 / 夜间全量重算,**不挂写钩子**
|
||||||
|
(写钩子零改动)。用途:精确 P50/P90、2h/24h 回复率分子、下钻证据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "first_reply_fact"
|
||||||
|
|
||||||
|
channel: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||||
|
owner_account_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
peer_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
thread_end_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
reply_ts: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
# 回复消息 msg_id:同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列
|
||||||
|
# 去重(一条回复恰好闭合一个串);兼作下钻证据定位回复消息
|
||||||
|
reply_msg_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
# 串末条所在上海自然日 yyyymmdd(冗余物化,窗口/趋势索引)
|
||||||
|
day: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
fr_ms: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_fr_fact_day_channel", "day", "channel"),)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeMetricDaily(Base):
|
||||||
|
"""员工×日管理指标宽表(v2 §3.2)——1:1 口径,管理域专用。
|
||||||
|
|
||||||
|
列来源(v2 §1.1 #2/#5):消息计数/负载列自 msg_daily_stats
|
||||||
|
(peer_kind='friend' 过滤 + account_map 归并);串类三列与首响日值由
|
||||||
|
first_reply_facts.compute_conversation 同源输出(串末条归属日)——
|
||||||
|
回复率分子分母同口径。metric_version 只影响派生列(生命周期),
|
||||||
|
默认 'v1'。account_map 映射不上或不在 staff 的 owner(幽灵员工)
|
||||||
|
过滤不落表(v2 §1.3 #11)。通话在 employee_call_daily(无渠道维)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "employee_metric_daily"
|
||||||
|
|
||||||
|
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)
|
||||||
|
metric_version: Mapped[str] = mapped_column(
|
||||||
|
String(16), primary_key=True, default="v1", server_default="v1"
|
||||||
|
)
|
||||||
|
# 消息计数列(1:1 口径)
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
# 负载列(active_rooms 为群负载,唯一计入宽表的群维度)
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
# 串类列(与 first_reply_fact 同源,按串末条归属日)
|
||||||
|
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(
|
||||||
|
BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
# 首响日值(趋势图用;评分用周期值从 fact 表窗口函数精确计算)
|
||||||
|
first_reply_p50_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
first_reply_p90_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
# 生命周期 4 列(距今天数口径,当日持有客户状态分布,当前归属)
|
||||||
|
new_customer_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
cooling_customer_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
dormant_customer_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
reactivated_customer_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
# 预留完整性标记:宽表生成当前恒写 True(串类列与 fact 同一算法直算,
|
||||||
|
# 不读 fact 表)——真实语义(如校验 fact 覆盖/同步完整)由批次B定义
|
||||||
|
data_complete: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
computed_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeCallDaily(Base):
|
||||||
|
"""员工×日通话小表(v2 §1.2 #9)——通话无消息渠道维度,独立于宽表。
|
||||||
|
|
||||||
|
phone_call.channel 是通话类型(实测值 voice_call/wa_voice_call,
|
||||||
|
无蜂窝 phone_call 行)非消息渠道,塞进 (day,channel,user) 宽表会在
|
||||||
|
channel=all 聚合时双计。接通 =
|
||||||
|
call_duration > 0;followup = 通话后 24h 内该员工任一账号对该客户
|
||||||
|
有消息(Task 0 已核实:1:1 通话可匹配率 100%,@chatroom 群通话除外)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "employee_call_daily"
|
||||||
|
|
||||||
|
day: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
call_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
call_seconds: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
answered_call_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
call_followup_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default=sa.text("0")
|
||||||
|
)
|
||||||
|
computed_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeScoreConfig(Base):
|
||||||
|
"""评分配置版本(v1 §10.4/v2 §3.3)——config_json 存维度/指标/权重/
|
||||||
|
方向/阈值/样本门槛;激活前须过权重总和与阈值校验(批次B scoring 实现)。
|
||||||
|
MVP 无可视化编辑器,受控 CLI 种子维护。
|
||||||
|
JSON 列(config_json 等)修改须整列重赋值,原地修改不会提交。"""
|
||||||
|
|
||||||
|
__tablename__ = "employee_score_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
version: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# draft | active | retired
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="draft", server_default="draft"
|
||||||
|
)
|
||||||
|
effective_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
effective_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
approved_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||||
|
)
|
||||||
|
activated_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeScoreSnapshot(Base):
|
||||||
|
"""周/月考核快照(v1 §8/v2 §3.3)——不可变:冻结后业务层禁改评分字段;
|
||||||
|
重算走 supersedes_id 替代链。同批次×员工唯一。"""
|
||||||
|
|
||||||
|
__tablename__ = "employee_score_snapshot"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
snapshot_batch_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# week | month | custom
|
||||||
|
period_type: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
period_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
period_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
score_config_version: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
metric_version: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
total_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
grade: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
# scored | insufficient_sample | incomplete_data
|
||||||
|
score_status: Mapped[str] = mapped_column(
|
||||||
|
String(24), nullable=False, default="scored", server_default="scored"
|
||||||
|
)
|
||||||
|
dimension_scores_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metric_details_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
data_as_of: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
generated_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||||
|
)
|
||||||
|
frozen_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
||||||
|
frozen_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
supersedes_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
sa.UniqueConstraint("snapshot_batch_id", "user_id",
|
||||||
|
name="uq_score_snapshot_batch_user"),
|
||||||
|
Index("ix_score_snapshot_period", "period_type", "period_start"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementFollowup(Base):
|
||||||
|
"""管理辅导闭环(v1 §7.3/v2 §3.3)——来源指标→问题→管理动作→结果。"""
|
||||||
|
|
||||||
|
__tablename__ = "management_followup"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
employee_user_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# metric | anomaly | review | manual
|
||||||
|
source_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
source_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
source_period_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
source_period_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
source_value_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
||||||
|
action_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
owner_manager_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# open | in_progress | resolved | cancelled
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default="open", server_default="open"
|
||||||
|
)
|
||||||
|
due_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
||||||
|
result: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
||||||
|
employee_feedback: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[dt.datetime] = _updated_at()
|
||||||
|
closed_at: Mapped[dt.datetime | None] = mapped_column(sa.DateTime, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_followup_emp_status_due", "employee_user_id", "status", "due_at"),
|
||||||
|
Index("ix_followup_owner_status", "owner_manager_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ChatGroup",
|
"ChatGroup",
|
||||||
"Customer",
|
"Customer",
|
||||||
"Department",
|
"Department",
|
||||||
|
"EmployeeCallDaily",
|
||||||
|
"EmployeeMetricDaily",
|
||||||
|
"EmployeeScoreConfig",
|
||||||
|
"EmployeeScoreSnapshot",
|
||||||
|
"FirstReplyFact",
|
||||||
"GroupMember",
|
"GroupMember",
|
||||||
|
"ManagementFollowup",
|
||||||
"Media",
|
"Media",
|
||||||
"Message",
|
"Message",
|
||||||
"MomentsPost",
|
"MomentsPost",
|
||||||
|
"MsgDailyStats",
|
||||||
"PhoneCall",
|
"PhoneCall",
|
||||||
|
"SensitiveHit",
|
||||||
|
"SensitiveScanState",
|
||||||
|
"SensitiveWord",
|
||||||
"Sms",
|
"Sms",
|
||||||
"Staff",
|
"Staff",
|
||||||
|
"StatDailyStaff",
|
||||||
"SyncRun",
|
"SyncRun",
|
||||||
"SyncState",
|
"SyncState",
|
||||||
"VoiceClip",
|
"VoiceClip",
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""管理域确定性异常规则(批次B Task B3,v1 §5.4 异常卡片)。
|
||||||
|
|
||||||
|
4 条规则,输入=当前+个人基线 PeriodMetrics,输出异常卡片 dict 列表。
|
||||||
|
全部确定性——不使用 LLM 推断,reason/title/suggested_action 均为固定
|
||||||
|
中文模板填数,reason 只描述数据事实(百分比/样本数/未回串数)。
|
||||||
|
|
||||||
|
规则表(触发比较均为严格 > / <):
|
||||||
|
- reply_p90_worsened : cur.fr_p90_ms > base×1.5 且 cur.cust_threads≥
|
||||||
|
soft_threads;>×2.5 升 critical
|
||||||
|
- reply_rate_dropped : derive(reply_2h_rate) cur < base 同式×0.7 且
|
||||||
|
cur.cust_threads≥soft_threads(率由 scoring.
|
||||||
|
derive_metrics 派生,与评分同一口径)
|
||||||
|
- activity_drop : cur 日均 (msg_sent+msg_recv)/n_days < base 日均
|
||||||
|
(…/28)×0.5 且 base.days_with_data≥min_days
|
||||||
|
(基线太稀疏不可比;基线窗口恒 28 天=baseline_window
|
||||||
|
语义,当前窗口天数由 n_days 传入)
|
||||||
|
- workload_overload : cur.active_friends(周期 distinct) > workload 维
|
||||||
|
active_friends_band.high(与评分 band 同源阈值)
|
||||||
|
|
||||||
|
无基线不判:baseline=None 直接返回 [](新员工无个人对比位即不出卡,
|
||||||
|
含与基线无关的 overload——新入职负载爬坡期误报多,不判);某侧值
|
||||||
|
None/不可算(无串样本的率、无 fact 的分位、0ms 退化基线)→ 对应规则
|
||||||
|
不触发。
|
||||||
|
|
||||||
|
纯函数(无 session/IO):B4 dashboard/employees 对全员逐个调用。
|
||||||
|
阈值不硬编码:soft_threads/min_days 取 config.sample_guards,过载上限取
|
||||||
|
config.dimensions.workload.metrics.active_friends_band.high;乘数
|
||||||
|
(1.5/2.5/0.7/0.5)是规则语义本身,定义为模块常量而非可调参数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.management.employee_metrics import PeriodMetrics
|
||||||
|
from app.management.scoring import derive_metrics
|
||||||
|
|
||||||
|
# 规则乘数(语义常量,非可调参数——调整即换规则版本)
|
||||||
|
P90_WARN_MULTIPLIER = 1.5
|
||||||
|
P90_CRITICAL_MULTIPLIER = 2.5
|
||||||
|
RATE_DROP_MULTIPLIER = 0.7
|
||||||
|
ACTIVITY_DROP_MULTIPLIER = 0.5
|
||||||
|
BASELINE_WINDOW_DAYS = 28 # 个人基线窗口恒 28 天(employee_metrics.baseline_window)
|
||||||
|
|
||||||
|
# ---- 固定文案模板(.format 填数,reason 只含数据事实) ----
|
||||||
|
TITLE_P90 = "回复 P90 较个人基线上升 {pct}%"
|
||||||
|
TITLE_RATE = "2h 回复率较个人基线下降 {pct}%"
|
||||||
|
TITLE_ACTIVITY = "日均消息量较个人基线下降 {pct}%"
|
||||||
|
TITLE_OVERLOAD = "活跃客户 {current} 人超过负载上限 {limit} 人"
|
||||||
|
|
||||||
|
REASON_P90 = (
|
||||||
|
"近 {n_days} 天首响 P90 为 {cur}(基线前 28 天为 {base}),上升 {pct}%;"
|
||||||
|
"期内 {threads} 串客户消息中 {unreplied} 串未回"
|
||||||
|
)
|
||||||
|
REASON_RATE = (
|
||||||
|
"近 {n_days} 天 2h 回复率为 {cur:.0%}({cur_cnt}/{cur_thr} 串),"
|
||||||
|
"基线(前 28 天)为 {base:.0%}({base_cnt}/{base_thr} 串);"
|
||||||
|
"期内 {unreplied} 串未回"
|
||||||
|
)
|
||||||
|
REASON_ACTIVITY = (
|
||||||
|
"近 {n_days} 天日均消息 {cur:.1f} 条/天,基线(前 28 天)日均 {base:.1f} 条/天,"
|
||||||
|
"下降 {pct}%;当前活跃客户 {friends} 人"
|
||||||
|
)
|
||||||
|
REASON_OVERLOAD = (
|
||||||
|
"近 {n_days} 天活跃客户 {current} 人(周期 distinct 口径),"
|
||||||
|
"超过配置上限 {limit} 人"
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTION_P90 = "查看超时未回客户明细,与员工确认近期负载与接待安排"
|
||||||
|
ACTION_RATE = "查看未回串明细,排查回复流程与时段覆盖问题"
|
||||||
|
ACTION_ACTIVITY = "与员工确认在岗状态与客户维护情况,并核对账号数据接入是否正常"
|
||||||
|
ACTION_OVERLOAD = "评估客户分配与负载均衡,考虑分流部分客户或安排协助"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ms(ms: float) -> str:
|
||||||
|
# 与 scoring._fmt_ms 同式(模块私有不跨文件引用)
|
||||||
|
minutes = ms / 60_000
|
||||||
|
if minutes >= 60 and minutes % 60 == 0:
|
||||||
|
return f"{minutes // 60:g}h"
|
||||||
|
return f"{minutes:g}min"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_up(cur: float, base: float) -> int:
|
||||||
|
"""较基线上升百分比(四舍五入取整;触发后调用,base>0 已保证)。"""
|
||||||
|
return int(round((cur - base) / base * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_down(cur: float, base: float) -> int:
|
||||||
|
"""较基线下降百分比(触发后调用,base>0 已由触发条件保证)。"""
|
||||||
|
return int(round((base - cur) / base * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_anomalies(
|
||||||
|
user_id: str,
|
||||||
|
current: PeriodMetrics,
|
||||||
|
baseline: PeriodMetrics | None,
|
||||||
|
*,
|
||||||
|
n_days: int,
|
||||||
|
config: dict,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""单员工确定性异常检测(纯函数,卡片结构见 v1 §5.4/模块 docstring)。
|
||||||
|
|
||||||
|
输出按规则表序:p90 恶化 → 回复率骤降 → 活动骤降 → 过载
|
||||||
|
(排序/聚合由 B4 调用方按 severity/affected_count 处理)。
|
||||||
|
"""
|
||||||
|
if baseline is None:
|
||||||
|
return [] # 无基线不判(新员工)
|
||||||
|
guards = config.get("sample_guards", {})
|
||||||
|
soft = guards.get("soft_threads", 10)
|
||||||
|
min_days = guards.get("min_days", 5)
|
||||||
|
band_high = (
|
||||||
|
config.get("dimensions", {})
|
||||||
|
.get("workload", {})
|
||||||
|
.get("metrics", {})
|
||||||
|
.get("active_friends_band", {})
|
||||||
|
.get("high")
|
||||||
|
)
|
||||||
|
cards: list[dict] = []
|
||||||
|
unreplied = current.cust_threads - current.threads_answered
|
||||||
|
threads_ok = current.cust_threads >= soft
|
||||||
|
|
||||||
|
# ---- 1. reply_p90_worsened:P90 较个人基线恶化 ×1.5/×2.5 ----
|
||||||
|
if (
|
||||||
|
threads_ok
|
||||||
|
and current.fr_p90_ms is not None
|
||||||
|
and baseline.fr_p90_ms is not None
|
||||||
|
and baseline.fr_p90_ms > 0 # 0ms 基线为退化数据,不可比
|
||||||
|
):
|
||||||
|
base_p90 = baseline.fr_p90_ms
|
||||||
|
if current.fr_p90_ms > base_p90 * P90_CRITICAL_MULTIPLIER:
|
||||||
|
severity = "critical"
|
||||||
|
elif current.fr_p90_ms > base_p90 * P90_WARN_MULTIPLIER:
|
||||||
|
severity = "warning"
|
||||||
|
else:
|
||||||
|
severity = None
|
||||||
|
if severity is not None:
|
||||||
|
pct = _pct_up(current.fr_p90_ms, base_p90)
|
||||||
|
cards.append({
|
||||||
|
"rule_code": "reply_p90_worsened",
|
||||||
|
"severity": severity,
|
||||||
|
"employee_user_id": user_id,
|
||||||
|
"title": TITLE_P90.format(pct=pct),
|
||||||
|
"current_value": current.fr_p90_ms,
|
||||||
|
"baseline_value": base_p90,
|
||||||
|
"unit": "ms",
|
||||||
|
"sample_size": current.cust_threads,
|
||||||
|
"affected_count": unreplied,
|
||||||
|
"reason": REASON_P90.format(
|
||||||
|
n_days=n_days, cur=_fmt_ms(current.fr_p90_ms),
|
||||||
|
base=_fmt_ms(base_p90), pct=pct,
|
||||||
|
threads=current.cust_threads, unreplied=unreplied,
|
||||||
|
),
|
||||||
|
"drilldown": {"type": "metric", "query": {
|
||||||
|
"metric_code": "first_reply_p90", "user_id": user_id}},
|
||||||
|
"suggested_action": ACTION_P90,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- 2. reply_rate_dropped:2h 回复率较基线降 30%+ ----
|
||||||
|
if threads_ok:
|
||||||
|
cur_rate = derive_metrics(current)["reply_2h_rate"]
|
||||||
|
base_rate = derive_metrics(baseline)["reply_2h_rate"]
|
||||||
|
if (
|
||||||
|
cur_rate is not None and base_rate is not None
|
||||||
|
and cur_rate < base_rate * RATE_DROP_MULTIPLIER
|
||||||
|
):
|
||||||
|
cards.append({
|
||||||
|
"rule_code": "reply_rate_dropped",
|
||||||
|
"severity": "warning",
|
||||||
|
"employee_user_id": user_id,
|
||||||
|
"title": TITLE_RATE.format(
|
||||||
|
pct=_pct_down(cur_rate, base_rate)),
|
||||||
|
"current_value": cur_rate,
|
||||||
|
"baseline_value": base_rate,
|
||||||
|
"unit": "0-1",
|
||||||
|
"sample_size": current.cust_threads,
|
||||||
|
"affected_count": unreplied,
|
||||||
|
"reason": REASON_RATE.format(
|
||||||
|
n_days=n_days, cur=cur_rate, cur_cnt=current.reply_2h_count,
|
||||||
|
cur_thr=current.cust_threads, base=base_rate,
|
||||||
|
base_cnt=baseline.reply_2h_count,
|
||||||
|
base_thr=baseline.cust_threads, unreplied=unreplied,
|
||||||
|
),
|
||||||
|
"drilldown": {"type": "metric", "query": {
|
||||||
|
"metric_code": "unreplied", "user_id": user_id}},
|
||||||
|
"suggested_action": ACTION_RATE,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- 3. activity_drop:日均消息量较基线腰斩 ----
|
||||||
|
if n_days > 0 and baseline.days_with_data >= min_days:
|
||||||
|
cur_daily = (current.msg_sent + current.msg_recv) / n_days
|
||||||
|
base_daily = (
|
||||||
|
baseline.msg_sent + baseline.msg_recv
|
||||||
|
) / BASELINE_WINDOW_DAYS
|
||||||
|
if cur_daily < base_daily * ACTIVITY_DROP_MULTIPLIER:
|
||||||
|
cards.append({
|
||||||
|
"rule_code": "activity_drop",
|
||||||
|
"severity": "warning",
|
||||||
|
"employee_user_id": user_id,
|
||||||
|
"title": TITLE_ACTIVITY.format(
|
||||||
|
pct=_pct_down(cur_daily, base_daily)),
|
||||||
|
"current_value": cur_daily,
|
||||||
|
"baseline_value": base_daily,
|
||||||
|
"unit": "条",
|
||||||
|
"sample_size": current.active_friends,
|
||||||
|
"affected_count": current.active_friends,
|
||||||
|
"reason": REASON_ACTIVITY.format(
|
||||||
|
n_days=n_days, cur=cur_daily, base=base_daily,
|
||||||
|
pct=_pct_down(cur_daily, base_daily),
|
||||||
|
friends=current.active_friends,
|
||||||
|
),
|
||||||
|
"drilldown": {"type": "employee",
|
||||||
|
"query": {"user_id": user_id}},
|
||||||
|
"suggested_action": ACTION_ACTIVITY,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- 4. workload_overload:活跃客户超 band 上限 ----
|
||||||
|
if band_high is not None and current.active_friends > band_high:
|
||||||
|
cards.append({
|
||||||
|
"rule_code": "workload_overload",
|
||||||
|
"severity": "warning",
|
||||||
|
"employee_user_id": user_id,
|
||||||
|
"title": TITLE_OVERLOAD.format(
|
||||||
|
current=current.active_friends, limit=band_high),
|
||||||
|
"current_value": current.active_friends,
|
||||||
|
"baseline_value": None, # 与个人基线无关,阈值在 band 配置
|
||||||
|
"unit": "人",
|
||||||
|
"sample_size": current.active_friends,
|
||||||
|
"affected_count": current.active_friends,
|
||||||
|
"reason": REASON_OVERLOAD.format(
|
||||||
|
n_days=n_days, current=current.active_friends,
|
||||||
|
limit=band_high,
|
||||||
|
),
|
||||||
|
"drilldown": {"type": "employee", "query": {"user_id": user_id}},
|
||||||
|
"suggested_action": ACTION_OVERLOAD,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cards
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
|
||||||
|
|
||||||
|
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
|
||||||
|
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
|
||||||
|
校验/HTTP 语义留在 router。
|
||||||
|
|
||||||
|
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
|
||||||
|
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
|
||||||
|
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import statistics
|
||||||
|
from bisect import bisect_right
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.analytics.lifecycle import classify
|
||||||
|
from app.db.models import (
|
||||||
|
Customer,
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
EmployeeScoreConfig,
|
||||||
|
FirstReplyFact,
|
||||||
|
MsgDailyStats,
|
||||||
|
PhoneCall,
|
||||||
|
Staff,
|
||||||
|
)
|
||||||
|
from app.management.anomalies import detect_anomalies
|
||||||
|
from app.management.employee_metrics import (
|
||||||
|
PeriodMetrics,
|
||||||
|
baseline_window,
|
||||||
|
day_range,
|
||||||
|
percentile,
|
||||||
|
period_metrics,
|
||||||
|
prev_window,
|
||||||
|
team_medians,
|
||||||
|
)
|
||||||
|
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||||
|
from app.management.scoring import derive_metrics, score_employee
|
||||||
|
from app.storage.employee_metrics import METRIC_VERSION
|
||||||
|
from app.storage.staff_summary import account_map
|
||||||
|
from app.storage.summary import day_bounds_ms
|
||||||
|
|
||||||
|
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
|
||||||
|
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
|
||||||
|
|
||||||
|
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
|
||||||
|
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
|
||||||
|
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
|
||||||
|
|
||||||
|
|
||||||
|
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
|
||||||
|
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
|
||||||
|
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
|
||||||
|
row = (await session.execute(
|
||||||
|
sa.select(EmployeeScoreConfig)
|
||||||
|
.where(EmployeeScoreConfig.status == "active")
|
||||||
|
.order_by(EmployeeScoreConfig.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
return row.config_json, "db"
|
||||||
|
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
|
||||||
|
|
||||||
|
|
||||||
|
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||||
|
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
|
||||||
|
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
|
||||||
|
team = dict(team_medians(metrics))
|
||||||
|
for key in _DERIVED_MEDIAN_KEYS:
|
||||||
|
vals = [
|
||||||
|
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
|
||||||
|
if v is not None
|
||||||
|
]
|
||||||
|
team[key] = statistics.median(vals) if vals else None
|
||||||
|
return team
|
||||||
|
|
||||||
|
|
||||||
|
def score_with_guards(
|
||||||
|
config: dict,
|
||||||
|
cur: PeriodMetrics,
|
||||||
|
baseline: PeriodMetrics | None = None,
|
||||||
|
team: dict[str, float] | None = None,
|
||||||
|
prev: PeriodMetrics | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
|
||||||
|
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
|
||||||
|
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
|
||||||
|
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
|
||||||
|
min_days = config.get("sample_guards", {}).get("min_days", 5)
|
||||||
|
if cur.days_with_data < min_days:
|
||||||
|
score["score_status"] = "insufficient_sample"
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
async def assemble_employees(
|
||||||
|
session: AsyncSession,
|
||||||
|
config: dict,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
channel: str | None,
|
||||||
|
*,
|
||||||
|
with_prev: bool = True,
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
|
||||||
|
score_with_guards + detect_anomalies(纯函数循环)。
|
||||||
|
|
||||||
|
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
|
||||||
|
employees/detail 需要 prev/团队中位时传 True。返回
|
||||||
|
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
|
||||||
|
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
|
||||||
|
"""
|
||||||
|
staff_rows = (await session.execute(
|
||||||
|
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
|
||||||
|
)).all()
|
||||||
|
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
|
||||||
|
if not staff:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
n_days = len(day_range(start_day, end_day))
|
||||||
|
cur = await period_metrics(session, start_day, end_day, channel)
|
||||||
|
bs, be = baseline_window(start_day)
|
||||||
|
base = await period_metrics(session, bs, be, channel)
|
||||||
|
prev: dict[str, PeriodMetrics] | None = None
|
||||||
|
team: dict[str, float | None] | None = None
|
||||||
|
if with_prev:
|
||||||
|
ps, pe = prev_window(start_day, end_day)
|
||||||
|
prev = await period_metrics(session, ps, pe, channel)
|
||||||
|
team = team_derived_medians(cur)
|
||||||
|
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for uid, (name, onjob) in staff.items():
|
||||||
|
c = cur.get(uid)
|
||||||
|
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
|
||||||
|
continue
|
||||||
|
b = base.get(uid)
|
||||||
|
p = prev.get(uid) if prev is not None else None
|
||||||
|
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
|
||||||
|
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
|
||||||
|
out[uid] = {
|
||||||
|
"name": name,
|
||||||
|
"is_on_job": onjob,
|
||||||
|
"cur": c,
|
||||||
|
"prev": p,
|
||||||
|
"baseline": b,
|
||||||
|
"score": score,
|
||||||
|
"anomalies": anomalies,
|
||||||
|
"anomaly_count": len(anomalies),
|
||||||
|
"anomaly_weight": sum(
|
||||||
|
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _code_to_source(config: dict) -> dict[str, str]:
|
||||||
|
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
|
||||||
|
|
||||||
|
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
|
||||||
|
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
|
||||||
|
其余 code 本身即源名。"""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for dim in config.get("dimensions", {}).values():
|
||||||
|
for code, spec in dim.get("metrics", {}).items():
|
||||||
|
out[code] = spec.get("metric", code)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
|
||||||
|
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
|
||||||
|
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
|
||||||
|
score = data["score"]
|
||||||
|
code_src = _code_to_source(config)
|
||||||
|
return {
|
||||||
|
"user_id": uid,
|
||||||
|
"name": data["name"],
|
||||||
|
"is_on_job": data["is_on_job"],
|
||||||
|
"metrics": data["cur"]._asdict(),
|
||||||
|
"comparisons": {
|
||||||
|
code_src.get(code, code): {
|
||||||
|
"current": d["value"],
|
||||||
|
"prev": d["prev"],
|
||||||
|
"baseline": d["baseline"],
|
||||||
|
"median": d["median"],
|
||||||
|
}
|
||||||
|
for code, d in score["metrics_detail"].items()
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"total_score": score["total_score"],
|
||||||
|
"grade": score["grade"],
|
||||||
|
"score_status": score["score_status"],
|
||||||
|
"trial": score["trial"],
|
||||||
|
"dimensions": {
|
||||||
|
key: {"score": dim["score"], "status": dim["status"]}
|
||||||
|
for key, dim in score["dimensions"].items()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"anomaly_count": data["anomaly_count"],
|
||||||
|
"anomaly_weight": data["anomaly_weight"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def derived_comparisons(
|
||||||
|
cur: PeriodMetrics,
|
||||||
|
prev: PeriodMetrics | None,
|
||||||
|
baseline: PeriodMetrics | None,
|
||||||
|
team: dict[str, float | None] | None,
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
|
||||||
|
——评分、异常、展示三方同一派生式。"""
|
||||||
|
cv = derive_metrics(cur)
|
||||||
|
pv = derive_metrics(prev) if prev is not None else {}
|
||||||
|
bv = derive_metrics(baseline) if baseline is not None else {}
|
||||||
|
return {
|
||||||
|
key: {
|
||||||
|
"current": cv.get(key),
|
||||||
|
"prev": pv.get(key),
|
||||||
|
"baseline": bv.get(key),
|
||||||
|
"median": team.get(key) if team else None,
|
||||||
|
}
|
||||||
|
for key in cv
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def wide_daily_rows(
|
||||||
|
session: AsyncSession,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
channel: str | None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
|
||||||
|
|
||||||
|
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
|
||||||
|
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
|
||||||
|
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
|
||||||
|
service_daily_trend 从 fact 精确算)。
|
||||||
|
"""
|
||||||
|
conds = [
|
||||||
|
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||||
|
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||||
|
]
|
||||||
|
if channel is not None:
|
||||||
|
conds.append(EmployeeMetricDaily.channel == channel)
|
||||||
|
if user_id is not None:
|
||||||
|
conds.append(EmployeeMetricDaily.user_id == user_id)
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeMetricDaily.day.label("day"),
|
||||||
|
EmployeeMetricDaily.channel.label("ch"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.cooling_customer_count).label("cool"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.dormant_customer_count).label("dorm"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.reactivated_customer_count).label("react"),
|
||||||
|
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
|
||||||
|
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
|
||||||
|
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
per_day: dict[int, dict] = {}
|
||||||
|
|
||||||
|
def _blank(day: int) -> dict:
|
||||||
|
return {
|
||||||
|
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
|
||||||
|
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
|
||||||
|
"fr_sum_ms": 0, "new_customer_count": 0,
|
||||||
|
"cooling_customer_count": 0, "dormant_customer_count": 0,
|
||||||
|
"reactivated_customer_count": 0,
|
||||||
|
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
|
||||||
|
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
|
||||||
|
react, p50, p90) in rows:
|
||||||
|
d = per_day.setdefault(day, _blank(day))
|
||||||
|
d["msg_sent"] += int(sent or 0)
|
||||||
|
d["msg_recv"] += int(recv or 0)
|
||||||
|
d["active_friends"] += int(af or 0)
|
||||||
|
d["active_rooms"] += int(ar or 0)
|
||||||
|
d["cust_threads"] += int(thr or 0)
|
||||||
|
d["threads_answered"] += int(ans or 0)
|
||||||
|
d["fr_sum_ms"] += int(frs or 0)
|
||||||
|
d["new_customer_count"] += int(nw or 0)
|
||||||
|
d["cooling_customer_count"] += int(cool or 0)
|
||||||
|
d["dormant_customer_count"] += int(dorm or 0)
|
||||||
|
d["reactivated_customer_count"] += int(react or 0)
|
||||||
|
if user_id is not None:
|
||||||
|
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
|
||||||
|
if pick > d["_pick"]:
|
||||||
|
d["_pick"] = pick
|
||||||
|
d["first_reply_p50_ms"] = p50
|
||||||
|
d["first_reply_p90_ms"] = p90
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for day in sorted(per_day):
|
||||||
|
d = per_day[day]
|
||||||
|
d.pop("_pick", None)
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def call_daily_rows(
|
||||||
|
session: AsyncSession,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
|
||||||
|
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
|
||||||
|
if user_id is not None:
|
||||||
|
conds.append(EmployeeCallDaily.user_id == user_id)
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeCallDaily.day,
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_count),
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_seconds),
|
||||||
|
sa.func.sum(EmployeeCallDaily.answered_call_count),
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_followup_count),
|
||||||
|
).where(*conds).group_by(EmployeeCallDaily.day)
|
||||||
|
)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"day": day, "call_count": int(cc or 0),
|
||||||
|
"call_seconds": int(cs or 0),
|
||||||
|
"answered_call_count": int(ca or 0),
|
||||||
|
"call_followup_count": int(cf or 0),
|
||||||
|
}
|
||||||
|
for day, cc, cs, ca, cf in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def service_daily_trend(
|
||||||
|
session: AsyncSession,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
channel: str | None,
|
||||||
|
threads_by_day: dict[int, int],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
|
||||||
|
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
|
||||||
|
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
|
||||||
|
|
||||||
|
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
|
||||||
|
wide_daily_rows 的全员 cust_threads 列。
|
||||||
|
"""
|
||||||
|
conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||||
|
if channel is not None:
|
||||||
|
conds.append(FirstReplyFact.channel == channel)
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
|
||||||
|
)).all()
|
||||||
|
by_day: dict[int, list[int]] = {}
|
||||||
|
for day, fr in rows:
|
||||||
|
by_day.setdefault(day, []).append(int(fr))
|
||||||
|
out = []
|
||||||
|
for day in day_range(start_day, end_day):
|
||||||
|
frs = by_day.get(day, [])
|
||||||
|
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
|
||||||
|
denom = threads_by_day.get(day, 0)
|
||||||
|
out.append({
|
||||||
|
"day": day,
|
||||||
|
"fr_p50_ms": percentile(frs, 1, 2),
|
||||||
|
"fr_p90_ms": percentile(frs, 9, 10),
|
||||||
|
"reply_2h_rate": (cnt_2h / denom) if denom else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def user_owner_accounts(
|
||||||
|
session: AsyncSession, user_id: str, channel: str | None
|
||||||
|
) -> list[str]:
|
||||||
|
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
|
||||||
|
chs = ("wechat", "whatsapp") if channel is None else (channel,)
|
||||||
|
out: list[str] = []
|
||||||
|
for ch in chs:
|
||||||
|
amap = await account_map(session, ch)
|
||||||
|
out.extend(owner for owner, uid in amap.items() if uid == user_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def lifecycle_customers(
|
||||||
|
session: AsyncSession,
|
||||||
|
owner_accounts: list[str],
|
||||||
|
as_of_day: int,
|
||||||
|
want_state: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
|
||||||
|
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
|
||||||
|
返回状态匹配行 {friend_id, channel, nickname, last_day, state},按 last_day asc
|
||||||
|
(最陈旧在前)、friend_id asc 排序。
|
||||||
|
|
||||||
|
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
|
||||||
|
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
|
||||||
|
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
|
||||||
|
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
|
||||||
|
custs = (await session.execute(
|
||||||
|
sa.select(Customer).where(
|
||||||
|
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
|
||||||
|
Customer.owner_account_id.in_(owner_accounts),
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
if not custs:
|
||||||
|
return []
|
||||||
|
ids = [c.friend_id for c in custs]
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.channel,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
MsgDailyStats.day,
|
||||||
|
MsgDailyStats.send_cnt,
|
||||||
|
MsgDailyStats.recv_cnt,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.peer_kind == "friend",
|
||||||
|
MsgDailyStats.peer_id.in_(ids),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
hist: dict[tuple[str, str], dict[int, int]] = {}
|
||||||
|
for ch, peer, day, snd, rcv in rows:
|
||||||
|
dm = hist.setdefault((ch, peer), {})
|
||||||
|
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||||
|
|
||||||
|
matches: list[dict] = []
|
||||||
|
for c in custs:
|
||||||
|
dm = hist.get((c.channel, c.friend_id))
|
||||||
|
if not dm:
|
||||||
|
continue
|
||||||
|
days_list = sorted(dm)
|
||||||
|
idx = bisect_right(days_list, as_of_day) - 1
|
||||||
|
if idx < 0:
|
||||||
|
continue # 窗口末日之前从未互动
|
||||||
|
last = days_list[idx]
|
||||||
|
prev_d = days_list[idx - 1] if idx > 0 else None
|
||||||
|
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
|
||||||
|
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
|
||||||
|
if state == want_state:
|
||||||
|
matches.append({
|
||||||
|
"friend_id": c.friend_id,
|
||||||
|
"channel": c.channel,
|
||||||
|
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
|
||||||
|
"last_day": last,
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
async def fr_fact_rows(
|
||||||
|
session: AsyncSession,
|
||||||
|
owner_accounts: list[str],
|
||||||
|
channel: str | None,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[int, list[dict]]:
|
||||||
|
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
|
||||||
|
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
|
||||||
|
(owner+talker 直达聊天记录)。返回 (total, items)。"""
|
||||||
|
conds = [
|
||||||
|
FirstReplyFact.day.between(start_day, end_day),
|
||||||
|
FirstReplyFact.owner_account_id.in_(owner_accounts),
|
||||||
|
]
|
||||||
|
if channel is not None:
|
||||||
|
conds.append(FirstReplyFact.channel == channel)
|
||||||
|
total = (await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
|
||||||
|
)).scalar_one()
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(FirstReplyFact).where(*conds)
|
||||||
|
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
|
||||||
|
FirstReplyFact.thread_end_ts.desc())
|
||||||
|
.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
)).scalars().all()
|
||||||
|
items = [{
|
||||||
|
"day": f.day,
|
||||||
|
"channel": f.channel,
|
||||||
|
"owner_account_id": f.owner_account_id,
|
||||||
|
"peer_id": f.peer_id,
|
||||||
|
"thread_end_ts": f.thread_end_ts,
|
||||||
|
"reply_ts": f.reply_ts,
|
||||||
|
"fr_ms": f.fr_ms,
|
||||||
|
"chat_hint": {"owner_account_id": f.owner_account_id,
|
||||||
|
"talker_id": f.peer_id},
|
||||||
|
} for f in rows]
|
||||||
|
return int(total), items
|
||||||
|
|
||||||
|
|
||||||
|
async def user_calls_page(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[int, list[dict]]:
|
||||||
|
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
|
||||||
|
call_id desc 决胜)分页。返回 (total, items)。"""
|
||||||
|
lo, _ = day_bounds_ms(start_day)
|
||||||
|
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
|
||||||
|
conds = [
|
||||||
|
PhoneCall.user_id == user_id,
|
||||||
|
PhoneCall.start_call_time.is_not(None),
|
||||||
|
PhoneCall.start_call_time >= lo,
|
||||||
|
PhoneCall.start_call_time < hi,
|
||||||
|
]
|
||||||
|
total = (await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
|
||||||
|
)).scalar_one()
|
||||||
|
rows = (await session.execute(
|
||||||
|
sa.select(PhoneCall).where(*conds)
|
||||||
|
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
|
||||||
|
.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
)).scalars().all()
|
||||||
|
items = [{
|
||||||
|
"call_id": c.call_id,
|
||||||
|
"channel": c.channel,
|
||||||
|
"call_number": c.call_number,
|
||||||
|
"start_call_time": c.start_call_time,
|
||||||
|
"call_duration": c.call_duration,
|
||||||
|
} for c in rows]
|
||||||
|
return int(total), items
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""管理域期聚合查询(批次B)——评分/异常/employees API 的单一数据源。
|
||||||
|
|
||||||
|
窗口:days=[start_day..end_day] 闭区间 yyyymmdd;上期=等长紧邻前窗;
|
||||||
|
个人基线=紧邻当前窗之前的 28 天。所有查询走 (day, ...) 索引,不扫消息表
|
||||||
|
(除 distinct 活跃客户走 msg_daily_stats friend 行)。
|
||||||
|
|
||||||
|
口径要点:
|
||||||
|
- 分位/回复计数:first_reply_fact 窗内行拉回 Python 按员工合并排序取
|
||||||
|
最近邻秩 ceil(q·n)(整除算术 (n+1)//2 / (9n+9)//10——SQLite 无原生
|
||||||
|
ceil,MySQL `/` 返回浮点,Python 侧取秩零方言风险)。合并按员工而非
|
||||||
|
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
|
||||||
|
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
|
||||||
|
Python 侧聚合可行。
|
||||||
|
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
|
||||||
|
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
|
||||||
|
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
|
||||||
|
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
|
||||||
|
active_friends 列),供评分 active_share 用——区别于 active_friends
|
||||||
|
字段的周期 distinct 口径。
|
||||||
|
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
|
||||||
|
非有数据天数——「日均负载」语义。
|
||||||
|
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
|
||||||
|
(PK 含 day+channel,每日至多一行),channel=None 双渠道同日也只计 1 天。
|
||||||
|
- 通话四列恒全渠道(employee_call_daily 无渠道维),channel 参数不过滤。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import statistics
|
||||||
|
from collections import namedtuple
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
FirstReplyFact,
|
||||||
|
MsgDailyStats,
|
||||||
|
Staff,
|
||||||
|
)
|
||||||
|
from app.storage.employee_metrics import METRIC_VERSION
|
||||||
|
from app.storage.staff_summary import account_map
|
||||||
|
|
||||||
|
PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||||
|
"user_id", "days_with_data", "msg_sent", "msg_recv", "cust_threads",
|
||||||
|
"threads_answered", "fr_sum_ms", "fr_p50_ms", "fr_p90_ms",
|
||||||
|
"reply_2h_count", "reply_24h_count",
|
||||||
|
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||||
|
"active_rooms_avg", # 日均(全窗天数摊薄)
|
||||||
|
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||||
|
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
|
||||||
|
"reactivated_customer", # 期内求和
|
||||||
|
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_date(k: int) -> dt.date:
|
||||||
|
return dt.date(k // 10000, k // 100 % 100, k % 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_key(d: dt.date) -> int:
|
||||||
|
return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
|
||||||
|
|
||||||
|
def day_range(start: int, end: int) -> list[int]:
|
||||||
|
"""闭区间日键列表。"""
|
||||||
|
out, cur = [], _to_date(start)
|
||||||
|
while cur <= _to_date(end):
|
||||||
|
out.append(_to_key(cur))
|
||||||
|
cur += dt.timedelta(days=1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def prev_window(start: int, end: int) -> tuple[int, int]:
|
||||||
|
"""等长紧邻前窗。"""
|
||||||
|
n = len(day_range(start, end))
|
||||||
|
pe = _to_date(start) - dt.timedelta(days=1)
|
||||||
|
ps = pe - dt.timedelta(days=n - 1)
|
||||||
|
return _to_key(ps), _to_key(pe)
|
||||||
|
|
||||||
|
|
||||||
|
def baseline_window(start: int) -> tuple[int, int]:
|
||||||
|
"""个人基线:紧邻当前窗之前的 28 天。"""
|
||||||
|
be = _to_date(start) - dt.timedelta(days=1)
|
||||||
|
bs = be - dt.timedelta(days=27)
|
||||||
|
return _to_key(bs), _to_key(be)
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
|
||||||
|
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
|
||||||
|
|
||||||
|
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
|
||||||
|
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
|
||||||
|
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
|
||||||
|
后者为不同层单独保留);空列表→None(与 0 严格区分)。
|
||||||
|
"""
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
s = sorted(vals)
|
||||||
|
n = len(s)
|
||||||
|
rank = max(1, min(n, (q_num * n + q_den - 1) // q_den))
|
||||||
|
return s[rank - 1]
|
||||||
|
|
||||||
|
|
||||||
|
# 累加器初值:全员(staff)都出结果行,无数据员工为零值而非缺行——
|
||||||
|
# employees API 全员列表语义。
|
||||||
|
_ZERO: dict[str, object] = {
|
||||||
|
"days_with_data": 0, "msg_sent": 0, "msg_recv": 0, "cust_threads": 0,
|
||||||
|
"threads_answered": 0, "fr_sum_ms": 0,
|
||||||
|
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||||
|
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||||
|
"active_friends": 0, "active_rooms_sum": 0,
|
||||||
|
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||||
|
"end_active_friends": 0,
|
||||||
|
"reactivated_customer": 0,
|
||||||
|
"call_count": 0, "call_seconds": 0, "answered_call": 0, "call_followup": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def period_metrics(
|
||||||
|
session: AsyncSession,
|
||||||
|
start_day: int,
|
||||||
|
end_day: int,
|
||||||
|
channel: str | None,
|
||||||
|
user_ids: Iterable[str] | None = None,
|
||||||
|
) -> dict[str, PeriodMetrics]:
|
||||||
|
"""窗口 [start_day, end_day] × channel(None=全渠道)的员工期指标。
|
||||||
|
|
||||||
|
user_ids 传入时与 staff 表求交(幽灵过滤);返回含全部目标员工的
|
||||||
|
PeriodMetrics(无数据员工为零值行)。channel=None 时两渠道
|
||||||
|
account_map 合并(wx_id 与 WA uid 命名空间不撞,无冲突)。
|
||||||
|
"""
|
||||||
|
staff_ids = {
|
||||||
|
u for u in (await session.execute(sa.select(Staff.user_id))).scalars() if u
|
||||||
|
}
|
||||||
|
target = staff_ids if user_ids is None else staff_ids & set(user_ids)
|
||||||
|
if not target:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if channel is None:
|
||||||
|
amap: dict[str, str] = {}
|
||||||
|
for ch in ("wechat", "whatsapp"):
|
||||||
|
amap.update(await account_map(session, ch))
|
||||||
|
else:
|
||||||
|
amap = await account_map(session, channel)
|
||||||
|
|
||||||
|
n_days = len(day_range(start_day, end_day))
|
||||||
|
acc: dict[str, dict] = {uid: dict(_ZERO) for uid in target}
|
||||||
|
|
||||||
|
# ---- 宽表 SUM:消息计数/串类/日均负载/复活 ----
|
||||||
|
wide_conds = [
|
||||||
|
EmployeeMetricDaily.day.between(start_day, end_day),
|
||||||
|
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||||
|
EmployeeMetricDaily.user_id.in_(target),
|
||||||
|
]
|
||||||
|
if channel is not None:
|
||||||
|
wide_conds.append(EmployeeMetricDaily.channel == channel)
|
||||||
|
wide = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeMetricDaily.user_id.label("uid"),
|
||||||
|
sa.func.count(sa.distinct(EmployeeMetricDaily.day)).label("nd"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.active_rooms).label("rooms"),
|
||||||
|
sa.func.sum(
|
||||||
|
EmployeeMetricDaily.reactivated_customer_count
|
||||||
|
).label("react"),
|
||||||
|
).where(*wide_conds).group_by(EmployeeMetricDaily.user_id)
|
||||||
|
)).all()
|
||||||
|
for uid, nd, sent, recv, thr, ans, frs, rooms, react in wide:
|
||||||
|
a = acc[uid]
|
||||||
|
a["days_with_data"] = int(nd or 0)
|
||||||
|
a["msg_sent"] = int(sent or 0)
|
||||||
|
a["msg_recv"] = int(recv or 0)
|
||||||
|
a["cust_threads"] = int(thr or 0)
|
||||||
|
a["threads_answered"] = int(ans or 0)
|
||||||
|
a["fr_sum_ms"] = int(frs or 0)
|
||||||
|
a["active_rooms_sum"] = int(rooms or 0)
|
||||||
|
a["reactivated_customer"] = int(react or 0)
|
||||||
|
|
||||||
|
# ---- 期末存量:每 (user, channel) 最新 ≤end 行,跨渠道相加 ----
|
||||||
|
stock_conds = [
|
||||||
|
EmployeeMetricDaily.day <= end_day,
|
||||||
|
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||||
|
EmployeeMetricDaily.user_id.in_(target),
|
||||||
|
]
|
||||||
|
if channel is not None:
|
||||||
|
stock_conds.append(EmployeeMetricDaily.channel == channel)
|
||||||
|
latest = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeMetricDaily.user_id.label("uid"),
|
||||||
|
EmployeeMetricDaily.channel.label("ch"),
|
||||||
|
sa.func.max(EmployeeMetricDaily.day).label("d"),
|
||||||
|
).where(*stock_conds).group_by(
|
||||||
|
EmployeeMetricDaily.user_id, EmployeeMetricDaily.channel
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
if latest:
|
||||||
|
# (user, channel, day) 含 metric_version 唯一(主键)——OR 点查
|
||||||
|
# 精确取回各渠道最新行,再按 user 跨渠道 SUM
|
||||||
|
stocks = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeMetricDaily.user_id.label("uid"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
|
||||||
|
sa.func.sum(
|
||||||
|
EmployeeMetricDaily.cooling_customer_count
|
||||||
|
).label("cool"),
|
||||||
|
sa.func.sum(
|
||||||
|
EmployeeMetricDaily.dormant_customer_count
|
||||||
|
).label("dorm"),
|
||||||
|
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||||
|
).where(sa.or_(*(
|
||||||
|
sa.and_(
|
||||||
|
EmployeeMetricDaily.user_id == uid,
|
||||||
|
EmployeeMetricDaily.channel == ch,
|
||||||
|
EmployeeMetricDaily.day == d,
|
||||||
|
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||||
|
)
|
||||||
|
for uid, ch, d in latest
|
||||||
|
))).group_by(EmployeeMetricDaily.user_id)
|
||||||
|
)).all()
|
||||||
|
for uid, nw, cool, dorm, af in stocks:
|
||||||
|
a = acc[uid]
|
||||||
|
a["new_customer"] = int(nw or 0)
|
||||||
|
a["cooling_customer"] = int(cool or 0)
|
||||||
|
a["dormant_customer"] = int(dorm or 0)
|
||||||
|
a["end_active_friends"] = int(af or 0)
|
||||||
|
|
||||||
|
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
|
||||||
|
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||||
|
if channel is not None:
|
||||||
|
fr_conds.append(FirstReplyFact.channel == channel)
|
||||||
|
fr_rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
FirstReplyFact.owner_account_id.label("owner"),
|
||||||
|
FirstReplyFact.fr_ms.label("fr"),
|
||||||
|
).where(*fr_conds)
|
||||||
|
)).all()
|
||||||
|
fr_by_user: dict[str, list[int]] = {}
|
||||||
|
for owner, fr in fr_rows:
|
||||||
|
uid = amap.get(owner or "")
|
||||||
|
if uid is not None and uid in target:
|
||||||
|
fr_by_user.setdefault(uid, []).append(int(fr))
|
||||||
|
for uid, frs in fr_by_user.items():
|
||||||
|
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
|
||||||
|
acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
|
||||||
|
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
|
||||||
|
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
|
||||||
|
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
|
||||||
|
|
||||||
|
# ---- distinct 活跃客户:账号归并到员工后按 peer 去重 ----
|
||||||
|
af_conds = [
|
||||||
|
MsgDailyStats.day.between(start_day, end_day),
|
||||||
|
MsgDailyStats.peer_kind == "friend",
|
||||||
|
]
|
||||||
|
if channel is not None:
|
||||||
|
af_conds.append(MsgDailyStats.channel == channel)
|
||||||
|
pairs = (await session.execute(
|
||||||
|
sa.select(MsgDailyStats.owner_account_id, MsgDailyStats.peer_id)
|
||||||
|
.distinct().where(*af_conds)
|
||||||
|
)).all()
|
||||||
|
active: dict[str, set[str]] = {}
|
||||||
|
for owner, peer in pairs:
|
||||||
|
uid = amap.get(owner or "")
|
||||||
|
if uid is None or uid not in target:
|
||||||
|
continue
|
||||||
|
# (uid, peer) 粒度:同一员工多账号(wx1+wx2)的共享客户只计 1
|
||||||
|
# ——与宽表日值口径一致(storage 侧账号归并后 add(peer))
|
||||||
|
active.setdefault(uid, set()).add(peer or "")
|
||||||
|
for uid, pset in active.items():
|
||||||
|
acc[uid]["active_friends"] = len(pset)
|
||||||
|
|
||||||
|
# ---- 通话:恒全渠道(表无渠道维,channel 参数不过滤) ----
|
||||||
|
calls = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
EmployeeCallDaily.user_id.label("uid"),
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_count).label("cc"),
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_seconds).label("cs"),
|
||||||
|
sa.func.sum(EmployeeCallDaily.answered_call_count).label("ca"),
|
||||||
|
sa.func.sum(EmployeeCallDaily.call_followup_count).label("cf"),
|
||||||
|
).where(
|
||||||
|
EmployeeCallDaily.day.between(start_day, end_day),
|
||||||
|
EmployeeCallDaily.user_id.in_(target),
|
||||||
|
).group_by(EmployeeCallDaily.user_id)
|
||||||
|
)).all()
|
||||||
|
for uid, cc, cs, ca, cf in calls:
|
||||||
|
a = acc[uid]
|
||||||
|
a["call_count"] = int(cc or 0)
|
||||||
|
a["call_seconds"] = int(cs or 0)
|
||||||
|
a["answered_call"] = int(ca or 0)
|
||||||
|
a["call_followup"] = int(cf or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: PeriodMetrics(
|
||||||
|
user_id=uid,
|
||||||
|
days_with_data=a["days_with_data"],
|
||||||
|
msg_sent=a["msg_sent"],
|
||||||
|
msg_recv=a["msg_recv"],
|
||||||
|
cust_threads=a["cust_threads"],
|
||||||
|
threads_answered=a["threads_answered"],
|
||||||
|
fr_sum_ms=a["fr_sum_ms"],
|
||||||
|
fr_p50_ms=a["fr_p50_ms"],
|
||||||
|
fr_p90_ms=a["fr_p90_ms"],
|
||||||
|
reply_2h_count=a["reply_2h_count"],
|
||||||
|
reply_24h_count=a["reply_24h_count"],
|
||||||
|
active_friends=a["active_friends"],
|
||||||
|
active_rooms_avg=(a["active_rooms_sum"] / n_days) if n_days else 0.0,
|
||||||
|
new_customer=a["new_customer"],
|
||||||
|
cooling_customer=a["cooling_customer"],
|
||||||
|
dormant_customer=a["dormant_customer"],
|
||||||
|
end_active_friends=a["end_active_friends"],
|
||||||
|
reactivated_customer=a["reactivated_customer"],
|
||||||
|
call_count=a["call_count"],
|
||||||
|
call_seconds=a["call_seconds"],
|
||||||
|
answered_call=a["answered_call"],
|
||||||
|
call_followup=a["call_followup"],
|
||||||
|
)
|
||||||
|
for uid, a in acc.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def team_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
|
||||||
|
"""每数值字段的全员中位数(纯函数,评分归一用)。
|
||||||
|
|
||||||
|
None(无样本的分位等)剔除后取中位——statistics.median:奇数取中位
|
||||||
|
样本、偶数取中间两数均值;全员 None → None(调用方按无基线处理)。
|
||||||
|
"""
|
||||||
|
out: dict[str, float | None] = {}
|
||||||
|
for field in PeriodMetrics._fields:
|
||||||
|
if field == "user_id":
|
||||||
|
continue
|
||||||
|
vals = [
|
||||||
|
v for v in (getattr(m, field) for m in metrics.values())
|
||||||
|
if v is not None
|
||||||
|
]
|
||||||
|
out[field] = statistics.median(vals) if vals else None
|
||||||
|
return out
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
|
||||||
|
|
||||||
|
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
|
||||||
|
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
|
||||||
|
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
|
||||||
|
新配置版本(employee_score_config),不静默改数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异
|
||||||
|
METRIC_DEFINITIONS: dict[str, dict] = {
|
||||||
|
"msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1,
|
||||||
|
"caliber": "仅私聊;现有看板为私聊+群聊混合"},
|
||||||
|
"msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1,
|
||||||
|
"caliber": "仅私聊"},
|
||||||
|
"cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1,
|
||||||
|
"caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"},
|
||||||
|
"threads_answered": {"name": "被回串数", "unit": "串", "direction": 1,
|
||||||
|
"caliber": "≡first_reply_fact行数(同源)"},
|
||||||
|
"fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "fr_sum_ms/threads_answered"},
|
||||||
|
"fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "周期内 fact 精确分位(窗口函数)"},
|
||||||
|
"fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1,
|
||||||
|
"caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"},
|
||||||
|
"reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "count(fr_ms<=2h)/cust_threads",
|
||||||
|
"caliber": "分子分母同源(fact/宽表同一算法)"},
|
||||||
|
"reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "count(fr_ms<=24h)/cust_threads"},
|
||||||
|
"active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"},
|
||||||
|
"active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1},
|
||||||
|
"new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "期末单日持有状态,非期内求和"},
|
||||||
|
"cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1,
|
||||||
|
"caliber": "距今天数口径(7-29天);频次基线口径二期"},
|
||||||
|
"dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1,
|
||||||
|
"caliber": "≥30天且历史≥5条"},
|
||||||
|
"reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1,
|
||||||
|
"caliber": "当日回流且前次间隔>30天,期内求和"},
|
||||||
|
"call_count": {"name": "通话数", "unit": "通", "direction": 1,
|
||||||
|
"caliber": "语音通话(微信+WA),无消息渠道维度"},
|
||||||
|
"answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "answered_call/call_count", "caliber": "接通=duration>0"},
|
||||||
|
"call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "call_followup_count/answered_call",
|
||||||
|
"caliber": "接通后24h内任一账号有1:1消息(含客户发来)"},
|
||||||
|
"active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1,
|
||||||
|
"formula": "期末active_friends/(期末active+cooling+dormant)",
|
||||||
|
"caliber": "试算口径:期末单日存量;宽表无生命周期active存量列,active以active_friends列充当"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||||
|
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||||
|
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||||
|
# band : 区间内 100,超上限 80(不再加分),低于下限 None(负载
|
||||||
|
# 不足→不评分)——得分取值范围 80..100
|
||||||
|
DEFAULT_SCORE_CONFIG: dict = {
|
||||||
|
"version": "v1.0",
|
||||||
|
"name": "一期试算(3维可用)",
|
||||||
|
"normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100
|
||||||
|
"trial": True,
|
||||||
|
"sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5},
|
||||||
|
"grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E
|
||||||
|
"dimensions": {
|
||||||
|
"customer": {"weight": 30, "name": "客户经营", "metrics": {
|
||||||
|
"active_share": {"type": "target", "floor": 0.20, "target": 0.60},
|
||||||
|
}},
|
||||||
|
"service": {"weight": 25, "name": "服务质量", "metrics": {
|
||||||
|
"reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85},
|
||||||
|
"reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97},
|
||||||
|
"fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000,
|
||||||
|
"floor_ms": 14_400_000}, # 30min..4h
|
||||||
|
}},
|
||||||
|
"action": {"weight": 20, "name": "行动执行",
|
||||||
|
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||||
|
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||||
|
# basis=period_distinct:用周期 distinct active_friends(B1 口径),
|
||||||
|
# 比单日期末值更抗单日噪声(2026-09-09 B2 决议)
|
||||||
|
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||||
|
"low": 5, "high": 80,
|
||||||
|
"basis": "period_distinct"},
|
||||||
|
"call_band": {"type": "band", "metric": "call_count",
|
||||||
|
"low": 1, "high": 200, "basis": "period_total",
|
||||||
|
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||||
|
}},
|
||||||
|
"risk": {"weight": 10, "name": "合规风险",
|
||||||
|
"status": "pending_data"}, # 批次E/三期接风险事件
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: dict) -> list[str]:
|
||||||
|
"""配置校验:权重总和/结构/引用的 metric_code 有元数据/阈值形状/
|
||||||
|
grade_bounds 形状/指标 code 跨维度唯一。
|
||||||
|
|
||||||
|
阈值形状(随评分引擎落地的 B0 评审预埋要求):
|
||||||
|
- type 须为 target/reverse/band 三者之一
|
||||||
|
- target : floor < target
|
||||||
|
- reverse: target_ms < floor_ms(100 分线在 0 分线之前)
|
||||||
|
- band : low < high
|
||||||
|
B4 预埋(配置将自 DB 加载,写入侧不再重跑种子校验):
|
||||||
|
- grade_bounds 须含 A/B/C/D 且严格降序
|
||||||
|
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
|
||||||
|
同名 code 会相互覆盖(后写胜出)
|
||||||
|
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
|
||||||
|
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
|
||||||
|
"""
|
||||||
|
problems: list[str] = []
|
||||||
|
dims = config.get("dimensions", {})
|
||||||
|
total = sum(d.get("weight", 0) for d in dims.values())
|
||||||
|
if total != 100:
|
||||||
|
problems.append(f"维度权重总和须=100,当前 {total}")
|
||||||
|
gb = config.get("grade_bounds")
|
||||||
|
if not isinstance(gb, dict) or not all(
|
||||||
|
isinstance(gb.get(k), int) for k in ("A", "B", "C", "D")
|
||||||
|
):
|
||||||
|
problems.append("grade_bounds 须含 A/B/C/D 且严格降序")
|
||||||
|
elif not (gb["A"] > gb["B"] > gb["C"] > gb["D"]):
|
||||||
|
problems.append(
|
||||||
|
f"grade_bounds 须严格降序 A>B>C>D"
|
||||||
|
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
|
||||||
|
)
|
||||||
|
seen_codes: dict[str, str] = {}
|
||||||
|
seen_sources: dict[str, str] = {}
|
||||||
|
for key, dim in dims.items():
|
||||||
|
if dim.get("status") == "pending_data":
|
||||||
|
continue
|
||||||
|
for code, spec in dim.get("metrics", {}).items():
|
||||||
|
if code in seen_codes:
|
||||||
|
problems.append(f"指标 code 重复定义于多维度: {code}")
|
||||||
|
seen_codes[code] = key
|
||||||
|
src = spec.get("metric", code)
|
||||||
|
if src in seen_sources:
|
||||||
|
problems.append(
|
||||||
|
f"{key}.{code} 与 {seen_sources[src]} 指向同一源指标 {src}"
|
||||||
|
f"(comparisons 键域将相互覆盖)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seen_sources[src] = f"{key}.{code}"
|
||||||
|
if src not in METRIC_DEFINITIONS:
|
||||||
|
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||||
|
mtype = spec.get("type")
|
||||||
|
if mtype not in ("target", "reverse", "band"):
|
||||||
|
problems.append(
|
||||||
|
f"{key}.{code} 类型非法:{mtype!r}(须 target/reverse/band)"
|
||||||
|
)
|
||||||
|
elif mtype == "target" and not spec.get("floor", 0) < spec.get("target", 0):
|
||||||
|
problems.append(
|
||||||
|
f"{key}.{code} target 型须 floor<target"
|
||||||
|
f"(当前 floor={spec.get('floor')} target={spec.get('target')})"
|
||||||
|
)
|
||||||
|
elif mtype == "reverse" and not (
|
||||||
|
spec.get("target_ms", 0) < spec.get("floor_ms", 0)
|
||||||
|
):
|
||||||
|
problems.append(
|
||||||
|
f"{key}.{code} reverse 型须 target_ms<floor_ms"
|
||||||
|
f"(当前 target_ms={spec.get('target_ms')}"
|
||||||
|
f" floor_ms={spec.get('floor_ms')})"
|
||||||
|
)
|
||||||
|
elif mtype == "band" and not spec.get("low", 0) < spec.get("high", 0):
|
||||||
|
problems.append(
|
||||||
|
f"{key}.{code} band 型须 low<high"
|
||||||
|
f"(当前 low={spec.get('low')} high={spec.get('high')})"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_score_config(session) -> str | None:
|
||||||
|
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。
|
||||||
|
|
||||||
|
同 ensure_starter_words 先例:域包提供纯会话种子函数,CLI 只做接线。
|
||||||
|
config_json 用 deepcopy 冻结快照——JSON 列须整列重赋值,深拷贝防止
|
||||||
|
长驻 API 进程未来的原地修改波及模块常量。
|
||||||
|
"""
|
||||||
|
from app.db.models import EmployeeScoreConfig
|
||||||
|
|
||||||
|
problems = validate_config(DEFAULT_SCORE_CONFIG)
|
||||||
|
if problems:
|
||||||
|
raise ValueError(f"score config invalid: {problems}")
|
||||||
|
exists = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(EmployeeScoreConfig.id).where(
|
||||||
|
EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists is not None:
|
||||||
|
return None
|
||||||
|
session.add(
|
||||||
|
EmployeeScoreConfig(
|
||||||
|
version=DEFAULT_SCORE_CONFIG["version"],
|
||||||
|
name=DEFAULT_SCORE_CONFIG["name"],
|
||||||
|
status="active",
|
||||||
|
config_json=copy.deepcopy(DEFAULT_SCORE_CONFIG),
|
||||||
|
created_by="seed-cli",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return DEFAULT_SCORE_CONFIG["version"]
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
|
||||||
|
|
||||||
|
三类标准化(v1 §10.2):
|
||||||
|
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
|
||||||
|
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||||
|
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
|
||||||
|
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
|
||||||
|
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
|
||||||
|
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
|
||||||
|
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
|
||||||
|
action/risk pending_data 不参与,不默认 0/100。
|
||||||
|
|
||||||
|
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
|
||||||
|
比单日期末值更抗噪声且与 B1 口径一致)。
|
||||||
|
|
||||||
|
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
|
||||||
|
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
|
||||||
|
reply_*_rate/active_share 需调用方对派生值取中位后传入。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from app.management.employee_metrics import PeriodMetrics
|
||||||
|
from app.management.metric_definitions import METRIC_DEFINITIONS
|
||||||
|
|
||||||
|
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(spec: dict, value: float | None) -> float | None:
|
||||||
|
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
|
||||||
|
|
||||||
|
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
|
||||||
|
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
|
||||||
|
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
mtype = spec["type"]
|
||||||
|
if mtype == "target":
|
||||||
|
floor, target = spec["floor"], spec["target"]
|
||||||
|
if value <= floor:
|
||||||
|
return 0.0
|
||||||
|
if value >= target:
|
||||||
|
return 100.0
|
||||||
|
return (value - floor) / (target - floor) * 100.0
|
||||||
|
if mtype == "reverse":
|
||||||
|
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
|
||||||
|
if value <= target_ms:
|
||||||
|
return 100.0
|
||||||
|
if value >= floor_ms:
|
||||||
|
return 0.0
|
||||||
|
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
|
||||||
|
if mtype == "band":
|
||||||
|
low, high = spec["low"], spec["high"]
|
||||||
|
if value < low:
|
||||||
|
return None
|
||||||
|
if value > high:
|
||||||
|
return 80.0
|
||||||
|
return 100.0
|
||||||
|
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
|
||||||
|
|
||||||
|
|
||||||
|
def derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
|
||||||
|
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。
|
||||||
|
|
||||||
|
公有供 anomalies(B3)/dashboard(B4) 复用——评分与异常对比同一派生口径。
|
||||||
|
"""
|
||||||
|
out: dict[str, float | None] = {
|
||||||
|
"fr_p90_ms": pm.fr_p90_ms,
|
||||||
|
"active_friends": pm.active_friends, # band 用,周期 distinct
|
||||||
|
"call_count": pm.call_count, # band 用,period_total
|
||||||
|
}
|
||||||
|
if pm.cust_threads:
|
||||||
|
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
|
||||||
|
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
|
||||||
|
else:
|
||||||
|
# 无串样本→无率(严格区分 0%),分子分母同源
|
||||||
|
out["reply_2h_rate"] = None
|
||||||
|
out["reply_24h_rate"] = None
|
||||||
|
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
|
||||||
|
out["active_share"] = (
|
||||||
|
pm.end_active_friends / denom if denom > 0 else None
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ms(ms: float) -> str:
|
||||||
|
minutes = ms / 60_000
|
||||||
|
if minutes >= 60 and minutes % 60 == 0:
|
||||||
|
return f"{minutes // 60:g}h"
|
||||||
|
return f"{minutes:g}min"
|
||||||
|
|
||||||
|
|
||||||
|
def _formula_note(spec: dict) -> str:
|
||||||
|
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
|
||||||
|
mtype = spec["type"]
|
||||||
|
if mtype == "target":
|
||||||
|
return (
|
||||||
|
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
|
||||||
|
"(≤floor 0,≥target 100)"
|
||||||
|
)
|
||||||
|
if mtype == "reverse":
|
||||||
|
return (
|
||||||
|
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
|
||||||
|
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
|
||||||
|
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_employee(
|
||||||
|
config: dict,
|
||||||
|
current: PeriodMetrics,
|
||||||
|
baseline: PeriodMetrics | None = None,
|
||||||
|
team: dict[str, float] | None = None,
|
||||||
|
prev: PeriodMetrics | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""单员工试算评分(纯函数,B4 端点直接透出)。
|
||||||
|
|
||||||
|
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
|
||||||
|
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
|
||||||
|
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
|
||||||
|
"""
|
||||||
|
cur_vals = derive_metrics(current)
|
||||||
|
prev_vals = derive_metrics(prev) if prev is not None else None
|
||||||
|
base_vals = derive_metrics(baseline) if baseline is not None else None
|
||||||
|
guards = config.get("sample_guards", {})
|
||||||
|
soft = guards.get("soft_threads", 10)
|
||||||
|
minimum = guards.get("min_threads", 30)
|
||||||
|
|
||||||
|
dimensions: dict[str, dict] = {}
|
||||||
|
metrics_detail: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for dim_key, dim in config.get("dimensions", {}).items():
|
||||||
|
entry: dict = {
|
||||||
|
"name": dim.get("name", dim_key),
|
||||||
|
"weight": dim.get("weight", 0),
|
||||||
|
"score": None,
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
if dim.get("status") == "pending_data" or not dim.get("metrics"):
|
||||||
|
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
|
||||||
|
dimensions[dim_key] = entry
|
||||||
|
continue
|
||||||
|
|
||||||
|
dim_scores: list[float] = []
|
||||||
|
any_insufficient = False # 有值但样本不足→维度 insufficient
|
||||||
|
dim_metrics: dict[str, dict] = {}
|
||||||
|
for code, spec in dim["metrics"].items():
|
||||||
|
src = spec.get("metric", code)
|
||||||
|
value = cur_vals.get(src)
|
||||||
|
# 样本量:串类=cust_threads,band 类=active_friends
|
||||||
|
sample_size = (
|
||||||
|
current.active_friends
|
||||||
|
if spec.get("type") == "band" else current.cust_threads
|
||||||
|
)
|
||||||
|
if sample_size >= minimum:
|
||||||
|
sample_status = "ok"
|
||||||
|
elif sample_size >= soft:
|
||||||
|
sample_status = "low_confidence"
|
||||||
|
else:
|
||||||
|
sample_status = "insufficient"
|
||||||
|
score: float | None = None
|
||||||
|
if value is not None:
|
||||||
|
if sample_status == "insufficient":
|
||||||
|
any_insufficient = True # 有可评值但不评分
|
||||||
|
else:
|
||||||
|
score = normalize(spec, value)
|
||||||
|
if score is not None:
|
||||||
|
dim_scores.append(score)
|
||||||
|
dim_metrics[code] = {
|
||||||
|
"value": value,
|
||||||
|
"score": score,
|
||||||
|
"sample_size": sample_size,
|
||||||
|
"sample_status": sample_status,
|
||||||
|
"formula_note": _formula_note(spec),
|
||||||
|
}
|
||||||
|
metrics_detail[code] = {
|
||||||
|
"value": value,
|
||||||
|
"prev": prev_vals.get(src) if prev_vals else None,
|
||||||
|
"baseline": base_vals.get(src) if base_vals else None,
|
||||||
|
"median": team.get(src) if team else None,
|
||||||
|
"sample_size": sample_size,
|
||||||
|
"sample_status": sample_status,
|
||||||
|
"score": score,
|
||||||
|
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
|
||||||
|
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dim_scores:
|
||||||
|
entry["score"] = sum(dim_scores) / len(dim_scores)
|
||||||
|
entry["status"] = "scored"
|
||||||
|
elif any_insufficient:
|
||||||
|
entry["status"] = "insufficient"
|
||||||
|
else:
|
||||||
|
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
|
||||||
|
entry["metrics"] = dim_metrics
|
||||||
|
dimensions[dim_key] = entry
|
||||||
|
|
||||||
|
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
|
||||||
|
available = [
|
||||||
|
(d["weight"], d["score"]) for d in dimensions.values()
|
||||||
|
if d["score"] is not None
|
||||||
|
]
|
||||||
|
total: int | None = None
|
||||||
|
if available:
|
||||||
|
wsum = sum(w for w, _ in available)
|
||||||
|
total = int(math.floor(
|
||||||
|
sum(w * s for w, s in available) / wsum + 0.5
|
||||||
|
))
|
||||||
|
grade: str | None = None
|
||||||
|
if total is not None:
|
||||||
|
grade = "E"
|
||||||
|
bounds = config.get("grade_bounds", {})
|
||||||
|
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
|
||||||
|
if total >= bounds[g]:
|
||||||
|
grade = g
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_score": total,
|
||||||
|
"grade": grade,
|
||||||
|
"score_status": "scored" if total is not None else "insufficient_sample",
|
||||||
|
"trial": bool(config.get("trial")),
|
||||||
|
"dimensions": dimensions,
|
||||||
|
"metrics_detail": metrics_detail,
|
||||||
|
}
|
||||||
@@ -901,7 +901,7 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
|
``'YYYY-MM-DD HH:mm:ss'`` 字符串,另兼容 ms-int/数字串,统一归一为
|
||||||
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
|
ms-epoch int,复用 ``_parse_add_time`` 的双形态解析)
|
||||||
"""
|
"""
|
||||||
return {
|
rec = {
|
||||||
"room_id": record.get("friendWxId"),
|
"room_id": record.get("friendWxId"),
|
||||||
"channel": "wechat",
|
"channel": "wechat",
|
||||||
"room_name": record.get("friendWxNickname"),
|
"room_name": record.get("friendWxNickname"),
|
||||||
@@ -929,6 +929,12 @@ def _normalize_chat_group(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"version": record.get("version"),
|
"version": record.get("version"),
|
||||||
"raw_json": record,
|
"raw_json": record,
|
||||||
}
|
}
|
||||||
|
# 群备注(2026-09-09 微信群统计):friend_list/es 是唯一备注源
|
||||||
|
# (getChatroomList 行无备注键);**有值才写**——空值不放键,upsert 不
|
||||||
|
# 更新该列,不会被无备注的抓取轮次清空
|
||||||
|
if record.get("friendWxRemark"):
|
||||||
|
rec["group_remark"] = record["friendWxRemark"]
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
async def scrape_wechat_groups(
|
async def scrape_wechat_groups(
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""employee_call_daily / employee_metric_daily 维护层(管理域,批次A)。
|
||||||
|
|
||||||
|
数据流(v2 §4):
|
||||||
|
msg_daily_stats(friend) + first_reply_facts 同源扫描 + customer 生命周期
|
||||||
|
→ employee_metric_daily (day, channel, user, metric_version) [Task 6]
|
||||||
|
phone_call(按 user_id+start_call_time 日聚合)
|
||||||
|
→ employee_call_daily (day, user)——通话无渠道维(v2 §1.2 #9) [本任务]
|
||||||
|
|
||||||
|
幽灵员工过滤(v2 §1.3 #11):owner 映射不上或不在 staff 表的不落表。
|
||||||
|
重算语义:按 day delete+insert,幂等;夜间全量 + (批次F)4h 滚动。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from bisect import bisect_right
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from itertools import accumulate
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.analytics.lifecycle import classify
|
||||||
|
from app.db.models import (
|
||||||
|
Customer,
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
Message,
|
||||||
|
MsgDailyStats,
|
||||||
|
PhoneCall,
|
||||||
|
Staff,
|
||||||
|
)
|
||||||
|
from app.storage.first_reply_facts import (
|
||||||
|
compute_conversation,
|
||||||
|
load_conversation,
|
||||||
|
pairs_active_in_days,
|
||||||
|
)
|
||||||
|
from app.storage.staff_summary import account_map
|
||||||
|
from app.storage.summary import day_bounds_ms
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 通话类型 → 消息渠道(Task0 核实:channel 列可判别,voice_call=微信/
|
||||||
|
# wa_voice_call=WA;followup 消息查询按此收窄)
|
||||||
|
_CALL_CHANNEL_TO_MSG: dict[str, str] = {"voice_call": "wechat", "wa_voice_call": "whatsapp"}
|
||||||
|
|
||||||
|
|
||||||
|
async def known_user_ids(session: AsyncSession) -> set[str]:
|
||||||
|
"""员工主数据 user_id 集合(staff 表;Task0 已核实 41 行全在职)。"""
|
||||||
|
rows = (await session.execute(sa.select(Staff.user_id))).scalars().all()
|
||||||
|
return {u for u in rows if u}
|
||||||
|
|
||||||
|
|
||||||
|
async def _followup_exists(
|
||||||
|
session: AsyncSession,
|
||||||
|
msg_channel: str,
|
||||||
|
owner_accounts: list[str],
|
||||||
|
peer: str,
|
||||||
|
call_ts_ms: int,
|
||||||
|
window_ms: int = 86_400_000,
|
||||||
|
) -> bool:
|
||||||
|
"""通话后 24h 内员工任一账号对该客户有 1:1 消息。
|
||||||
|
|
||||||
|
含客户发来的消息——口径=窗口内存在任一方向 1:1 消息。
|
||||||
|
按通话类型收窄消息渠道(Task0:voice_call→wechat/wa_voice_call→whatsapp,
|
||||||
|
1:1 通话可匹配率 100%,@chatroom 群通话天然不匹配 1:1 消息)。"""
|
||||||
|
if not owner_accounts or not peer:
|
||||||
|
return False
|
||||||
|
n = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(Message).where(
|
||||||
|
Message.channel == msg_channel,
|
||||||
|
Message.owner_account_id.in_(owner_accounts),
|
||||||
|
Message.talker_id == peer,
|
||||||
|
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
|
||||||
|
Message.timestamp.is_not(None),
|
||||||
|
Message.timestamp > call_ts_ms,
|
||||||
|
Message.timestamp <= call_ts_ms + window_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
return bool(n)
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_call_days(session: AsyncSession, days: Iterable[int]) -> int:
|
||||||
|
"""重算给定日的通话小表:按 day delete+insert,幂等。返回写入行数。
|
||||||
|
|
||||||
|
口径:接通 = call_duration > 0(Task0:与 call_status=1 差 31 条/1.0%,
|
||||||
|
取 dur>0 与现有 roadmap 口径一致);call_seconds 单位=秒(Task0 已核实);
|
||||||
|
followup 只对已接通通话判定(未接通的回拨语义留二期)。
|
||||||
|
"""
|
||||||
|
# 员工→该渠道账号(followup 判定用,按消息渠道分组)
|
||||||
|
acct = {ch: await account_map(session, ch) for ch in ("wechat", "whatsapp")}
|
||||||
|
rev: dict[str, dict[str, list[str]]] = {} # uid → msg_channel → owners
|
||||||
|
for msg_ch, amap in acct.items():
|
||||||
|
for owner, uid in amap.items():
|
||||||
|
rev.setdefault(uid, {}).setdefault(msg_ch, []).append(owner)
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
staff_ids = await known_user_ids(session) # 幽灵过滤(v2 §1.3 #11)
|
||||||
|
for day in sorted(set(days)):
|
||||||
|
lo, hi = day_bounds_ms(day)
|
||||||
|
calls = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
PhoneCall.user_id,
|
||||||
|
PhoneCall.channel,
|
||||||
|
PhoneCall.call_number,
|
||||||
|
PhoneCall.start_call_time,
|
||||||
|
PhoneCall.call_duration,
|
||||||
|
).where(
|
||||||
|
PhoneCall.start_call_time.is_not(None),
|
||||||
|
PhoneCall.start_call_time >= lo,
|
||||||
|
PhoneCall.start_call_time < hi,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
await session.execute(
|
||||||
|
sa.delete(EmployeeCallDaily).where(EmployeeCallDaily.day == day)
|
||||||
|
)
|
||||||
|
# (uid, call_type, number, ts, dur)——msg_channel 按通话类型映射;
|
||||||
|
# 未知通话类型(如蜂窝 phone_call)无消息渠道可查,followup 判
|
||||||
|
# False 但仍计数
|
||||||
|
agg: dict[str, dict] = {}
|
||||||
|
for uid, call_ch, number, ts, dur in calls:
|
||||||
|
if not uid or uid not in staff_ids:
|
||||||
|
continue
|
||||||
|
a = agg.setdefault(uid, {"cnt": 0, "sec": 0, "answered": 0, "followup": 0})
|
||||||
|
a["cnt"] += 1
|
||||||
|
a["sec"] += int(dur or 0)
|
||||||
|
if (dur or 0) > 0:
|
||||||
|
a["answered"] += 1
|
||||||
|
msg_ch = _CALL_CHANNEL_TO_MSG.get(call_ch or "")
|
||||||
|
if msg_ch and number:
|
||||||
|
owners = rev.get(uid, {}).get(msg_ch, [])
|
||||||
|
if await _followup_exists(session, msg_ch, owners, str(number), int(ts)):
|
||||||
|
a["followup"] += 1
|
||||||
|
for uid, a in sorted(agg.items()):
|
||||||
|
session.add(
|
||||||
|
EmployeeCallDaily(
|
||||||
|
day=day,
|
||||||
|
user_id=uid,
|
||||||
|
call_count=a["cnt"],
|
||||||
|
call_seconds=a["sec"],
|
||||||
|
answered_call_count=a["answered"],
|
||||||
|
call_followup_count=a["followup"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
written += 1
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
METRIC_VERSION = "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(vals: list[int], q: float) -> int | None:
|
||||||
|
"""最近邻秩分位 ceil(q·n);空列表→None(与 0 严格区分)。"""
|
||||||
|
if not vals:
|
||||||
|
return None
|
||||||
|
s = sorted(vals)
|
||||||
|
idx = min(len(s) - 1, max(0, math.ceil(q * len(s)) - 1))
|
||||||
|
return s[idx]
|
||||||
|
|
||||||
|
|
||||||
|
async def _lifecycle_daily(
|
||||||
|
session: AsyncSession,
|
||||||
|
days: list[int],
|
||||||
|
channel: str,
|
||||||
|
amap: dict[str, str],
|
||||||
|
valid_users: set[str],
|
||||||
|
) -> dict[int, dict[str, dict[str, int]]]:
|
||||||
|
"""逐日 {day: {user: {state: count}}}——customer 当前归属 + 互动日 sweep。
|
||||||
|
|
||||||
|
数据源:msg_daily_stats friend 行全历史(互动日/条数)。hist 为 ≤as_of
|
||||||
|
的历史消息数(时点语义:与 last 同一 bisect 位取按日累积数组 cum[idx],
|
||||||
|
as_of 之后的消息不虚增历史日的沉睡门槛)。16k 客户 × 窗口天数
|
||||||
|
bisect+classify,夜间全量实测见批次A核实文档;4h tick 只算当日+3 天。
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.peer_id, MsgDailyStats.day,
|
||||||
|
MsgDailyStats.send_cnt, MsgDailyStats.recv_cnt,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.channel == channel,
|
||||||
|
MsgDailyStats.peer_kind == "friend",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
# peer → {day: 消息条数};days_list 与累积数组 cum 对齐:
|
||||||
|
# cum[i] = 该客户 ≤days_list[i] 的历史消息数(hist 时点语义)
|
||||||
|
day_msgs: dict[str, dict[int, int]] = {}
|
||||||
|
for peer, day, snd, rcv in rows:
|
||||||
|
dm = day_msgs.setdefault(peer, {})
|
||||||
|
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
|
||||||
|
interaction = {p: sorted(dm) for p, dm in day_msgs.items()}
|
||||||
|
|
||||||
|
custs = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(Customer.owner_account_id, Customer.friend_id).where(
|
||||||
|
Customer.channel == channel,
|
||||||
|
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
|
||||||
|
Customer.owner_account_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
peer_to_user: dict[str, str] = {}
|
||||||
|
for owner, peer in custs:
|
||||||
|
uid = amap.get(owner or "")
|
||||||
|
if uid and uid in valid_users and peer:
|
||||||
|
peer_to_user[str(peer)] = uid
|
||||||
|
|
||||||
|
out: dict[int, dict[str, dict[str, int]]] = {d: {} for d in days}
|
||||||
|
for peer, days_list in interaction.items():
|
||||||
|
uid = peer_to_user.get(peer)
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
first = days_list[0]
|
||||||
|
cum = list(accumulate(day_msgs[peer][d] for d in days_list))
|
||||||
|
for d in days:
|
||||||
|
idx = bisect_right(days_list, d) - 1
|
||||||
|
if idx < 0:
|
||||||
|
continue
|
||||||
|
last = days_list[idx]
|
||||||
|
prev = days_list[idx - 1] if idx > 0 else None
|
||||||
|
hist_asof = cum[idx]
|
||||||
|
state = classify(first, last, prev, d, hist_asof)
|
||||||
|
if state:
|
||||||
|
out[d].setdefault(uid, {}).setdefault(state, 0)
|
||||||
|
out[d][uid][state] += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_metric_days(
|
||||||
|
session: AsyncSession,
|
||||||
|
days: Iterable[int],
|
||||||
|
channels: tuple[str, ...] = ("wechat", "whatsapp"),
|
||||||
|
) -> int:
|
||||||
|
"""重算给定日的宽表:按 (day, channel) delete+insert,幂等。返回写入行数。
|
||||||
|
|
||||||
|
串类列与首响日值:扫窗口内活跃 friend 会话的**全历史**(每会话一次),
|
||||||
|
与 first_reply_fact 同一 compute_conversation——分子分母同源(v2 §1.1 #5)。
|
||||||
|
"""
|
||||||
|
days = sorted(set(days))
|
||||||
|
valid_users = await known_user_ids(session)
|
||||||
|
pairs = await pairs_active_in_days(session, days)
|
||||||
|
pairs_by_channel: dict[str, list] = {}
|
||||||
|
for p in pairs:
|
||||||
|
pairs_by_channel.setdefault(p[0], []).append((p[1], p[2]))
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
for channel in channels:
|
||||||
|
amap = await account_map(session, channel)
|
||||||
|
# ---- 串类+分位:逐会话全历史一次,聚合到 (day, user) ----
|
||||||
|
thread_cols: dict[tuple[int, str], dict] = {}
|
||||||
|
frs: dict[tuple[int, str], list[int]] = {}
|
||||||
|
day_set = set(days)
|
||||||
|
for owner, peer in pairs_by_channel.get(channel, []):
|
||||||
|
uid = amap.get(owner)
|
||||||
|
if uid is None or uid not in valid_users:
|
||||||
|
continue
|
||||||
|
rows = await load_conversation(session, channel, owner, peer)
|
||||||
|
facts, agg = compute_conversation(rows)
|
||||||
|
for d, a in agg.items():
|
||||||
|
if d not in day_set:
|
||||||
|
continue
|
||||||
|
key = (d, uid)
|
||||||
|
t = thread_cols.setdefault(
|
||||||
|
key, {"threads": 0, "answered": 0, "fr": 0}
|
||||||
|
)
|
||||||
|
t["threads"] += a.threads
|
||||||
|
t["answered"] += a.answered
|
||||||
|
t["fr"] += a.fr_sum_ms
|
||||||
|
frs.setdefault(key, []).extend(
|
||||||
|
f.fr_ms for f in facts if f.day == d
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 生命周期 ----
|
||||||
|
life = await _lifecycle_daily(session, days, channel, amap, valid_users)
|
||||||
|
|
||||||
|
# ---- 消息计数+负载:msg_daily_stats(算法无关列) ----
|
||||||
|
for day in days:
|
||||||
|
mds = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
MsgDailyStats.peer_kind,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
MsgDailyStats.send_cnt,
|
||||||
|
MsgDailyStats.recv_cnt,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.day == day, MsgDailyStats.channel == channel
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
await session.execute(
|
||||||
|
sa.delete(EmployeeMetricDaily).where(
|
||||||
|
EmployeeMetricDaily.day == day,
|
||||||
|
EmployeeMetricDaily.channel == channel,
|
||||||
|
EmployeeMetricDaily.metric_version == METRIC_VERSION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
agg: dict[str, dict] = {}
|
||||||
|
for owner, kind, peer, snd, rcv in mds:
|
||||||
|
uid = amap.get(owner)
|
||||||
|
if uid is None or uid not in valid_users:
|
||||||
|
continue # 幽灵过滤
|
||||||
|
a = agg.setdefault(
|
||||||
|
uid,
|
||||||
|
{"sent": 0, "recv": 0, "friends": set(), "rooms": set()},
|
||||||
|
)
|
||||||
|
# 消息计数列 = 1:1 口径(v2 §1.1 #2):room 只进 active_rooms
|
||||||
|
if kind == "friend":
|
||||||
|
a["sent"] += int(snd or 0)
|
||||||
|
a["recv"] += int(rcv or 0)
|
||||||
|
a["friends"].add(peer)
|
||||||
|
elif kind == "room":
|
||||||
|
a["rooms"].add(peer)
|
||||||
|
day_life = life.get(day, {})
|
||||||
|
# 行并集:消息源 ∪ 生命周期源——纯生命周期日(员工当日无消息
|
||||||
|
# 但持有降温/沉睡客户)也要出宽表行,否则客户经营维度漏天
|
||||||
|
all_uids = sorted(set(agg) | set(day_life))
|
||||||
|
for uid in all_uids:
|
||||||
|
a = agg.get(
|
||||||
|
uid, {"sent": 0, "recv": 0, "friends": set(), "rooms": set()}
|
||||||
|
)
|
||||||
|
key = (day, uid)
|
||||||
|
t = thread_cols.get(key, {"threads": 0, "answered": 0, "fr": 0})
|
||||||
|
lv = day_life.get(uid, {})
|
||||||
|
session.add(
|
||||||
|
EmployeeMetricDaily(
|
||||||
|
day=day,
|
||||||
|
channel=channel,
|
||||||
|
user_id=uid,
|
||||||
|
metric_version=METRIC_VERSION,
|
||||||
|
msg_sent=a["sent"],
|
||||||
|
msg_recv=a["recv"],
|
||||||
|
active_friends=len(a["friends"]),
|
||||||
|
active_rooms=len(a["rooms"]),
|
||||||
|
cust_threads=t["threads"],
|
||||||
|
threads_answered=t["answered"],
|
||||||
|
fr_sum_ms=t["fr"],
|
||||||
|
first_reply_p50_ms=_percentile(frs.get(key, []), 0.5),
|
||||||
|
first_reply_p90_ms=_percentile(frs.get(key, []), 0.9),
|
||||||
|
new_customer_count=lv.get("new", 0),
|
||||||
|
cooling_customer_count=lv.get("cooling", 0),
|
||||||
|
dormant_customer_count=lv.get("dormant", 0),
|
||||||
|
reactivated_customer_count=lv.get("reactivated", 0),
|
||||||
|
data_complete=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
written += 1
|
||||||
|
# 防御性 flush:防 autoflush=False 类配置下逐日 add 的 ORM 对象
|
||||||
|
# 攒到事务尾(夜间全量约 52k 行;与 Task3 周期性 flush 同教训)
|
||||||
|
await session.flush()
|
||||||
|
return written
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""first_reply_fact 维护层(管理域,批次A)——跨日精确串算法。
|
||||||
|
|
||||||
|
区别于 app.storage.summary._thread_stats 的日切近似(v2 §1.1 #1):
|
||||||
|
按会话**全历史**计算,跨夜串(客户 23:00 → 员工次日 9:00 回)不再丢失。
|
||||||
|
**不挂写钩子**(v2 §3.1):由回填 CLI / 4h 滚动 / 夜间全量重算,写钩子
|
||||||
|
零改动。只处理 1:1(friend)会话——群串语义不成立。
|
||||||
|
|
||||||
|
compute_conversation 同时输出每日串聚合(threads/answered/fr_sum_ms,
|
||||||
|
按串末条归属日)——employee_metric_daily 的串类列与 fact 行**同源**
|
||||||
|
(回复率分子分母一致,v2 §1.1 #5)。
|
||||||
|
|
||||||
|
时序确定性:同毫秒并列(ts 相同的 F/T 消息)按 msg_id 定序(wechat
|
||||||
|
msgSvrId 服务端单调,近似真实到达序)——SQL 等键返回顺序未定义,不做
|
||||||
|
定序会导致串归属随查询抖动;fact PK 含 reply_msg_id,同毫秒 F,T,F,T
|
||||||
|
交换的两个样本不撞主键。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import namedtuple
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import FirstReplyFact, Message, MsgDailyStats
|
||||||
|
from app.storage.summary import day_key
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 一个被回串的首响样本;day = 串末条客户消息所在上海自然日,
|
||||||
|
# reply_msg_id = 闭合该串的员工回复消息 msg_id(PK 并列去重 + 下钻证据)
|
||||||
|
FactSample = namedtuple("FactSample", "thread_end_ts reply_ts fr_ms day reply_msg_id")
|
||||||
|
# 某会话某日的串聚合(分子分母同源)
|
||||||
|
DayAgg = namedtuple("DayAgg", "threads answered fr_sum_ms")
|
||||||
|
|
||||||
|
|
||||||
|
class _DayAcc:
|
||||||
|
__slots__ = ("answered", "fr_sum_ms", "threads")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.threads = 0
|
||||||
|
self.answered = 0
|
||||||
|
self.fr_sum_ms = 0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_conversation(rows: list) -> tuple[list[FactSample], dict[int, DayAgg]]:
|
||||||
|
"""跨日精确串算法。rows = [(ts, is_send, msg_id)] 该会话全历史,
|
||||||
|
**须按 (ts, msg_id) 升序**(load_conversation 负责排序;同毫秒并列以
|
||||||
|
msg_id 定序,见模块 docstring)。
|
||||||
|
|
||||||
|
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断的
|
||||||
|
段;is_send=None 跳过(不打断也不起串)——与 _thread_stats 判定一致。
|
||||||
|
- 每个被回串一个样本:fr_ms = 员工首条回复 ts − 串**末条**客户消息 ts,
|
||||||
|
归属 day = 串末条所在上海自然日(跨夜串不再丢失);reply_msg_id =
|
||||||
|
该回复消息 msg_id——一条回复恰好闭合一个串,同毫秒 F,T,F,T 交换的
|
||||||
|
(thread_end_ts, reply_ts) 并列靠它去重,兼作下钻证据。
|
||||||
|
- 会话末尾仍未闭合的串:计入当日 threads(回复率分母),不产生样本。
|
||||||
|
"""
|
||||||
|
facts: list[FactSample] = []
|
||||||
|
acc: dict[int, _DayAcc] = {}
|
||||||
|
in_thread = False
|
||||||
|
end_ts: int | None = None
|
||||||
|
for ts, is_send, msg_id in rows:
|
||||||
|
if is_send is False:
|
||||||
|
in_thread = True
|
||||||
|
end_ts = int(ts)
|
||||||
|
elif is_send is True:
|
||||||
|
if in_thread and end_ts is not None:
|
||||||
|
fr = int(ts) - end_ts
|
||||||
|
d = day_key(end_ts)
|
||||||
|
facts.append(FactSample(end_ts, int(ts), fr, d, msg_id))
|
||||||
|
a = acc.setdefault(d, _DayAcc())
|
||||||
|
a.threads += 1
|
||||||
|
a.answered += 1
|
||||||
|
a.fr_sum_ms += fr
|
||||||
|
in_thread = False
|
||||||
|
end_ts = None
|
||||||
|
# is_send=None:跳过
|
||||||
|
if in_thread and end_ts is not None: # 会话末尾未闭合串
|
||||||
|
a = acc.setdefault(day_key(end_ts), _DayAcc())
|
||||||
|
a.threads += 1
|
||||||
|
return facts, {d: DayAgg(a.threads, a.answered, a.fr_sum_ms)
|
||||||
|
for d, a in acc.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_conversation(
|
||||||
|
session: AsyncSession, channel: str, owner: str, peer: str
|
||||||
|
) -> list:
|
||||||
|
"""拉取该 1:1 会话全历史 (timestamp, is_send, msg_id),Python 侧按
|
||||||
|
(ts, msg_id) 排序——串算法需严格时序且同毫秒并列须定序(SQL 等键顺序
|
||||||
|
未定义,不排序会随查询抖动;与 summary.recompute_days 同惯例,避免
|
||||||
|
依赖 SQL 排序)。friend 过滤口径与 summary._peer_filter 的 friend 分支
|
||||||
|
同源:talker_id 原值精确匹配 + room_id 为空(peer_id 与 talker_id 同源
|
||||||
|
同形态,不做截断/归一化)。
|
||||||
|
|
||||||
|
公有命名:employee_metrics 宽表生成复用同一加载口径(同源原则)。"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Message.timestamp, Message.is_send, Message.msg_id
|
||||||
|
).where(
|
||||||
|
Message.channel == channel,
|
||||||
|
Message.owner_account_id == owner,
|
||||||
|
Message.talker_id == peer,
|
||||||
|
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
|
||||||
|
Message.timestamp.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return sorted(rows, key=lambda r: (r[0], r[2]))
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_pairs(
|
||||||
|
session: AsyncSession, pairs: list[tuple[str, str, str]]
|
||||||
|
) -> int:
|
||||||
|
"""重算给定 friend 会话的 fact 行:按会话 delete+insert,幂等。
|
||||||
|
|
||||||
|
pairs = [(channel, owner_account_id, peer_id)]。返回会话数。每 500 个
|
||||||
|
会话 flush 一次约束 session 内存(与 summary.recompute_all 同惯例——
|
||||||
|
全量回填百万级 fact 行不能攒到最后),同时打进度日志(回填分钟级,
|
||||||
|
可观测)。commit 由调用方负责。
|
||||||
|
"""
|
||||||
|
n = 0
|
||||||
|
for channel, owner, peer in sorted(pairs):
|
||||||
|
rows = await load_conversation(session, channel, owner, peer)
|
||||||
|
facts, _agg = compute_conversation(rows)
|
||||||
|
await session.execute(
|
||||||
|
sa.delete(FirstReplyFact).where(
|
||||||
|
FirstReplyFact.channel == channel,
|
||||||
|
FirstReplyFact.owner_account_id == owner,
|
||||||
|
FirstReplyFact.peer_id == peer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for f in facts:
|
||||||
|
session.add(
|
||||||
|
FirstReplyFact(
|
||||||
|
channel=channel,
|
||||||
|
owner_account_id=owner,
|
||||||
|
peer_id=peer,
|
||||||
|
thread_end_ts=f.thread_end_ts,
|
||||||
|
reply_ts=f.reply_ts,
|
||||||
|
reply_msg_id=f.reply_msg_id,
|
||||||
|
day=f.day,
|
||||||
|
fr_ms=f.fr_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
if n % 500 == 0:
|
||||||
|
await session.flush()
|
||||||
|
logger.info("first_reply_facts: 已重算 %d 个会话", n)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def all_friend_pairs(session: AsyncSession) -> list[tuple[str, str, str]]:
|
||||||
|
"""全部 1:1 会话(friend 行 distinct)——全量回填用。
|
||||||
|
|
||||||
|
依赖 msg_daily_stats 已回填(先 stats 后 facts):friend 会话清单取自
|
||||||
|
stats 表而非 message 全表 distinct——写路径钩子保证 stats 常新,代价
|
||||||
|
远低于扫 message。
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.channel,
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
).where(MsgDailyStats.peer_kind == "friend")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return sorted({(r[0], r[1], r[2]) for r in rows})
|
||||||
|
|
||||||
|
|
||||||
|
async def pairs_active_in_days(
|
||||||
|
session: AsyncSession, days: Iterable[int]
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""窗口内有消息的 friend 会话(4h 滚动重算用;批次F 接调度)。"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
MsgDailyStats.channel,
|
||||||
|
MsgDailyStats.owner_account_id,
|
||||||
|
MsgDailyStats.peer_id,
|
||||||
|
).where(
|
||||||
|
MsgDailyStats.peer_kind == "friend",
|
||||||
|
MsgDailyStats.day.in_(list(days)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return sorted({(r[0], r[1], r[2]) for r in rows})
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_all(session: AsyncSession) -> int:
|
||||||
|
"""全量重建(回填/夜间自愈,幂等)。"""
|
||||||
|
return await recompute_pairs(session, await all_friend_pairs(session))
|
||||||
@@ -53,6 +53,7 @@ that one table. Multi-table routing is a LATER task (YAGNI for M1).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -61,7 +62,9 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
ChatGroup,
|
ChatGroup,
|
||||||
|
ChatGroupMemberStats,
|
||||||
Customer,
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
Department,
|
Department,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
Media,
|
Media,
|
||||||
@@ -74,11 +77,16 @@ from app.db.models import (
|
|||||||
WechatAccount,
|
WechatAccount,
|
||||||
WhatsappAccount,
|
WhatsappAccount,
|
||||||
)
|
)
|
||||||
|
from app.storage.summary import recompute_days, touched_day_keys
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"upsert_chat_group",
|
"upsert_chat_group",
|
||||||
|
"upsert_chat_group_member_stats",
|
||||||
"upsert_customer",
|
"upsert_customer",
|
||||||
"upsert_customers",
|
"upsert_customers",
|
||||||
|
"prune_customer_links",
|
||||||
"upsert_department",
|
"upsert_department",
|
||||||
"upsert_media",
|
"upsert_media",
|
||||||
"upsert_messages",
|
"upsert_messages",
|
||||||
@@ -376,9 +384,34 @@ async def upsert_customer(session: AsyncSession, rec: dict[str, Any]) -> None:
|
|||||||
"""Idempotently upsert one :class:`Customer` (PK = ``friend_id``).
|
"""Idempotently upsert one :class:`Customer` (PK = ``friend_id``).
|
||||||
|
|
||||||
Same ``friend_id`` twice -> one row, with non-PK fields refreshed from the
|
Same ``friend_id`` twice -> one row, with non-PK fields refreshed from the
|
||||||
later record.
|
later record. 同时维护 ``customer_account_link``(渠道×账号×客户一行,
|
||||||
|
2026-09-08):customer 档案全局单行 last-wins,共享客户的其他员工关系
|
||||||
|
靠 link 表保留;员工维度列表/计数读 link(对齐云客 per-employee 口径)。
|
||||||
"""
|
"""
|
||||||
await _upsert_one(session, Customer, rec)
|
await _upsert_one(session, Customer, rec)
|
||||||
|
owner = rec.get("owner_account_id")
|
||||||
|
if owner:
|
||||||
|
raw = rec.get("raw_json") or {}
|
||||||
|
link_rec = {
|
||||||
|
"channel": rec.get("channel"),
|
||||||
|
"owner_account_id": owner,
|
||||||
|
"friend_id": rec.get("friend_id"),
|
||||||
|
"friend_nickname": rec.get("friend_nickname"),
|
||||||
|
"friend_remark": rec.get("friend_remark"),
|
||||||
|
"head_image_url": rec.get("head_image_url"),
|
||||||
|
"real_name": rec.get("real_name"),
|
||||||
|
"phone": rec.get("phone"),
|
||||||
|
"last_chat_time": rec.get("last_chat_time"),
|
||||||
|
"add_time": rec.get("add_time"),
|
||||||
|
"send_num": rec.get("send_num"),
|
||||||
|
"receive_num": rec.get("receive_num"),
|
||||||
|
"message_count": rec.get("message_count"),
|
||||||
|
"delete_flag": rec.get("delete_flag"),
|
||||||
|
}
|
||||||
|
# fromType 只在 raw_json(云客数据来源代码,员工客户页列)
|
||||||
|
if isinstance(raw, dict) and raw.get("fromType") is not None:
|
||||||
|
link_rec["from_type"] = raw.get("fromType")
|
||||||
|
await _upsert_one(session, CustomerAccountLink, link_rec)
|
||||||
|
|
||||||
|
|
||||||
async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
|
async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
|
||||||
@@ -388,10 +421,33 @@ async def upsert_customers(session: AsyncSession, recs: list[dict[str, Any]]) ->
|
|||||||
single row (the last write wins) at the DB layer.
|
single row (the last write wins) at the DB layer.
|
||||||
"""
|
"""
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
await _upsert_one(session, Customer, rec)
|
await upsert_customer(session, rec)
|
||||||
return len(recs)
|
return len(recs)
|
||||||
|
|
||||||
|
|
||||||
|
async def prune_customer_links(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
owner_account_id: str,
|
||||||
|
keep_friend_ids: list[str],
|
||||||
|
) -> int:
|
||||||
|
"""删除该账号名下**不在最新名单**里的 customer_account_link 行。
|
||||||
|
|
||||||
|
云客 isDel=0 只返回当前好友;抓取器逐员工整批 upsert 后调用本函数对账
|
||||||
|
(离开名单=已不是好友,或好友转移)。**调用方必须保证名单完整**(翻满
|
||||||
|
声明 total)再调——拿到半份名单就 prune 会误删。返回删除行数。
|
||||||
|
"""
|
||||||
|
conditions = [
|
||||||
|
CustomerAccountLink.channel == channel,
|
||||||
|
CustomerAccountLink.owner_account_id == owner_account_id,
|
||||||
|
]
|
||||||
|
if keep_friend_ids:
|
||||||
|
conditions.append(CustomerAccountLink.friend_id.not_in(keep_friend_ids))
|
||||||
|
result = await session.execute(sa.delete(CustomerAccountLink).where(*conditions))
|
||||||
|
return int(result.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
|
async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) -> int:
|
||||||
"""Batch idempotent upsert for :class:`Message` (PK = ``msg_id``).
|
"""Batch idempotent upsert for :class:`Message` (PK = ``msg_id``).
|
||||||
|
|
||||||
@@ -409,6 +465,20 @@ async def upsert_messages(session: AsyncSession, recs: list[dict[str, Any]]) ->
|
|||||||
"""
|
"""
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
await _upsert_one(session, Message, rec)
|
await _upsert_one(session, Message, rec)
|
||||||
|
|
||||||
|
# 落库后维护日粒度汇总(msg_daily_stats):只重算本批触及的「会话×天」,
|
||||||
|
# 窄时间范围查询毫秒级。所有消息写入路径(页面抓取器微信/WA/群、CLI
|
||||||
|
# 手动同步)都汇到本函数,一处挂全。失败不阻塞消息写入——夜间
|
||||||
|
# recompute_all 自愈兜底(2026-09-03 会话列表超时修复)。
|
||||||
|
keys = touched_day_keys(recs)
|
||||||
|
if keys:
|
||||||
|
try:
|
||||||
|
await recompute_days(session, keys)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"daily-stats recompute failed (%d keys); nightly rebuild will heal", len(keys)
|
||||||
|
)
|
||||||
|
|
||||||
return len(recs)
|
return len(recs)
|
||||||
|
|
||||||
|
|
||||||
@@ -563,6 +633,18 @@ async def upsert_chat_group(session: AsyncSession, rec: dict[str, Any]) -> None:
|
|||||||
await _upsert_one(session, GroupMember, member_rec)
|
await _upsert_one(session, GroupMember, member_rec)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_chat_group_member_stats(
|
||||||
|
session: AsyncSession, rec: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Idempotently upsert one :class:`ChatGroupMemberStats` day row.
|
||||||
|
|
||||||
|
PK = (channel, owner_account_id, room_id, day)。每夜「当日窗」抓取的
|
||||||
|
addMembers/lossMembers 落一行;同日重跑刷新(幂等)。页面任意日窗的
|
||||||
|
新增/退出 = 按天求和(2026-09-09 微信群统计对齐)。
|
||||||
|
"""
|
||||||
|
await _upsert_one(session, ChatGroupMemberStats, rec)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PhoneCall (voice_call) upsert (M2, Task 2.4)
|
# PhoneCall (voice_call) upsert (M2, Task 2.4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -721,8 +803,13 @@ async def upsert_media(
|
|||||||
|
|
||||||
dialect = await _dialect_name(session)
|
dialect = await _dialect_name(session)
|
||||||
pk_cols = _pk_col_names(Media)
|
pk_cols = _pk_col_names(Media)
|
||||||
|
# 只更新实际插入的列(2026-09-10 生产修复):此前取全部表列,server_default
|
||||||
|
# 列(fetched_at/updated_at)不在 values 里 → MySQL 编译出
|
||||||
|
# ``new.fetched_at`` 悬空引用 → 每夜 call_recordings 步骤 1054 静默
|
||||||
|
# 失败,录音本地化停更多日。
|
||||||
update_cols = [
|
update_cols = [
|
||||||
c.name for c in Media.__table__.columns if c.name not in pk_cols
|
c.name for c in Media.__table__.columns
|
||||||
|
if c.name not in pk_cols and c.name in values
|
||||||
]
|
]
|
||||||
media_cols = Media.__table__.c
|
media_cols = Media.__table__.c
|
||||||
|
|
||||||
@@ -730,7 +817,8 @@ async def upsert_media(
|
|||||||
"""Map each updated column to its conflict-set expression.
|
"""Map each updated column to its conflict-set expression.
|
||||||
|
|
||||||
Volatile download artifacts use ``COALESCE(incoming, existing)``; every
|
Volatile download artifacts use ``COALESCE(incoming, existing)``; every
|
||||||
other column is refreshed directly from the incoming row.
|
other column is refreshed directly from the incoming row. ``updated_at``
|
||||||
|
刷新为 now()(冲突重下时反映最新一次落盘)。
|
||||||
"""
|
"""
|
||||||
set_: dict[str, Any] = {}
|
set_: dict[str, Any] = {}
|
||||||
for name in update_cols:
|
for name in update_cols:
|
||||||
@@ -738,6 +826,7 @@ async def upsert_media(
|
|||||||
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
|
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
|
||||||
else:
|
else:
|
||||||
set_[name] = other_cols[name]
|
set_[name] = other_cols[name]
|
||||||
|
set_["updated_at"] = sa.func.now()
|
||||||
return set_
|
return set_
|
||||||
|
|
||||||
if dialect == "sqlite":
|
if dialect == "sqlite":
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""日粒度会话汇总(msg_daily_stats)的维护层。
|
||||||
|
|
||||||
|
设计要点(2026-09-03 会话列表超时修复):
|
||||||
|
|
||||||
|
- **day 口径**:Asia/Shanghai 自然日,:func:`day_key` 纯 Python 计算——
|
||||||
|
不用 SQL 日期函数(from_unixtime 跟随 MySQL 会话时区,UTC 容器下与
|
||||||
|
sessions 的上海零点口径割裂;SQLite/MySQL 方言也各自不同)。
|
||||||
|
- **写路径钩子**::func:`repos.upsert_messages` 落库后调
|
||||||
|
:func:`recompute_days` 重算本批触及的「会话×天」(窄时间范围查询,
|
||||||
|
毫秒级)。页面抓取器/WA/群消息/CLI 手动同步全部路径最终都走
|
||||||
|
upsert_messages,一处挂全。
|
||||||
|
- **全量重建**::func:`recompute_all` 清空后逐会话重算——夜间自愈
|
||||||
|
(02:00 full_sync_all.sh 追加一步)兼部署回填,幂等。
|
||||||
|
|
||||||
|
计数语义与旧实时聚合完全一致: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
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import Message, MsgDailyStats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
async def has_rows(session: AsyncSession) -> bool:
|
||||||
|
"""汇总表是否已有数据(读端点的空表守卫:部署回填前自动回退旧实时聚合)。
|
||||||
|
|
||||||
|
表还不存在(漏跑迁移的升级)与空表同视——回退旧路径,不 500。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(MsgDailyStats)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
except (sa.exc.OperationalError, sa.exc.ProgrammingError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def day_key(ts_ms: int) -> int:
|
||||||
|
"""ms-epoch → 上海自然日 yyyymmdd。"""
|
||||||
|
d = dt.datetime.fromtimestamp(ts_ms / 1000, tz=_SH)
|
||||||
|
return d.year * 10000 + d.month * 100 + d.day
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds_ms(day: int) -> tuple[int, int]:
|
||||||
|
"""上海自然日 → [当日 00:00, 次日 00:00) 的 ms 左闭右开区间。"""
|
||||||
|
y, m, d = day // 10000, (day // 100) % 100, day % 100
|
||||||
|
start = dt.datetime(y, m, d, tzinfo=_SH)
|
||||||
|
return int(start.timestamp() * 1000), int(
|
||||||
|
(start + dt.timedelta(days=1)).timestamp() * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_peer(rec: dict) -> tuple[str, str] | None:
|
||||||
|
"""消息记录 → (peer_kind, peer_id);room 优先(群消息按群归账)。"""
|
||||||
|
room = rec.get("room_id")
|
||||||
|
if room:
|
||||||
|
return ("room", str(room))
|
||||||
|
talker = rec.get("talker_id")
|
||||||
|
if talker:
|
||||||
|
return ("friend", str(talker))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def touched_day_keys(
|
||||||
|
recs: list[dict],
|
||||||
|
) -> set[tuple[str, str, str, str, int]]:
|
||||||
|
"""从一批消息记录提取要重算的 (channel, owner, kind, peer, day) 集合。"""
|
||||||
|
out: set[tuple[str, str, str, str, int]] = set()
|
||||||
|
for rec in recs:
|
||||||
|
ts = rec.get("timestamp")
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
kp = _kind_peer(rec)
|
||||||
|
if kp is None:
|
||||||
|
continue
|
||||||
|
out.add(
|
||||||
|
(
|
||||||
|
str(rec.get("channel") or ""),
|
||||||
|
str(rec.get("owner_account_id") or ""),
|
||||||
|
kp[0],
|
||||||
|
kp[1],
|
||||||
|
day_key(int(ts)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_filter(kind: str, peer: str) -> list:
|
||||||
|
"""kind/peer → message 表过滤条件(与 sessions/staff-chat-stats 口径一致)。"""
|
||||||
|
if kind == "room":
|
||||||
|
return [
|
||||||
|
Message.room_id == peer,
|
||||||
|
Message.room_id.is_not(None),
|
||||||
|
Message.room_id != "",
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
Message.talker_id == peer,
|
||||||
|
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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]],
|
||||||
|
) -> int:
|
||||||
|
"""重算给定 (channel, owner, kind, peer, day) 组合的统计行。
|
||||||
|
|
||||||
|
幂等:先删旧行再按当前 message 真值重建(0 条消息的日子删行不留);
|
||||||
|
同批消息跨页重放自然收敛。查询是窄时间范围(单会话单日),毫秒级。
|
||||||
|
"""
|
||||||
|
n = 0
|
||||||
|
for channel, owner, kind, peer, day in sorted(keys):
|
||||||
|
lo, hi = _day_bounds_ms(day)
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(Message.timestamp, Message.is_send).where(
|
||||||
|
Message.channel == channel,
|
||||||
|
Message.owner_account_id == owner,
|
||||||
|
*_peer_filter(kind, peer),
|
||||||
|
Message.timestamp.is_not(None),
|
||||||
|
Message.timestamp >= lo,
|
||||||
|
Message.timestamp < hi,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
send = recv = 0
|
||||||
|
last_ts: int | None = None
|
||||||
|
last_is_send: bool | None = None
|
||||||
|
# 串算法需要严格时序:先排序(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:
|
||||||
|
recv += 1
|
||||||
|
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,
|
||||||
|
MsgDailyStats.owner_account_id == owner,
|
||||||
|
MsgDailyStats.peer_kind == kind,
|
||||||
|
MsgDailyStats.peer_id == peer,
|
||||||
|
MsgDailyStats.day == day,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
session.add(
|
||||||
|
MsgDailyStats(
|
||||||
|
channel=channel,
|
||||||
|
owner_account_id=owner,
|
||||||
|
peer_kind=kind,
|
||||||
|
peer_id=peer,
|
||||||
|
day=day,
|
||||||
|
send_cnt=send,
|
||||||
|
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
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def recompute_all(session: AsyncSession) -> int:
|
||||||
|
"""全量重建 msg_daily_stats(夜间自愈 / 部署回填)。
|
||||||
|
|
||||||
|
清空后按会话分组拉取全历史 (timestamp, is_send) 在 Python 侧按上海日
|
||||||
|
分桶——一次清空保证绕过钩子的写入(裸 UPDATE/回填脚本)也能被纠正。
|
||||||
|
返回重算的会话数。夜间 02:00 窗口跑,全库 86 万行量级预计分钟级。
|
||||||
|
"""
|
||||||
|
await session.execute(sa.delete(MsgDailyStats))
|
||||||
|
pair_rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Message.channel,
|
||||||
|
Message.owner_account_id,
|
||||||
|
Message.room_id,
|
||||||
|
Message.talker_id,
|
||||||
|
)
|
||||||
|
.where(Message.timestamp.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 四列 DISTINCT 后同一群会按成员 talker 出现多行 → 归并成 (channel,
|
||||||
|
# owner, kind, peer) 去重,否则同主键日行会插两遍(IntegrityError)。
|
||||||
|
pairs: set[tuple[str, str, str, str]] = set()
|
||||||
|
for channel, owner, room, talker in pair_rows:
|
||||||
|
if room:
|
||||||
|
pairs.add((channel, owner, "room", str(room)))
|
||||||
|
elif talker:
|
||||||
|
pairs.add((channel, owner, "friend", str(talker)))
|
||||||
|
|
||||||
|
total_pairs = 0
|
||||||
|
for channel, owner, kind, peer in sorted(pairs):
|
||||||
|
# 拉全会话消息按日重算(而非逐日窄查):全量重建时一次往返更省
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(Message.timestamp, Message.is_send).where(
|
||||||
|
Message.channel == channel,
|
||||||
|
Message.owner_account_id == owner,
|
||||||
|
*_peer_filter(kind, peer),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
days: dict[int, list] = {}
|
||||||
|
for ts, is_send in rows:
|
||||||
|
days.setdefault(day_key(int(ts)), []).append((ts, is_send))
|
||||||
|
for day, day_rows in days.items():
|
||||||
|
send = recv = 0
|
||||||
|
last_ts = None
|
||||||
|
last_is_send = None
|
||||||
|
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:
|
||||||
|
recv += 1
|
||||||
|
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,
|
||||||
|
owner_account_id=owner,
|
||||||
|
peer_kind=kind,
|
||||||
|
peer_id=peer,
|
||||||
|
day=day,
|
||||||
|
send_cnt=send,
|
||||||
|
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).
|
"""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:
|
# One-shot full sync (every entity) into the configured DB:
|
||||||
python -m app.sync.cli sync
|
python -m app.sync.cli sync
|
||||||
@@ -11,6 +11,14 @@ Three invocations::
|
|||||||
# Long-running scheduler (APScheduler; full sync daily + messages every 4h):
|
# Long-running scheduler (APScheduler; full sync daily + messages every 4h):
|
||||||
python -m app.sync.cli scheduler
|
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
|
||||||
|
python -m app.sync.cli first-reply-facts
|
||||||
|
python -m app.sync.cli employee-metrics
|
||||||
|
python -m app.sync.cli score-config
|
||||||
|
|
||||||
Auth + DB
|
Auth + DB
|
||||||
---------
|
---------
|
||||||
Auth comes from env / ``.env`` via the same helpers the smoke script uses
|
Auth comes from env / ``.env`` via the same helpers the smoke script uses
|
||||||
@@ -44,6 +52,8 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import models # noqa: F401 (register tables on Base.metadata)
|
from app.db import models # noqa: F401 (register tables on Base.metadata)
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -89,6 +99,85 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
help="Suppress per-run JSON progress lines (errors still logged).",
|
help="Suppress per-run JSON progress lines (errors still logged).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ``daily-stats`` ---------------------------------------------------------
|
||||||
|
p_stats = sub.add_parser(
|
||||||
|
"daily-stats",
|
||||||
|
help="Rebuild msg_daily_stats from the message table.",
|
||||||
|
description=(
|
||||||
|
"Full rebuild of the daily conversation-summary table "
|
||||||
|
"(msg_daily_stats). Used as the nightly self-heal step in "
|
||||||
|
"scripts/full_sync_all.sh and as the one-off deploy backfill "
|
||||||
|
"(see docs/DEPLOY.md). Idempotent."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``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).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``first-reply-facts`` ----------------------------------------------
|
||||||
|
p_fr = sub.add_parser(
|
||||||
|
"first-reply-facts",
|
||||||
|
help="Rebuild first_reply_fact (cross-day exact threads).",
|
||||||
|
description=(
|
||||||
|
"Full rebuild of the first-reply fact table from the message "
|
||||||
|
"table (cross-day exact thread algorithm — overnight samples "
|
||||||
|
"not lost, unlike msg_daily_stats day-cut). Deploy backfill + "
|
||||||
|
"nightly self-heal step. Idempotent."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``employee-metrics`` ------------------------------------------------
|
||||||
|
p_emp = sub.add_parser(
|
||||||
|
"employee-metrics",
|
||||||
|
help="Rebuild employee_metric_daily + employee_call_daily.",
|
||||||
|
description=(
|
||||||
|
"Full rebuild of the management wide table (per-day x channel "
|
||||||
|
"x staff, 1:1 caliber) and the call daily table. Must run "
|
||||||
|
"AFTER first-reply-facts (the fact table feeds Batch-B "
|
||||||
|
"scoring and drilldown; both recompute threads from messages "
|
||||||
|
"via one shared algorithm). Deploy backfill + nightly "
|
||||||
|
"self-heal. Idempotent."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``score-config`` ----------------------------------------------------
|
||||||
|
p_score = sub.add_parser(
|
||||||
|
"score-config",
|
||||||
|
help="Seed the default score config (idempotent).",
|
||||||
|
description=(
|
||||||
|
"Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into "
|
||||||
|
"employee_score_config as active. Skips if the version "
|
||||||
|
"already exists. Batch B."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ``scheduler`` -----------------------------------------------------------
|
# ``scheduler`` -----------------------------------------------------------
|
||||||
p_sched = sub.add_parser(
|
p_sched = sub.add_parser(
|
||||||
"scheduler",
|
"scheduler",
|
||||||
@@ -274,6 +363,130 @@ def _print_sync_summary(entity: str, results: list[Any]) -> None:
|
|||||||
print(line, flush=True)
|
print(line, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_daily_stats(args: argparse.Namespace) -> int:
|
||||||
|
"""全量重建 msg_daily_stats(夜间自愈 / 部署回填,幂等)。"""
|
||||||
|
settings = get_settings()
|
||||||
|
engine, session_maker = await _init_db(settings.db_url)
|
||||||
|
try:
|
||||||
|
from app.storage.summary import recompute_all
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
pairs = await recompute_all(session)
|
||||||
|
await session.commit()
|
||||||
|
print(f"[daily-stats] rebuilt {pairs} conversation pairs")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_first_reply_facts(args: argparse.Namespace) -> int:
|
||||||
|
"""全量重建 first_reply_fact(跨日精确串;部署回填/夜间自愈,幂等)。"""
|
||||||
|
settings = get_settings()
|
||||||
|
engine, session_maker = await _init_db(settings.db_url)
|
||||||
|
try:
|
||||||
|
from app.storage.first_reply_facts import recompute_all
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
pairs = await recompute_all(session)
|
||||||
|
await session.commit()
|
||||||
|
print(f"[first-reply-facts] rebuilt {pairs} conversations")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_employee_metrics(args: argparse.Namespace) -> int:
|
||||||
|
"""全量重建 employee_metric_daily + employee_call_daily(幂等)。"""
|
||||||
|
settings = get_settings()
|
||||||
|
engine, session_maker = await _init_db(settings.db_url)
|
||||||
|
try:
|
||||||
|
from app.db.models import MsgDailyStats, PhoneCall
|
||||||
|
from app.storage.employee_metrics import (
|
||||||
|
recompute_call_days,
|
||||||
|
recompute_metric_days,
|
||||||
|
)
|
||||||
|
from app.storage.summary import day_key
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
msg_days = (
|
||||||
|
await session.execute(sa.select(MsgDailyStats.day).distinct())
|
||||||
|
).scalars().all()
|
||||||
|
# 宽表全走消息派生源,只算消息日。
|
||||||
|
rows = await recompute_metric_days(session, list(msg_days))
|
||||||
|
# 通话日 = 消息日 ∪ 通话日:消息空窗日(节假日/同步缺口)的通话
|
||||||
|
# 也要进小表。start_call_time→上海日必须在 Python 侧走 day_key
|
||||||
|
# ——MySQL 会话时区是 UTC,SQL 日期函数会切错日。
|
||||||
|
call_ts = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(PhoneCall.start_call_time)
|
||||||
|
.where(PhoneCall.start_call_time.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
call_days = sorted(set(msg_days) | {day_key(ts) for ts in call_ts})
|
||||||
|
calls = await recompute_call_days(session, call_days)
|
||||||
|
await session.commit()
|
||||||
|
print(
|
||||||
|
f"[employee-metrics] rebuilt {rows} wide rows, {calls} call rows "
|
||||||
|
f"({len(call_days)} days)"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_score_config(args: argparse.Namespace) -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
engine, session_maker = await _init_db(settings.db_url)
|
||||||
|
try:
|
||||||
|
from app.management.metric_definitions import _seed_score_config
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
version = await _seed_score_config(session)
|
||||||
|
await session.commit()
|
||||||
|
print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point. Returns a process exit code."""
|
"""CLI entry point. Returns a process exit code."""
|
||||||
# Configure logging so the scheduler's JSON line + the orchestrator's
|
# Configure logging so the scheduler's JSON line + the orchestrator's
|
||||||
@@ -292,6 +505,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return asyncio.run(_cmd_sync(args))
|
return asyncio.run(_cmd_sync(args))
|
||||||
if args.command == "scheduler":
|
if args.command == "scheduler":
|
||||||
return asyncio.run(_cmd_scheduler(args))
|
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))
|
||||||
|
if args.command == "first-reply-facts":
|
||||||
|
return asyncio.run(_cmd_first_reply_facts(args))
|
||||||
|
if args.command == "employee-metrics":
|
||||||
|
return asyncio.run(_cmd_employee_metrics(args))
|
||||||
|
if args.command == "score-config":
|
||||||
|
return asyncio.run(_cmd_score_config(args))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
|
# So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C.
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -535,6 +536,16 @@ class SyncScheduler:
|
|||||||
coalesce=True,
|
coalesce=True,
|
||||||
max_instances=1,
|
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()
|
sched.start()
|
||||||
self._scheduler = sched
|
self._scheduler = sched
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -591,50 +602,77 @@ class SyncScheduler:
|
|||||||
if not bash:
|
if not bash:
|
||||||
logger.error("page sync 'full': bash not found on PATH")
|
logger.error("page sync 'full': bash not found on PATH")
|
||||||
return
|
return
|
||||||
commands: list[list[str]] = [[bash, "scripts/full_sync_all.sh"]]
|
commands = [([bash, "scripts/full_sync_all.sh"], 22 * 3600)] # 22h 上限:防极端情况拖进次日相位(全量常态 10-20h)
|
||||||
elif kind == "messages":
|
elif kind == "messages":
|
||||||
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照
|
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
|
||||||
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类
|
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
|
||||||
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受);
|
# 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
|
||||||
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多
|
# full 刷,长任务挤掉 full 就停更);
|
||||||
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh)
|
# ② 消息实体只扫**近 24h 活跃**对话(--active-window-hours;
|
||||||
|
# 从未扫过的不受限)——全对话遍历(WA 9,357 个×~3.5s≈9h)
|
||||||
|
# 曾把序列拖到 15h+,超过 4h 间隔→与 02:00 full 背靠背
|
||||||
|
# 互相挤死;陈旧对话无新消息,每日 full 兜底;
|
||||||
|
# ③ 每命令超时保险丝(超时 kill 下轮补,增量幂等)。
|
||||||
|
# friends 仍先刷(最近沟通时间是排序键+活跃窗口的判定源)。
|
||||||
|
msg_entities = [
|
||||||
|
("accounts", None, 900),
|
||||||
|
("wa-accounts", None, 900),
|
||||||
|
("friends", None, 2400),
|
||||||
|
("wa-friends", None, 2400),
|
||||||
|
("messages", 24, 4800),
|
||||||
|
("wa-messages", 24, 4800),
|
||||||
|
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
|
||||||
|
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
|
||||||
|
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
|
||||||
|
("chatroom-stats", None, 1800),
|
||||||
|
("group-messages", 24, 4800),
|
||||||
|
("wa-group-messages", 24, 2400),
|
||||||
|
("moments", None, 2400),
|
||||||
|
]
|
||||||
commands = [
|
commands = [
|
||||||
[sys.executable, "scripts/scrape_via_page.py", "--entity", "friends"],
|
(
|
||||||
[sys.executable, "scripts/scrape_via_page.py", "--entity", "wa-friends"],
|
[
|
||||||
] + [
|
sys.executable,
|
||||||
[
|
"scripts/scrape_via_page.py",
|
||||||
sys.executable,
|
"--entity",
|
||||||
"scripts/scrape_via_page.py",
|
entity,
|
||||||
"--entity",
|
]
|
||||||
e,
|
+ (
|
||||||
"--max-conversations",
|
[
|
||||||
"99999",
|
"--max-conversations",
|
||||||
]
|
"99999",
|
||||||
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages")
|
"--active-window-hours",
|
||||||
] + [
|
str(window),
|
||||||
[
|
]
|
||||||
sys.executable,
|
if window
|
||||||
"scripts/scrape_via_page.py",
|
else (
|
||||||
"--entity",
|
["--max-pages", "3"]
|
||||||
"moments",
|
if entity == "moments"
|
||||||
"--max-pages",
|
else []
|
||||||
"3",
|
)
|
||||||
]
|
),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
for entity, window, timeout in msg_entities
|
||||||
]
|
]
|
||||||
elif kind == "full-history":
|
elif kind == "full-history":
|
||||||
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
|
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
|
||||||
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
|
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
|
||||||
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
|
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
|
||||||
commands = [
|
commands = [
|
||||||
[
|
(
|
||||||
sys.executable,
|
[
|
||||||
"scripts/scrape_via_page.py",
|
sys.executable,
|
||||||
"--entity",
|
"scripts/scrape_via_page.py",
|
||||||
e,
|
"--entity",
|
||||||
"--max-conversations",
|
e,
|
||||||
"99999",
|
"--max-conversations",
|
||||||
"--full-history",
|
"99999",
|
||||||
]
|
"--full-history",
|
||||||
|
],
|
||||||
|
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
|
||||||
|
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
|
||||||
|
)
|
||||||
for e in entities
|
for e in entities
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
@@ -642,8 +680,8 @@ class SyncScheduler:
|
|||||||
return
|
return
|
||||||
|
|
||||||
rc = 0
|
rc = 0
|
||||||
for args in commands:
|
for args, timeout in commands:
|
||||||
rc = await self._page_subprocess(args) or rc
|
rc = await self._page_subprocess(args, timeout=timeout) or rc
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
|
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
|
||||||
break
|
break
|
||||||
@@ -662,8 +700,71 @@ class SyncScheduler:
|
|||||||
logger.info(line)
|
logger.info(line)
|
||||||
print(line, flush=True)
|
print(line, flush=True)
|
||||||
|
|
||||||
async def _page_subprocess(self, args: list[str]) -> int:
|
async def _analytics_tick(self) -> None:
|
||||||
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。"""
|
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
|
||||||
|
|
||||||
|
两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
|
||||||
|
+ 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
|
||||||
|
段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
|
||||||
|
子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
|
||||||
|
夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
|
||||||
|
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
|
||||||
|
|
||||||
|
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch
|
||||||
|
(``time.time()*1000``)即无歧义。
|
||||||
|
"""
|
||||||
|
from app.storage.summary import day_key
|
||||||
|
|
||||||
|
today = day_key(int(time.time() * 1000))
|
||||||
|
# ── 段1:staff 滚动 + 敏感词增量(既有,语义原样) ──
|
||||||
|
try:
|
||||||
|
async with self._session_maker() as session:
|
||||||
|
from app.analytics.sensitive import scanner
|
||||||
|
from app.storage.staff_summary import recompute_days, rolling_days
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
|
||||||
|
try:
|
||||||
|
async with self._session_maker() as session:
|
||||||
|
from app.storage.employee_metrics import (
|
||||||
|
recompute_call_days,
|
||||||
|
recompute_metric_days,
|
||||||
|
)
|
||||||
|
from app.storage.first_reply_facts import (
|
||||||
|
pairs_active_in_days,
|
||||||
|
recompute_pairs,
|
||||||
|
)
|
||||||
|
from app.storage.staff_summary import rolling_days
|
||||||
|
|
||||||
|
rd = rolling_days(today, 3)
|
||||||
|
pairs = await pairs_active_in_days(session, rd)
|
||||||
|
f = await recompute_pairs(session, pairs)
|
||||||
|
w = await recompute_metric_days(session, rd)
|
||||||
|
c = await recompute_call_days(session, rd)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
|
||||||
|
f, w, c,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("management metrics tick failed")
|
||||||
|
|
||||||
|
async def _page_subprocess(self, args: list[str], *, timeout: float | None = None) -> int:
|
||||||
|
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。
|
||||||
|
|
||||||
|
``timeout``(秒,2026-09-10 调度根治保险丝):超时 kill 子进程返回
|
||||||
|
非零——上游 break 跳过本序列剩余命令,增量幂等下轮自动补,防止
|
||||||
|
单命令拖死整个调度相位(4h 序列曾因此 15h+ 与 full 互相挤死)。
|
||||||
|
"""
|
||||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.db_url and "DB_URL" not in env:
|
if settings.db_url and "DB_URL" not in env:
|
||||||
@@ -673,7 +774,18 @@ class SyncScheduler:
|
|||||||
cwd=str(REPO_ROOT),
|
cwd=str(REPO_ROOT),
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
return await proc.wait()
|
if timeout is None:
|
||||||
|
return await proc.wait()
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(proc.wait(), timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"page sync subprocess timeout after %.0fs (killed): %s",
|
||||||
|
timeout, " ".join(args[-6:]),
|
||||||
|
)
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return 124 # 超时语义(同 shell timeout 惯例)
|
||||||
|
|
||||||
async def _safe_run(
|
async def _safe_run(
|
||||||
self, entity: str, *, log: bool = True
|
self, entity: str, *, log: bool = True
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""msg_daily_stats 日粒度会话汇总表(会话列表/5数字弹窗超时修复)
|
||||||
|
|
||||||
|
Revision ID: a7d3e9f1c2b4
|
||||||
|
Revises: c53f90b1e6a4
|
||||||
|
Create Date: 2026-09-03 17:00:00.000000
|
||||||
|
|
||||||
|
内容
|
||||||
|
----
|
||||||
|
sessions 与 staff-chat-stats 原先每次请求对 owner 全量 message 实时
|
||||||
|
GROUP BY——重账号 5.2 万条消息实测 3~5s(sessions)/6.9s(全时段
|
||||||
|
staff-chat-stats),在服务器 128MB buffer pool 下进一步被推过前端 axios
|
||||||
|
15s 超时(ECONNABORTED,2026-09-03 用户报「聊天记录页数据多时请求失败」)。
|
||||||
|
|
||||||
|
本表是预聚合层:每个「渠道×账号×会话×上海自然日」一行(收/发计数、当日
|
||||||
|
最后一条时间与方向)。维护::
|
||||||
|
|
||||||
|
* 写路径:``repos.upsert_messages`` 落库后自动重算本批触及的「会话×天」
|
||||||
|
(所有抓取路径——页面抓取器微信/WA/群、CLI 手动同步——都汇到该函数)。
|
||||||
|
* 读路径:两个端点在表有行时读它(毫秒级),空表回退旧实时聚合(部署
|
||||||
|
回填前的守卫)。
|
||||||
|
* 自愈:夜间 full_sync_all.sh 的 ``daily-stats`` 步骤全量重建,纠正绕过
|
||||||
|
钩子的写入(裸 UPDATE/回填脚本);部署回填也用同一条命令
|
||||||
|
(``python -m app.sync.cli daily-stats``)。
|
||||||
|
|
||||||
|
day 为 int yyyymmdd,口径一律 Asia/Shanghai(Python 计算,不用 SQL 日期
|
||||||
|
函数——from_unixtime 跟随 DB 会话时区,与今日数口径割裂)。
|
||||||
|
|
||||||
|
幂等:``sa.inspect`` 先查同名表,存在即跳过。SQLite/MySQL 通用。
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a7d3e9f1c2b4"
|
||||||
|
down_revision: str | Sequence[str] | None = "c53f90b1e6a4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_TABLE = "msg_daily_stats"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if insp.has_table(_TABLE):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
_TABLE,
|
||||||
|
sa.Column("channel", sa.String(length=16), primary_key=True),
|
||||||
|
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("peer_kind", sa.String(length=8), primary_key=True),
|
||||||
|
sa.Column("peer_id", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("day", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"send_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"recv_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
|
||||||
|
),
|
||||||
|
sa.Column("last_ts", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("last_is_send", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if not insp.has_table(_TABLE):
|
||||||
|
return
|
||||||
|
op.drop_table(_TABLE)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""经营管理后台批次A:6 张新表(fact/宽表/通话小表/评分配置/快照/辅导)
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: f7a8b9c0d1e2
|
||||||
|
Create Date: 2026-09-08
|
||||||
|
|
||||||
|
spec: docs/superpowers/plans/2026-09-08-yunke-management-console-implementation-v2.md §3。
|
||||||
|
幂等:sa.inspect 先查,存在即跳过;SQLite/MySQL 通用。回填走 CLI
|
||||||
|
(first-reply-facts / employee-metrics),migration 不扫数据。
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b7c8d9e0f1a2"
|
||||||
|
down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_TABLES = [
|
||||||
|
("first_reply_fact", lambda: op.create_table(
|
||||||
|
"first_reply_fact",
|
||||||
|
sa.Column("channel", sa.String(length=16), primary_key=True),
|
||||||
|
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("peer_id", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("thread_end_ts", sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column("reply_ts", sa.BigInteger(), primary_key=True),
|
||||||
|
# 同毫秒 F,T,F,T 交换的 (thread_end_ts, reply_ts) 并列去重(一条回复
|
||||||
|
# 恰好闭合一个串,reply_msg_id 唯一定位);兼作下钻证据定位回复消息
|
||||||
|
sa.Column("reply_msg_id", sa.String(length=64), primary_key=True),
|
||||||
|
sa.Column("day", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("fr_ms", sa.BigInteger(), nullable=False),
|
||||||
|
)),
|
||||||
|
("employee_metric_daily", lambda: op.create_table(
|
||||||
|
"employee_metric_daily",
|
||||||
|
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("metric_version", sa.String(length=16), primary_key=True,
|
||||||
|
server_default="v1"),
|
||||||
|
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("cust_threads", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("threads_answered", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("fr_sum_ms", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("first_reply_p50_ms", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("first_reply_p90_ms", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("new_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("cooling_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("dormant_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("reactivated_customer_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("data_complete", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)),
|
||||||
|
("employee_call_daily", lambda: op.create_table(
|
||||||
|
"employee_call_daily",
|
||||||
|
sa.Column("day", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(length=64), primary_key=True),
|
||||||
|
sa.Column("call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("call_seconds", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("answered_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("call_followup_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("computed_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
)),
|
||||||
|
("employee_score_config", lambda: op.create_table(
|
||||||
|
"employee_score_config",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("version", sa.String(length=32), nullable=False, unique=True),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="draft"),
|
||||||
|
sa.Column("effective_from", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("effective_to", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("config_json", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("approved_by", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("activated_at", sa.DateTime(), nullable=True),
|
||||||
|
)),
|
||||||
|
("employee_score_snapshot", lambda: op.create_table(
|
||||||
|
"employee_score_snapshot",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("snapshot_batch_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("period_type", sa.String(length=8), nullable=False),
|
||||||
|
sa.Column("period_start", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("period_end", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("score_config_version", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("metric_version", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("total_score", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("grade", sa.String(length=2), nullable=True),
|
||||||
|
sa.Column("score_status", sa.String(length=24), nullable=False, server_default="scored"),
|
||||||
|
sa.Column("dimension_scores_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("metric_details_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("data_as_of", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("frozen_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("frozen_by", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("supersedes_id", sa.Integer(), nullable=True),
|
||||||
|
sa.UniqueConstraint("snapshot_batch_id", "user_id",
|
||||||
|
name="uq_score_snapshot_batch_user"),
|
||||||
|
)),
|
||||||
|
("management_followup", lambda: op.create_table(
|
||||||
|
"management_followup",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("employee_user_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("source_type", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("source_code", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("source_period_start", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_period_end", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_value_json", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("action_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("owner_manager_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="open"),
|
||||||
|
sa.Column("due_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("result", sa.Text(), nullable=True),
|
||||||
|
sa.Column("employee_feedback", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=64), 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()),
|
||||||
|
sa.Column("closed_at", sa.DateTime(), nullable=True),
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
|
||||||
|
_INDEXES = [
|
||||||
|
("first_reply_fact", "ix_fr_fact_day_channel", ["day", "channel"]),
|
||||||
|
("employee_score_snapshot", "ix_score_snapshot_period", ["period_type", "period_start"]),
|
||||||
|
("management_followup", "ix_followup_emp_status_due",
|
||||||
|
["employee_user_id", "status", "due_at"]),
|
||||||
|
("management_followup", "ix_followup_owner_status", ["owner_manager_id", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
for name, create in _TABLES:
|
||||||
|
if not insp.has_table(name):
|
||||||
|
create()
|
||||||
|
# 索引守卫独立于建表(MySQL DDL 逐句自动提交;建表成功但索引失败的
|
||||||
|
# 中断重跑要能补上索引)
|
||||||
|
for table, index, cols in _INDEXES:
|
||||||
|
if not _has_index(bind, table, index):
|
||||||
|
op.create_index(index, table, cols)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
for table in ("management_followup", "employee_score_snapshot",
|
||||||
|
"employee_score_config", "employee_call_daily",
|
||||||
|
"employee_metric_daily", "first_reply_fact"):
|
||||||
|
if insp.has_table(table):
|
||||||
|
op.drop_table(table)
|
||||||
@@ -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,80 @@
|
|||||||
|
"""chat_group 补 group_remark 列 + chat_group_member_stats 按天进出表
|
||||||
|
|
||||||
|
Revision ID: c9d0e1f2a3b4
|
||||||
|
Revises: b7c8d9e0f1a2
|
||||||
|
Create Date: 2026-09-09
|
||||||
|
|
||||||
|
内容
|
||||||
|
----
|
||||||
|
微信群列表对齐云客「微信群统计」页(2026-09-09 真机探明,数据源切
|
||||||
|
``getChatroomList``):
|
||||||
|
|
||||||
|
* ``chat_group.group_remark`` —— 群备注列(getChatroomList.remark /
|
||||||
|
friend_list/es 的 friendWxRemark,此前只存 raw_json);
|
||||||
|
* ``chat_group_member_stats`` —— 群成员进出**按天**统计表。云客的新增/
|
||||||
|
退出成员数是按任意时间窗实时算的,本地无成员进出事件流,改为每夜抓
|
||||||
|
一次「当日窗」的 addMembers/lossMembers 落行,页面任意日窗=按天求和;
|
||||||
|
上线日起积累,历史日期无数据。
|
||||||
|
|
||||||
|
幂等:列/表存在即跳过。SQLite/MySQL 通用。
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c9d0e1f2a3b4'
|
||||||
|
down_revision: str | Sequence[str] | None = 'b7c8d9e0f1a2'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_TABLE = 'chat_group_member_stats'
|
||||||
|
_COL = 'group_remark'
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table: str) -> bool:
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
return table in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table: str, column: str) -> bool:
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
return column in {c["name"] for c in insp.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
if not _has_column("chat_group", _COL):
|
||||||
|
op.add_column("chat_group", sa.Column(_COL, sa.String(255), nullable=True))
|
||||||
|
# 回填:群备注从存量 raw_json.friendWxRemark 提取(friend_list/es 来源;
|
||||||
|
# getChatroomList 行无备注键)。幂等:只补 NULL 行。MySQL 方言
|
||||||
|
# (JSON_UNQUOTE),SQLite(测试库,不走生产迁移)跳过。
|
||||||
|
if op.get_bind().dialect.name == "mysql":
|
||||||
|
op.execute(
|
||||||
|
"UPDATE chat_group SET group_remark = JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) "
|
||||||
|
"WHERE group_remark IS NULL "
|
||||||
|
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) IS NOT NULL "
|
||||||
|
"AND JSON_UNQUOTE(JSON_EXTRACT(raw_json, '$.friendWxRemark')) != ''"
|
||||||
|
)
|
||||||
|
if _has_table(_TABLE):
|
||||||
|
return # 已建过,幂等跳过
|
||||||
|
op.create_table(
|
||||||
|
_TABLE,
|
||||||
|
sa.Column('channel', sa.String(16), primary_key=True),
|
||||||
|
sa.Column('owner_account_id', sa.String(128), primary_key=True),
|
||||||
|
sa.Column('room_id', sa.String(128), primary_key=True),
|
||||||
|
sa.Column('day', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('added', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('fetched_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
if _has_table(_TABLE):
|
||||||
|
op.drop_table(_TABLE)
|
||||||
|
if _has_column("chat_group", _COL):
|
||||||
|
op.drop_column("chat_group", _COL)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""message 补无渠道前缀索引 (owner_account_id, talker_id, timestamp)
|
||||||
|
|
||||||
|
Revision ID: d8e9f0a1b2c3
|
||||||
|
Revises: c9d0e1f2a3b4
|
||||||
|
Create Date: 2026-09-10
|
||||||
|
|
||||||
|
背景:该回未回决赛圈点查在 channel=None(管理驾驶舱 action_counts)时
|
||||||
|
无渠道前缀条件,现有索引全部以 channel 打头用不上——服务器 100 万行
|
||||||
|
实测 dashboard 34~37s 撞前端 15s 超时;索引落地后 1.3~1.8s。
|
||||||
|
幂等:sa.inspect 先查,存在即跳过(服务器已先行手工建同名索引,本迁移
|
||||||
|
兜底对齐 schema 版本)。「模型+迁移双声明」防 autogenerate 反 DROP。
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "d8e9f0a1b2c3"
|
||||||
|
down_revision: str | Sequence[str] | None = "c9d0e1f2a3b4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_IDX = "ix_message_owner_talker_time"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(bind) -> bool:
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if not insp.has_table("message"):
|
||||||
|
return False
|
||||||
|
return _IDX in {ix["name"] for ix in insp.get_indexes("message")}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_index(op.get_bind()):
|
||||||
|
op.create_index(_IDX, "message",
|
||||||
|
["owner_account_id", "talker_id", "timestamp"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_index(op.get_bind()):
|
||||||
|
op.drop_index(_IDX, table_name="message")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""customer_account_link 账号↔客户多对多关联表
|
||||||
|
|
||||||
|
Revision ID: f7a8b9c0d1e2
|
||||||
|
Revises: e6f7a8b9c0d1
|
||||||
|
Create Date: 2026-09-08
|
||||||
|
|
||||||
|
内容
|
||||||
|
----
|
||||||
|
customer 表 PK=friend_id 全局一客户一行、归属=最后抓取员工(last-wins)。
|
||||||
|
共享客户(同一人加多个员工)在镜像里只挂在最后抓的员工名下——实测 Lily
|
||||||
|
云客好友 36,镜像员工客户页只有 12,其余 24 个全在另外 9 个员工名下
|
||||||
|
(2026-09-08 用户报「好友数与点进去的数量不一致」真机探明)。
|
||||||
|
|
||||||
|
云客口径是**每个账号各算一个**(per-employee 列表连备注/收发计数都按
|
||||||
|
关系各一份)。本表每个「渠道×账号×客户」一行:
|
||||||
|
|
||||||
|
- 写:repos.upsert_customer 随档案同事务维护(所有好友抓取路径一处挂全);
|
||||||
|
- 读:/api/customers?owner_account_id= 改读本表(员工维度列表/计数),
|
||||||
|
与云客 contacts_num 完全对齐;
|
||||||
|
- 对账:scrape_friends 逐员工整批 upsert 后删除该员工名下不在最新名单
|
||||||
|
里的 link(云客 isDel=0 返回当前好友,删链=已不是好友)。
|
||||||
|
|
||||||
|
**上线后需重跑 friends + wa-friends 各一次回填 link**(server:
|
||||||
|
`docker compose exec backend python scripts/scrape_via_page.py --entity friends`)
|
||||||
|
——存量关系无法从单行档案反推。
|
||||||
|
|
||||||
|
幂等:sa.inspect 先查表存在则跳过。SQLite/MySQL 通用。
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f7a8b9c0d1e2'
|
||||||
|
down_revision: str | Sequence[str] | None = 'e6f7a8b9c0d1'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_TABLE = 'customer_account_link'
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table: str) -> bool:
|
||||||
|
insp = sa.inspect(op.get_bind())
|
||||||
|
return table in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
if _has_table(_TABLE):
|
||||||
|
return # 已建过(或先手工建过),幂等跳过
|
||||||
|
op.create_table(
|
||||||
|
_TABLE,
|
||||||
|
sa.Column('channel', sa.String(16), primary_key=True),
|
||||||
|
sa.Column('owner_account_id', sa.String(128), primary_key=True),
|
||||||
|
sa.Column('friend_id', sa.String(128), primary_key=True),
|
||||||
|
sa.Column('friend_nickname', sa.String(255), nullable=True),
|
||||||
|
sa.Column('friend_remark', sa.String(255), nullable=True),
|
||||||
|
sa.Column('head_image_url', sa.String(1024), nullable=True),
|
||||||
|
sa.Column('real_name', sa.String(64), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(64), nullable=True),
|
||||||
|
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('add_time', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('send_num', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('receive_num', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('message_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('from_type', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('delete_flag', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('fetched_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
if not _has_table(_TABLE):
|
||||||
|
return
|
||||||
|
op.drop_table(_TABLE)
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""从群消息派生 group_member(发言者=成员)。
|
"""从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
|
||||||
|
|
||||||
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
|
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
|
||||||
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其
|
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
|
||||||
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。
|
sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
|
||||||
|
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
|
||||||
|
|
||||||
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为,
|
未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
|
||||||
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。
|
命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
|
||||||
|
|
||||||
|
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
|
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
|
||||||
@@ -26,13 +29,14 @@ from app.storage.repos import _upsert_one
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("backfill_group_members")
|
log = logging.getLogger("backfill_group_members")
|
||||||
|
|
||||||
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的
|
# row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
|
||||||
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用
|
# MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
|
||||||
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为
|
# 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
|
||||||
SQL = sa_text("""
|
SQL = sa_text("""
|
||||||
SELECT room_id, talker_id, sender_name, last_ts, msg_count
|
SELECT room_id, talker_id, sender_name, sender_head_url, last_ts, msg_count
|
||||||
FROM (
|
FROM (
|
||||||
SELECT room_id, talker_id, sender_name, timestamp AS last_ts,
|
SELECT room_id, talker_id, sender_name, sender_head_url,
|
||||||
|
timestamp AS last_ts,
|
||||||
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
|
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
|
||||||
row_number() OVER (
|
row_number() OVER (
|
||||||
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
|
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
|
||||||
@@ -44,6 +48,17 @@ FROM (
|
|||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 未发言成员兜底:member_id 命中 customer(本司好友)时补 NULL 的昵称/头像。
|
||||||
|
# customer 全局一人一行(PK=friend_id),join 不放大行数;COALESCE 只补不清。
|
||||||
|
CUSTOMER_FALLBACK = sa_text("""
|
||||||
|
UPDATE group_member gm
|
||||||
|
JOIN customer c ON c.friend_id = gm.member_id AND c.channel = gm.channel
|
||||||
|
SET gm.nickname = COALESCE(gm.nickname, c.friend_nickname),
|
||||||
|
gm.avatar = COALESCE(gm.avatar, c.head_image_url)
|
||||||
|
WHERE gm.channel = 'wechat'
|
||||||
|
AND (gm.nickname IS NULL OR gm.avatar IS NULL)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
@@ -51,12 +66,13 @@ async def main() -> None:
|
|||||||
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
|
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
for room_id, talker_id, sender_name, last_ts, msg_count in rows:
|
for room_id, talker_id, sender_name, head_url, last_ts, msg_count in rows:
|
||||||
await _upsert_one(session, GroupMember, {
|
await _upsert_one(session, GroupMember, {
|
||||||
"channel": "wechat",
|
"channel": "wechat",
|
||||||
"room_id": room_id,
|
"room_id": room_id,
|
||||||
"member_id": talker_id,
|
"member_id": talker_id,
|
||||||
"nickname": sender_name,
|
"nickname": sender_name,
|
||||||
|
"avatar": head_url,
|
||||||
# raw_json 附加派生统计,抽屉可见
|
# raw_json 附加派生统计,抽屉可见
|
||||||
"raw_json": {
|
"raw_json": {
|
||||||
"derived_from": "group_messages",
|
"derived_from": "group_messages",
|
||||||
@@ -65,7 +81,13 @@ async def main() -> None:
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
log.info(f"DONE: group_member upserted={len(rows)}")
|
|
||||||
|
# 兜底补未发言成员(独立事务,幂等)
|
||||||
|
async with async_engine.begin() as conn:
|
||||||
|
res = await conn.execute(CUSTOMER_FALLBACK)
|
||||||
|
log.info(f"customer 兜底补全 {res.rowcount} 行昵称/头像")
|
||||||
|
|
||||||
|
log.info("DONE: group_member upsert 完成")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ echo "$(date) --- 5/10 WeChat groups ---"
|
|||||||
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
$PY scripts/scrape_via_page.py --entity groups --max-employees 100
|
||||||
echo "$(date) --- 5b/10 WA groups ---"
|
echo "$(date) --- 5b/10 WA groups ---"
|
||||||
$PY scripts/scrape_via_page.py --entity wa-groups
|
$PY scripts/scrape_via_page.py --entity wa-groups
|
||||||
|
# 5c: 微信群统计(getChatroomList,云客「微信群统计」同款)——全窗刷档案列
|
||||||
|
# (真成员数/群备注/员工信息)+ 昨日窗落成员进出日行(页面窗内新增/退出)
|
||||||
|
echo "$(date) --- 5c/10 WeChat chatroom stats ---"
|
||||||
|
$PY scripts/scrape_via_page.py --entity chatroom-stats
|
||||||
|
YDAY=$(date -d "yesterday" +%F)
|
||||||
|
$PY scripts/scrape_via_page.py --entity chatroom-stats --day "$YDAY"
|
||||||
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
echo "$(date) --- 6/10 Voice calls (WeChat + WA) ---"
|
||||||
$PY scripts/scrape_via_page.py --entity voice-calls
|
$PY scripts/scrape_via_page.py --entity voice-calls
|
||||||
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
|
# 语音重扫会把 record_media_hash 覆盖回 None —— 必须紧跟录音重链接
|
||||||
@@ -45,30 +51,70 @@ echo "$(date) --- 9c/10 Moments (员工朋友圈) ---"
|
|||||||
$PY scripts/scrape_via_page.py --entity moments
|
$PY scripts/scrape_via_page.py --entity moments
|
||||||
echo "$(date) --- 10/10 Media download (audio+images) ---"
|
echo "$(date) --- 10/10 Media download (audio+images) ---"
|
||||||
$PY scripts/scrape_via_page.py --entity media
|
$PY scripts/scrape_via_page.py --entity media
|
||||||
|
# 11:会话日汇总全量重建(夜间自愈:绕过 upsert 钩子的写入由它纠正;
|
||||||
|
# sessions/staff-chat-stats 读路径依赖此表,2026-09-03)
|
||||||
|
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
|
||||||
|
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
|
||||||
|
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
|
||||||
|
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
|
||||||
|
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
|
||||||
|
$PY -m app.sync.cli first-reply-facts
|
||||||
|
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
|
||||||
|
# 算法各自直算,放 fact 后保持派生链顺序语义)
|
||||||
|
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
|
||||||
|
$PY -m app.sync.cli employee-metrics
|
||||||
|
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo "$(date) === FULL RE-SYNC COMPLETE ==="
|
echo "$(date) === FULL RE-SYNC COMPLETE ==="
|
||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
|
|
||||||
$PY -c "
|
# 终局数据快照:走 app 引擎(DB_URL 可移植,SQLite/MySQL 通吃;原 sqlite3
|
||||||
import sqlite3
|
# 直连 smoke.db 的写法在服务器 MySQL 上会炸,2026-09-03 修)
|
||||||
con = sqlite3.connect('smoke.db')
|
$PY - <<'PYEOF'
|
||||||
c = con.cursor()
|
import asyncio
|
||||||
print()
|
|
||||||
print('=== FINAL DATA SUMMARY ===')
|
from sqlalchemy import text
|
||||||
for label, sql in [
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
('wechat accounts', \"SELECT COUNT(*) FROM wechat_account\"),
|
|
||||||
('whatsapp accounts', \"SELECT COUNT(*) FROM whatsapp_account\"),
|
from app.config import get_settings
|
||||||
('wechat customers', \"SELECT COUNT(*) FROM customer WHERE channel='wechat'\"),
|
|
||||||
('whatsapp customers', \"SELECT COUNT(*) FROM customer WHERE channel='whatsapp'\"),
|
TABLES = [
|
||||||
('wechat groups', \"SELECT COUNT(*) FROM chat_group WHERE channel='wechat'\"),
|
("wechat accounts", "SELECT COUNT(*) FROM wechat_account"),
|
||||||
('whatsapp groups', \"SELECT COUNT(*) FROM chat_group WHERE channel='whatsapp'\"),
|
("whatsapp accounts", "SELECT COUNT(*) FROM whatsapp_account"),
|
||||||
('wechat messages', \"SELECT COUNT(*) FROM message WHERE channel='wechat'\"),
|
("wechat customers", "SELECT COUNT(*) FROM customer WHERE channel='wechat'"),
|
||||||
('whatsapp messages', \"SELECT COUNT(*) FROM message WHERE channel='whatsapp'\"),
|
("whatsapp customers", "SELECT COUNT(*) FROM customer WHERE channel='whatsapp'"),
|
||||||
('voice calls', \"SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')\"),
|
("wechat groups", "SELECT COUNT(*) FROM chat_group WHERE channel='wechat'"),
|
||||||
('voice clips', 'SELECT COUNT(*) FROM voice_clip'),
|
("whatsapp groups", "SELECT COUNT(*) FROM chat_group WHERE channel='whatsapp'"),
|
||||||
('moments posts', 'SELECT COUNT(*) FROM moments_post'),
|
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
|
||||||
('media files', 'SELECT COUNT(*) FROM media'),
|
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
|
||||||
]:
|
("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"),
|
||||||
print(f' {label:22s} {c.execute(sql).fetchone()[0]:>6}')
|
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
|
||||||
"
|
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
|
||||||
|
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
|
||||||
|
("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
|
||||||
|
("voice clips", "SELECT COUNT(*) FROM voice_clip"),
|
||||||
|
("moments posts", "SELECT COUNT(*) FROM moments_post"),
|
||||||
|
("media files", "SELECT COUNT(*) FROM media"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
engine = create_async_engine(get_settings().db_url)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
print()
|
||||||
|
print("=== FINAL DATA SUMMARY ===")
|
||||||
|
for label, sql in TABLES:
|
||||||
|
n = (await conn.execute(text(sql))).scalar_one()
|
||||||
|
print(f" {label:22s} {n:>6}")
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
PYEOF
|
||||||
|
|||||||
@@ -267,10 +267,10 @@ async def scrape_friends(cookie_str: str, max_employees: int = 100):
|
|||||||
小漂移可忽略,且与云客后台「按员工看客户列表」口径一致。员工清单来自
|
小漂移可忽略,且与云客后台「按员工看客户列表」口径一致。员工清单来自
|
||||||
本地 wechat_account(user_id 列)。
|
本地 wechat_account(user_id 列)。
|
||||||
"""
|
"""
|
||||||
from app.db.base import async_engine
|
from app.db.base import async_engine, async_session_maker
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
from app.scraper.wechat import _normalize
|
from app.scraper.wechat import _normalize
|
||||||
from app.storage.repos import upsert_customer
|
from app.storage.repos import prune_customer_links, upsert_customer
|
||||||
|
|
||||||
dept = await _root_dept_id()
|
dept = await _root_dept_id()
|
||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
@@ -334,9 +334,21 @@ async def scrape_friends(cookie_str: str, max_employees: int = 100):
|
|||||||
if records:
|
if records:
|
||||||
await _upsert_all(upsert_customer, records)
|
await _upsert_all(upsert_customer, records)
|
||||||
total_upserted += len(records)
|
total_upserted += len(records)
|
||||||
|
# 对账(2026-09-08):云客 isDel=0 返回当前好友;名单取满(>=声明
|
||||||
|
# total)才可信,删掉该账号名下已不在名单里的 link(转移/删好友)。
|
||||||
|
# 半份名单宁可不对账,防误删。
|
||||||
|
pruned = 0
|
||||||
|
if declared is not None and len(rows) >= declared:
|
||||||
|
keep_ids = [r.get("friendWxId") for r in rows if r.get("friendWxId")]
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
pruned = await prune_customer_links(
|
||||||
|
session, channel="wechat", owner_account_id=wx_id,
|
||||||
|
keep_friend_ids=keep_ids,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
f" [{i+1}/{len(employees)}] {wx_id[:16]}..: declared={declared} "
|
f" [{i+1}/{len(employees)}] {wx_id[:16]}..: declared={declared} "
|
||||||
f"rows={len(rows)} (total fetched {total_fetched})"
|
f"rows={len(rows)} pruned={pruned} (total fetched {total_fetched})"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(2 + (i % 3))
|
await asyncio.sleep(2 + (i % 3))
|
||||||
|
|
||||||
@@ -502,7 +514,50 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
|
|||||||
await sleep(1)
|
await sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]:
|
# ---------------------------------------------------------------------------
|
||||||
|
# 空对话/空群「已扫」记忆(2026-09-10 同步审计):从未有消息的对话(纯语音/
|
||||||
|
# 未聊过客户,WA 实测 2,468 个)水位恒 0,每轮都被当 unscanned 全量翻史
|
||||||
|
# (~2.6s/个)——时长黑洞本体。拉到 0 条有效消息即记 sync_state 扫描标记;
|
||||||
|
# 发现层排除「已扫空且不活跃」的——客户一旦开始发消息(last_chat_time 进
|
||||||
|
# 活跃窗)自动回到访问集;周日 full_history 强制全访问兜底(云客补历史)。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_scan_empty(kind: str, scope: str) -> None:
|
||||||
|
"""记录「该对话/群已扫且无消息」。UPDATE 0 行则 INSERT(sync_state 无唯一键)。"""
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
from app.db.base import async_engine
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
now_ms = int(_t.time() * 1000)
|
||||||
|
async with async_engine.begin() as conn:
|
||||||
|
res = await conn.execute(sa_text(
|
||||||
|
"UPDATE sync_state SET last_timestamp = :ts "
|
||||||
|
"WHERE entity_type = :et AND scope_id = :sid"
|
||||||
|
), {"et": kind, "sid": scope, "ts": now_ms})
|
||||||
|
if not res.rowcount:
|
||||||
|
await conn.execute(sa_text(
|
||||||
|
"INSERT INTO sync_state (entity_type, channel, scope_id, last_timestamp) "
|
||||||
|
"VALUES (:et, NULL, :sid, :ts)"
|
||||||
|
), {"et": kind, "sid": scope, "ts": now_ms})
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_scan_empty(kind: str) -> set[str]:
|
||||||
|
from app.db.base import async_engine
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
rows = (await conn.execute(sa_text(
|
||||||
|
"SELECT scope_id FROM sync_state WHERE entity_type = :et"
|
||||||
|
), {"et": kind})).fetchall()
|
||||||
|
return {r[0] for r in rows if r[0]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover_conversations(
|
||||||
|
channel: str, voice_channel: str, active_since_ms: int | None = None,
|
||||||
|
*, exclude_scanned_empty: bool = True,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
"""Conversation-pair discovery for the message scrapers (wechat + WA).
|
"""Conversation-pair discovery for the message scrapers (wechat + WA).
|
||||||
|
|
||||||
Sources (union, dedup):
|
Sources (union, dedup):
|
||||||
@@ -515,6 +570,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
|
|
||||||
Order: pairs with no messages yet FIRST (new coverage survives an
|
Order: pairs with no messages yet FIRST (new coverage survives an
|
||||||
interruption), then the rest by customer last_chat_time DESC.
|
interruption), then the rest by customer last_chat_time DESC.
|
||||||
|
|
||||||
|
``active_since_ms``(2026-09-10 调度根治):4h 增量模式传「now-24h」——
|
||||||
|
**已扫过**的对话仅保留 last_chat_time 在窗内的(活跃的),陈旧对话本轮
|
||||||
|
跳过(没有新消息,每日全量兜底);**从未扫过**的(fresh)无论新旧全保
|
||||||
|
(新覆盖优先原则不变)。None=不过滤(全量模式)。
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -524,14 +584,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
cust = (
|
cust = (
|
||||||
await conn.execute(sa_text(
|
await conn.execute(sa_text(
|
||||||
"SELECT owner_account_id, friend_id FROM customer "
|
"SELECT owner_account_id, friend_id, last_chat_time FROM customer "
|
||||||
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
|
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
|
||||||
"ORDER BY last_chat_time DESC"
|
"ORDER BY last_chat_time DESC"
|
||||||
), {"ch": channel})
|
), {"ch": channel})
|
||||||
).fetchall()
|
).fetchall()
|
||||||
voice = (
|
voice = (
|
||||||
await conn.execute(sa_text(
|
await conn.execute(sa_text(
|
||||||
"SELECT raw_json FROM phone_call WHERE channel=:vc"
|
"SELECT raw_json, start_call_time FROM phone_call WHERE channel=:vc"
|
||||||
), {"vc": voice_channel})
|
), {"vc": voice_channel})
|
||||||
).fetchall()
|
).fetchall()
|
||||||
with_msgs = (
|
with_msgs = (
|
||||||
@@ -542,20 +602,45 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
have = {(r[0], r[1]) for r in with_msgs}
|
have = {(r[0], r[1]) for r in with_msgs}
|
||||||
|
scanned_empty = (
|
||||||
|
await _load_scan_empty(f"{channel}_conv_empty") if exclude_scanned_empty else set()
|
||||||
|
)
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
fresh: list[tuple[str, str]] = []
|
fresh: list[tuple[str, str]] = []
|
||||||
known: list[tuple[str, str]] = []
|
known: list[tuple[str, str]] = []
|
||||||
|
|
||||||
def _add(key: tuple[str, str]) -> None:
|
def _active(last_ts: int | None) -> bool:
|
||||||
|
"""窗口判定:未开窗口恒真;有窗时 last_ts 缺失(从未聊过)也保(fresh)。"""
|
||||||
|
if active_since_ms is None:
|
||||||
|
return True
|
||||||
|
return last_ts is None or last_ts >= active_since_ms
|
||||||
|
|
||||||
|
def _add(key: tuple[str, str], last_ts: int | None) -> None:
|
||||||
if key in seen:
|
if key in seen:
|
||||||
return
|
return
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
(fresh if key not in have else known).append(key)
|
if key not in have:
|
||||||
|
# 从未扫过:新覆盖优先,不受窗口限制。但「已扫空且不活跃」的除外
|
||||||
|
# (2026-09-10:WA 2,468 个无消息对话每轮全翻史的黑洞)——一旦
|
||||||
|
# 活跃(last_chat_time 进窗)自动回到访问集;full_history 强制全访。
|
||||||
|
scope = f"{key[0]}:{key[1]}"
|
||||||
|
if (
|
||||||
|
exclude_scanned_empty
|
||||||
|
and scope in scanned_empty
|
||||||
|
and active_since_ms is not None
|
||||||
|
and not _active(last_ts)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
fresh.append(key)
|
||||||
|
elif _active(last_ts):
|
||||||
|
known.append(key)
|
||||||
|
# else: 已扫过且陈旧 → 本轮跳过
|
||||||
|
|
||||||
for owner, friend in cust:
|
for owner, friend, last_ts in cust:
|
||||||
_add((owner, friend))
|
_add((owner, friend), last_ts)
|
||||||
voice_only = 0
|
voice_only = 0
|
||||||
for (rj,) in voice:
|
cust_keys = {(r[0], r[1]) for r in cust}
|
||||||
|
for rj, call_ts in voice:
|
||||||
try:
|
try:
|
||||||
d = _json.loads(rj)
|
d = _json.loads(rj)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -564,13 +649,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
|
|||||||
if not owner or not talker or str(talker).endswith("@chatroom"):
|
if not owner or not talker or str(talker).endswith("@chatroom"):
|
||||||
continue
|
continue
|
||||||
before = len(seen)
|
before = len(seen)
|
||||||
_add((str(owner), str(talker)))
|
_add((str(owner), str(talker)), call_ts)
|
||||||
if len(seen) > before and (str(owner), str(talker)) not in cust:
|
if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
|
||||||
voice_only += 1
|
voice_only += 1
|
||||||
|
|
||||||
|
skipped = len(cust) + voice_only - len(fresh) - len(known)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
|
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
|
||||||
f"{len(fresh)} unscanned (first) / {len(known)} already have messages"
|
f"{len(fresh)} unscanned (first) / {len(known)} active in window"
|
||||||
|
+ (f" / {skipped} stale skipped" if active_since_ms is not None else f" / {len(known)} already have messages")
|
||||||
)
|
)
|
||||||
return fresh + known
|
return fresh + known
|
||||||
|
|
||||||
@@ -623,7 +710,7 @@ def _page_min_ts(data: dict) -> int:
|
|||||||
return min(ts_list) if ts_list else 0
|
return min(ts_list) if ts_list else 0
|
||||||
|
|
||||||
|
|
||||||
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
|
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
|
||||||
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
|
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
|
||||||
|
|
||||||
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
|
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
|
||||||
@@ -636,7 +723,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
|||||||
from app.db.base import async_session_maker
|
from app.db.base import async_session_maker
|
||||||
from app.storage.repos import upsert_messages
|
from app.storage.repos import upsert_messages
|
||||||
|
|
||||||
conversations = await _discover_conversations("wechat", "voice_call")
|
conversations = await _discover_conversations(
|
||||||
|
"wechat", "voice_call",
|
||||||
|
active_since_ms=(
|
||||||
|
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||||
|
if active_window_hours else None
|
||||||
|
),
|
||||||
|
# full_history(周日对齐)强制全访问:云客可能补出历史消息
|
||||||
|
exclude_scanned_empty=not full_history,
|
||||||
|
)
|
||||||
watermarks = await _conversation_watermarks("wechat")
|
watermarks = await _conversation_watermarks("wechat")
|
||||||
cust_activity = await _customer_activity("wechat")
|
cust_activity = await _customer_activity("wechat")
|
||||||
|
|
||||||
@@ -725,6 +820,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
|||||||
ts = int(str(ts_raw)) if ts_raw else None
|
ts = int(str(ts_raw)) if ts_raw else None
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
ts = None
|
ts = None
|
||||||
|
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
|
||||||
|
if watermark and ts is not None and ts <= watermark:
|
||||||
|
continue
|
||||||
records.append({
|
records.append({
|
||||||
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
|
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
|
||||||
"channel": "wechat",
|
"channel": "wechat",
|
||||||
@@ -774,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
total_fetched += len(records)
|
total_fetched += len(records)
|
||||||
|
if not records:
|
||||||
|
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
|
||||||
total_pages += pages
|
total_pages += pages
|
||||||
|
|
||||||
if records:
|
if records:
|
||||||
@@ -841,7 +941,7 @@ def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | N
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
|
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
|
||||||
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
|
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
|
||||||
|
|
||||||
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
|
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
|
||||||
@@ -856,12 +956,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
|||||||
from app.storage.repos import upsert_messages
|
from app.storage.repos import upsert_messages
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
active_since_ms = (
|
||||||
|
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||||
|
if active_window_hours else None
|
||||||
|
)
|
||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
result = await conn.execute(sa_text(
|
result = await conn.execute(sa_text(
|
||||||
"SELECT owner_account_id, room_id FROM chat_group "
|
"SELECT owner_account_id, room_id FROM chat_group "
|
||||||
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
|
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
|
||||||
|
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t) "
|
||||||
"ORDER BY last_chat_time DESC"
|
"ORDER BY last_chat_time DESC"
|
||||||
))
|
), {"t": active_since_ms})
|
||||||
groups = [(row[0], row[1]) for row in result.fetchall()]
|
groups = [(row[0], row[1]) for row in result.fetchall()]
|
||||||
|
|
||||||
room_wms = await _room_watermarks()
|
room_wms = await _room_watermarks()
|
||||||
@@ -922,6 +1027,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
|||||||
continue
|
continue
|
||||||
rec = _normalize_group_message(row, owner_id, room_id)
|
rec = _normalize_group_message(row, owner_id, room_id)
|
||||||
if rec is not None:
|
if rec is not None:
|
||||||
|
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
|
||||||
|
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||||
|
continue
|
||||||
records.append(rec)
|
records.append(rec)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1131,10 +1239,12 @@ async def _browser_fetch(page, path: str, json_body: dict | None = None, form_bo
|
|||||||
"""POST from inside the browser context (proper cookies/referrer => no 风控).
|
"""POST from inside the browser context (proper cookies/referrer => no 风控).
|
||||||
|
|
||||||
``json_body`` sends a JSON body; ``form_body`` sends form-urlencoded.
|
``json_body`` sends a JSON body; ``form_body`` sends form-urlencoded.
|
||||||
Returns the parsed JSON response.
|
Returns the parsed JSON response. 鉴权失败信封(云客 cookie 过期)在此
|
||||||
|
统一拦截抛 :class:`CookieExpiredError`(2026-09-03:过期时 WA 路径对
|
||||||
|
str data 调 .get 崩 AttributeError、微信路径静默当空数据 55 员工 0 行)。
|
||||||
"""
|
"""
|
||||||
if json_body is not None:
|
if json_body is not None:
|
||||||
return await page.evaluate(
|
resp = await page.evaluate(
|
||||||
"""async ([path, body]) => {
|
"""async ([path, body]) => {
|
||||||
const resp = await fetch(path, {
|
const resp = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1145,19 +1255,128 @@ async def _browser_fetch(page, path: str, json_body: dict | None = None, form_bo
|
|||||||
}""",
|
}""",
|
||||||
[path, json_body],
|
[path, json_body],
|
||||||
)
|
)
|
||||||
return await page.evaluate(
|
else:
|
||||||
"""async ([path, params]) => {
|
resp = await page.evaluate(
|
||||||
const ps = new URLSearchParams();
|
"""async ([path, params]) => {
|
||||||
for (const [k, v] of Object.entries(params)) ps.append(k, String(v));
|
const ps = new URLSearchParams();
|
||||||
const resp = await fetch(path, {
|
for (const [k, v] of Object.entries(params)) ps.append(k, String(v));
|
||||||
method: 'POST',
|
const resp = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
method: 'POST',
|
||||||
body: ps.toString(),
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
});
|
body: ps.toString(),
|
||||||
return await resp.json();
|
});
|
||||||
}""",
|
return await resp.json();
|
||||||
[path, form_body or {}],
|
}""",
|
||||||
)
|
[path, form_body or {}],
|
||||||
|
)
|
||||||
|
return _check_auth_envelope(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieExpiredError(RuntimeError):
|
||||||
|
"""云客会话过期(鉴权失败信封)。调用方应中止本轮同步,提示重新登录换 cookie。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth_envelope(resp):
|
||||||
|
"""鉴权失败信封 → CookieExpiredError;其余响应原样返回。
|
||||||
|
|
||||||
|
云客过期时的实测信封(code 时而 str 时而 int):
|
||||||
|
``{"code":"-20020","data":"账号认证已过期…","message":"鉴权失败…","success":false}``
|
||||||
|
业务错误(如 -90000)不是鉴权问题,照旧放行由调用方语义处理。
|
||||||
|
"""
|
||||||
|
if isinstance(resp, dict) and resp.get("success") is False:
|
||||||
|
code = str(resp.get("code", ""))
|
||||||
|
if code in {"-20020", "20020"}:
|
||||||
|
raise CookieExpiredError(
|
||||||
|
f"云客 cookie 已过期(code={code}:{resp.get('message')}),"
|
||||||
|
"需重新登录换取后更新 YK_COOKIE"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ── cookie 自动续期(2026-09-03)────────────────────────────────────────── #
|
||||||
|
# 缓存文件优先于 compose 注入的 YK_COOKIE(自动续期成功时写入,永远比 env 新);
|
||||||
|
# 实体撞 CookieExpiredError → 浏览器表单重登 → 落缓存 → 整实体重跑一次
|
||||||
|
# (upsert 幂等 + 水位增量,重跑安全);二次过期直接抛出,绝不循环。
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_cache_path():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
override = os.environ.get("YK_COOKIE_CACHE")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
return Path(get_settings().media_dir) / ".yk_cookie_cache"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cookie() -> str:
|
||||||
|
"""缓存文件 > YK_COOKIE env > settings(.env)。空串=三者皆无。"""
|
||||||
|
try:
|
||||||
|
cached = _cookie_cache_path().read_text(encoding="utf-8").strip()
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
cookie = os.environ.get("YK_COOKIE", "")
|
||||||
|
if not cookie:
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
cookie = get_settings().yk_cookie
|
||||||
|
return cookie or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cookie_cache(cookie: str) -> None:
|
||||||
|
"""尽力而为持久化:路径不可写(本地无挂载)只告警不炸。"""
|
||||||
|
try:
|
||||||
|
p = _cookie_cache_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(cookie, encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(f"cookie 缓存写入失败({exc}),新 cookie 仅本次进程生效")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_with_auto_relogin(entity, cookie: str, *, relogin=None):
|
||||||
|
"""跑一个 entity(cookie 参数化);撞过期自动重登换 cookie 重跑一次。"""
|
||||||
|
if relogin is None:
|
||||||
|
relogin = _relogin
|
||||||
|
try:
|
||||||
|
return await entity(cookie)
|
||||||
|
except CookieExpiredError:
|
||||||
|
logger.warning("cookie 过期 → 浏览器表单自动重登…")
|
||||||
|
new_cookie = await relogin()
|
||||||
|
_save_cookie_cache(new_cookie)
|
||||||
|
logger.info("自动续期成功,新 cookie 已缓存;重跑本实体")
|
||||||
|
return await entity(new_cookie)
|
||||||
|
|
||||||
|
|
||||||
|
async def _relogin() -> str:
|
||||||
|
"""浏览器表单登录云客换取新 cookie(2026-09-03 实测一次成功)。
|
||||||
|
|
||||||
|
选择器来自真实页面:tab 文本「密码登录」、placeholder 请输入手机号/
|
||||||
|
请输入密码、按钮「登 录」;成功判据 = URL 跳到 /cms/home/* 且
|
||||||
|
document.cookie 出现 userToken。
|
||||||
|
"""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
if not s.yunke_username or not s.yunke_password:
|
||||||
|
raise CookieExpiredError("自动重登失败:未配置 YUNKE_USERNAME/YUNKE_PASSWORD")
|
||||||
|
try:
|
||||||
|
async with PageScraper("") as scraper:
|
||||||
|
page = await scraper.context.new_page()
|
||||||
|
await page.goto(f"{YUNKE_BASE}/cms/auth/login", wait_until="networkidle", timeout=30000)
|
||||||
|
await page.click("text=密码登录")
|
||||||
|
await page.fill('input[placeholder="请输入手机号"]', s.yunke_username)
|
||||||
|
await page.fill('input[placeholder="请输入密码"]', s.yunke_password)
|
||||||
|
await page.click('button:has-text("登 录")')
|
||||||
|
await page.wait_for_url("**/cms/home/**", timeout=20000)
|
||||||
|
cookie = await page.evaluate("() => document.cookie")
|
||||||
|
except Exception as exc:
|
||||||
|
raise CookieExpiredError(f"自动重登失败:{exc}") from exc
|
||||||
|
if "userToken=" not in cookie:
|
||||||
|
raise CookieExpiredError("自动重登失败:登录后未见 userToken cookie")
|
||||||
|
return cookie
|
||||||
|
|
||||||
|
|
||||||
def _rows_flat(body: dict) -> list[dict]:
|
def _rows_flat(body: dict) -> list[dict]:
|
||||||
@@ -1352,10 +1571,10 @@ async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
|
|||||||
全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000,
|
全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000,
|
||||||
2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是
|
2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是
|
||||||
``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。"""
|
``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。"""
|
||||||
from app.db.base import async_engine
|
from app.db.base import async_engine, async_session_maker
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
from app.scraper.whatsapp import _normalize_whatsapp_friend
|
from app.scraper.whatsapp import _normalize_whatsapp_friend
|
||||||
from app.storage.repos import upsert_customer
|
from app.storage.repos import prune_customer_links, upsert_customer
|
||||||
|
|
||||||
dept = await _root_dept_id()
|
dept = await _root_dept_id()
|
||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
@@ -1409,8 +1628,20 @@ async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
|
|||||||
if records:
|
if records:
|
||||||
await _upsert_all(upsert_customer, records)
|
await _upsert_all(upsert_customer, records)
|
||||||
total_upserted += len(records)
|
total_upserted += len(records)
|
||||||
|
# 对账(2026-09-08,同微信 friends):名单取满才删该账号名下
|
||||||
|
# 不在当前列表里的 link。
|
||||||
|
pruned = 0
|
||||||
|
if declared is not None and len(rows) >= declared:
|
||||||
|
keep_ids = [r.get("friendUid") for r in rows if r.get("friendUid")]
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
pruned = await prune_customer_links(
|
||||||
|
session, channel="whatsapp", owner_account_id=wa_uid,
|
||||||
|
keep_friend_ids=keep_ids,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} rows={len(rows)}"
|
f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} "
|
||||||
|
f"rows={len(rows)} pruned={pruned}"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(2 + (i % 3))
|
await asyncio.sleep(2 + (i % 3))
|
||||||
|
|
||||||
@@ -1496,6 +1727,200 @@ async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
|
|||||||
return total_fetched, total_upserted
|
return total_fetched, total_upserted
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 微信群统计 getChatroomList(2026-09-09 真机探明,云客同款数据源)
|
||||||
|
#
|
||||||
|
# 云客「沟通→微信号管理→微信群统计」页(/cms/wechat/wechatgroup)数据源:
|
||||||
|
# POST /wechatweb/chatroom/getChatroomList(form-urlencoded)
|
||||||
|
# departmentId + startTime/endTime('YYYY-MM-DD HH:MM:SS' 时间窗) + 分页;
|
||||||
|
# 信封 data.overviews[] + totalCount(1,854 行,员工×群维度)。
|
||||||
|
# 行含:真 memberCount(群成员数量——friend_list/es 的 friendNum 其实是
|
||||||
|
# 员工好友数,旧映射存反了)、remark(群备注)、员工微信(wechatId=微信号
|
||||||
|
# alias/wechatNickName/头像)、员工信息(userName/userPhone)、以及**窗内**
|
||||||
|
# addMembers/lossMembers(新增/退出成员数,任意窗实时算)。
|
||||||
|
#
|
||||||
|
# 本地无成员进出事件流 → add/loss 只能按天积累:每夜抓「当日窗」落
|
||||||
|
# chat_group_member_stats 日行,页面任意日窗=按天求和(历史无数据)。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHATROOM_LIST_PATH = "/wechatweb/chatroom/getChatroomList"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
|
||||||
|
"""getChatroomList 行 → ChatGroup 补列记录。
|
||||||
|
|
||||||
|
只放该端点有的字段——未出现的列不参与 upsert 的 UPDATE 子句,
|
||||||
|
friend_list/es 抓的群主信息等旧值得以保留。
|
||||||
|
"""
|
||||||
|
rec = {
|
||||||
|
"room_id": row.get("chatRoomId"),
|
||||||
|
"channel": "wechat",
|
||||||
|
"owner_account_id": owner_wxid,
|
||||||
|
"room_name": row.get("chatRoomName"),
|
||||||
|
# 真群成员数(修正:旧源 friendNum 是「员工好友数」被误存此列)
|
||||||
|
"member_count": row.get("memberCount"),
|
||||||
|
"head_image_url": row.get("headImageUrl"),
|
||||||
|
"owner_account_nickname": row.get("wechatNickName"),
|
||||||
|
"owner_user_id": row.get("userId"),
|
||||||
|
"owner_user_phone": row.get("userPhone"),
|
||||||
|
"message_count": row.get("messageCount"),
|
||||||
|
"own_message_count": row.get("ownMessageCount"),
|
||||||
|
"add_members": row.get("addMembers"), # 本窗快照(日窗=当日进出)
|
||||||
|
"loss_members": row.get("lossMembers"),
|
||||||
|
"last_chat_time": row.get("lastChatTime"),
|
||||||
|
"update_time": row.get("updateTime"),
|
||||||
|
"raw_json": row,
|
||||||
|
}
|
||||||
|
# 群备注:getChatroomList 行通常无 remark 键(云客页面也显示 -);有才写,
|
||||||
|
# 无则不放该键——upsert 不更新未出现的列,friend_list/es 来源的
|
||||||
|
# friendWxRemark 备注得以保留
|
||||||
|
if row.get("remark"):
|
||||||
|
rec["group_remark"] = row["remark"]
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
async def _chatroom_rows(
|
||||||
|
page, dept: str, start_str: str, end_str: str, max_pages: int = 100
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
|
||||||
|
rows_all: list[dict] = []
|
||||||
|
declared = 0
|
||||||
|
page_num = 1
|
||||||
|
while page_num <= max_pages:
|
||||||
|
body = await _browser_fetch(
|
||||||
|
page, CHATROOM_LIST_PATH,
|
||||||
|
form_body={
|
||||||
|
"departmentId": dept, "startTime": start_str, "endTime": end_str,
|
||||||
|
"pageNum": page_num, "pageSize": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = body.get("data") or {}
|
||||||
|
rows = data.get("overviews") or []
|
||||||
|
rows_all.extend(rows)
|
||||||
|
declared = data.get("totalCount") or declared
|
||||||
|
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
|
||||||
|
if not rows or page_num * 50 >= (declared or 0):
|
||||||
|
break
|
||||||
|
page_num += 1
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
return rows_all, declared or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_alias_map(conn):
|
||||||
|
"""wechat_account 的 alias/nick_name → wx_id 映射。
|
||||||
|
|
||||||
|
getChatroomList 行的 wechatId 是员工**微信号**(alias,如 Nancyjade)
|
||||||
|
而非 wxId,owner 归属需经此解析;miss 时回退存原值(不影响其余列)。
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
result = await conn.execute(sa_text(
|
||||||
|
"SELECT alias, nick_name, wx_id FROM wechat_account "
|
||||||
|
"WHERE wx_id IS NOT NULL AND wx_id != ''"
|
||||||
|
))
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for alias, nick, wx in result.fetchall():
|
||||||
|
if alias:
|
||||||
|
mapping[str(alias)] = wx
|
||||||
|
if nick:
|
||||||
|
mapping.setdefault(str(nick), wx)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
||||||
|
"""getChatroomList 抓取(云客微信群统计同款)。
|
||||||
|
|
||||||
|
* ``day=None``:全窗(2020 起)——chat_group 档案补列基线(真成员数/
|
||||||
|
群备注/员工信息列;add/loss 存全窗快照);
|
||||||
|
* ``day='YYYY-MM-DD'``:单日窗——落 chat_group_member_stats 当日行
|
||||||
|
(页面任意日窗的新增/退出=按天求和)。
|
||||||
|
"""
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
from app.db.base import async_engine
|
||||||
|
from app.storage.repos import upsert_chat_group, upsert_chat_group_member_stats
|
||||||
|
|
||||||
|
dept = await _root_dept_id()
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
alias_map = await _owner_alias_map(conn)
|
||||||
|
|
||||||
|
if day:
|
||||||
|
start_str, end_str = f"{day} 00:00:00", f"{day} 23:59:59"
|
||||||
|
day_key = int(day.replace("-", ""))
|
||||||
|
else:
|
||||||
|
start_str = "2020-01-01 00:00:00"
|
||||||
|
end_str = f"{_dt.date.today()} 23:59:59"
|
||||||
|
day_key = None
|
||||||
|
|
||||||
|
group_recs: list[dict] = []
|
||||||
|
stats_recs: list[dict] = []
|
||||||
|
declared = 0
|
||||||
|
async with PageScraper(cookie_str) as scraper:
|
||||||
|
page = await _open_fetch_page(scraper)
|
||||||
|
rows, declared = await _chatroom_rows(page, dept, start_str, end_str)
|
||||||
|
for row in rows:
|
||||||
|
rid = row.get("chatRoomId")
|
||||||
|
if not rid:
|
||||||
|
continue
|
||||||
|
owner_wxid = (
|
||||||
|
alias_map.get(str(row.get("wechatId") or ""))
|
||||||
|
or alias_map.get(str(row.get("wechatNickName") or ""))
|
||||||
|
or str(row.get("wechatId") or "")
|
||||||
|
)
|
||||||
|
group_recs.append(_normalize_chatroom_stat_row(row, owner_wxid))
|
||||||
|
if day_key is not None:
|
||||||
|
stats_recs.append({
|
||||||
|
"channel": "wechat",
|
||||||
|
"owner_account_id": owner_wxid,
|
||||||
|
"room_id": rid,
|
||||||
|
"day": day_key,
|
||||||
|
"added": row.get("addMembers") or 0,
|
||||||
|
"lost": row.get("lossMembers") or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if group_recs:
|
||||||
|
await _upsert_all(upsert_chat_group, group_recs)
|
||||||
|
if stats_recs:
|
||||||
|
await _upsert_all(upsert_chat_group_member_stats, stats_recs)
|
||||||
|
|
||||||
|
# 对账(2026-09-10,全窗模式):删掉本地有而云客名单里没有的 chat_group 行
|
||||||
|
# ——friend_list/es 时代的残留行(owner 组合已不在 getChatroomList 返回,
|
||||||
|
# 实测多 148 行致页脚与云客不一致)。名单取满(>=声明 total)才删,防短页
|
||||||
|
# 误删;只动 wechat(WA 群另一来源);GroupMember 按 room 共享不动。
|
||||||
|
pruned = 0
|
||||||
|
if day_key is None and group_recs and declared and len(group_recs) >= declared:
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.db.base import async_session_maker
|
||||||
|
from app.db.models import ChatGroup
|
||||||
|
|
||||||
|
keep = {(r["owner_account_id"], r["room_id"]) for r in group_recs}
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
local = (await session.execute(
|
||||||
|
sa.select(ChatGroup.owner_account_id, ChatGroup.room_id)
|
||||||
|
.where(ChatGroup.channel == "wechat")
|
||||||
|
)).all()
|
||||||
|
stale = [(o, r) for (o, r) in local if (o, r) not in keep]
|
||||||
|
for owner, room in stale:
|
||||||
|
# 逐行删(差集常态 0~几行;不用 tuple IN——asyncmy 对其参数
|
||||||
|
# 形态挑剔,实测 TypeError)
|
||||||
|
await session.execute(
|
||||||
|
sa.delete(ChatGroup).where(
|
||||||
|
ChatGroup.channel == "wechat",
|
||||||
|
ChatGroup.owner_account_id == owner,
|
||||||
|
ChatGroup.room_id == room,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pruned = len(stale)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
|
||||||
|
f"stats_rows={len(stats_recs)} pruned={pruned}"
|
||||||
|
)
|
||||||
|
return len(group_recs), len(stats_recs)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 员工朋友圈 Moments (browser fetch, per employee)
|
# 员工朋友圈 Moments (browser fetch, per employee)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1851,7 +2276,7 @@ async def scrape_voice_clips(cookie_str: str, max_employees: int = 200, page_siz
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
|
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
|
||||||
"""Scrape WhatsApp chat records via browser fetch POST
|
"""Scrape WhatsApp chat records via browser fetch POST
|
||||||
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
|
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
|
||||||
|
|
||||||
@@ -1866,7 +2291,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
|||||||
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
||||||
from app.storage.repos import upsert_messages
|
from app.storage.repos import upsert_messages
|
||||||
|
|
||||||
conversations = await _discover_conversations("whatsapp", "wa_voice_call")
|
conversations = await _discover_conversations(
|
||||||
|
"whatsapp", "wa_voice_call",
|
||||||
|
active_since_ms=(
|
||||||
|
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||||
|
if active_window_hours else None
|
||||||
|
),
|
||||||
|
exclude_scanned_empty=not full_history,
|
||||||
|
)
|
||||||
watermarks = await _conversation_watermarks("whatsapp")
|
watermarks = await _conversation_watermarks("whatsapp")
|
||||||
cust_activity = await _customer_activity("whatsapp")
|
cust_activity = await _customer_activity("whatsapp")
|
||||||
|
|
||||||
@@ -1912,7 +2344,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
|||||||
msgs = data.get("messages") or []
|
msgs = data.get("messages") or []
|
||||||
for row in msgs:
|
for row in msgs:
|
||||||
if isinstance(row, dict) and not _is_wa_divider(row):
|
if isinstance(row, dict) and not _is_wa_divider(row):
|
||||||
records.append(_normalize_whatsapp_message(row))
|
rec = _normalize_whatsapp_message(row)
|
||||||
|
# 增量模式过滤边界旧消息:云客 start 参数是 inclusive
|
||||||
|
# (返回 ts>=start 含边界),不过滤则每对话白拉数条已
|
||||||
|
# 入库旧消息(2026-09-10 实测一夜 fetched 53,682 仅
|
||||||
|
# 321 条真新,99% 白写)。
|
||||||
|
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||||
|
continue
|
||||||
|
records.append(rec)
|
||||||
if not data.get("hasLast"):
|
if not data.get("hasLast"):
|
||||||
break
|
break
|
||||||
# 增量触底停:翻到已同步区
|
# 增量触底停:翻到已同步区
|
||||||
@@ -1943,6 +2382,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
total_fetched += len(records)
|
total_fetched += len(records)
|
||||||
|
if not records:
|
||||||
|
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
|
||||||
if records:
|
if records:
|
||||||
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
|
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
|
||||||
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
|
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
|
||||||
@@ -1989,7 +2430,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
|
|||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
|
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
|
||||||
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
|
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
|
||||||
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
|
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
|
||||||
|
|
||||||
@@ -2003,11 +2444,16 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
|
|||||||
from app.storage.repos import upsert_messages
|
from app.storage.repos import upsert_messages
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
active_since_ms = (
|
||||||
|
int((__import__("time").time() - active_window_hours * 3600) * 1000)
|
||||||
|
if active_window_hours else None
|
||||||
|
)
|
||||||
async with async_engine.connect() as conn:
|
async with async_engine.connect() as conn:
|
||||||
result = await conn.execute(sa_text(
|
result = await conn.execute(sa_text(
|
||||||
"SELECT owner_account_id, room_id FROM chat_group "
|
"SELECT owner_account_id, room_id FROM chat_group "
|
||||||
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL"
|
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL "
|
||||||
))
|
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t)"
|
||||||
|
), {"t": active_since_ms})
|
||||||
groups = [(row[0], row[1]) for row in result.fetchall()]
|
groups = [(row[0], row[1]) for row in result.fetchall()]
|
||||||
|
|
||||||
room_wms = await _room_watermarks()
|
room_wms = await _room_watermarks()
|
||||||
@@ -2051,7 +2497,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if not isinstance(row, dict) or _is_wa_divider(row):
|
if not isinstance(row, dict) or _is_wa_divider(row):
|
||||||
continue
|
continue
|
||||||
records.append(_normalize_wa_group_message(row))
|
rec = _normalize_wa_group_message(row)
|
||||||
|
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
|
||||||
|
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||||
|
continue
|
||||||
|
records.append(rec)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
|
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
|
||||||
@@ -2166,6 +2616,7 @@ async def main():
|
|||||||
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
||||||
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
||||||
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
|
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
|
||||||
|
"chatroom-stats", # 微信群统计 (getChatroomList 带窗,云客同款)
|
||||||
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
||||||
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
||||||
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
||||||
@@ -2180,27 +2631,34 @@ async def main():
|
|||||||
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
|
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
|
||||||
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
||||||
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
|
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
|
||||||
|
parser.add_argument("--active-window-hours", type=int, default=None,
|
||||||
|
help="只扫近 N 小时活跃的对话(4h 增量模式传 24;未扫过的对话不受限;默认不过滤=全量发现)")
|
||||||
|
parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cookie = os.environ.get("YK_COOKIE", "")
|
cookie = _load_cookie()
|
||||||
if not cookie:
|
|
||||||
# Try reading from .env
|
|
||||||
from app.config import get_settings
|
|
||||||
cookie = get_settings().yk_cookie
|
|
||||||
if not cookie:
|
if not cookie:
|
||||||
logger.error("YK_COOKIE not set in .env or environment")
|
logger.error("YK_COOKIE not set in .env or environment")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Starting page-based scrape: entity={args.entity}")
|
logger.info(f"Starting page-based scrape: entity={args.entity}")
|
||||||
|
|
||||||
|
async def _run(c: str) -> None:
|
||||||
|
await _dispatch_entity(args, c)
|
||||||
|
|
||||||
|
# cookie 过期自动续期:重登→落缓存→整实体重跑(2026-09-03)
|
||||||
|
await _run_with_auto_relogin(_run, cookie)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_entity(args, cookie: str) -> None:
|
||||||
if args.entity == "friends":
|
if args.entity == "friends":
|
||||||
await scrape_friends(cookie, args.max_employees)
|
await scrape_friends(cookie, args.max_employees)
|
||||||
elif args.entity == "groups":
|
elif args.entity == "groups":
|
||||||
await scrape_groups(cookie, args.max_employees)
|
await scrape_groups(cookie, args.max_employees)
|
||||||
elif args.entity == "messages":
|
elif args.entity == "messages":
|
||||||
await scrape_messages(cookie, args.max_conversations, args.full_history)
|
await scrape_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||||
elif args.entity == "group-messages":
|
elif args.entity == "group-messages":
|
||||||
await scrape_group_messages(cookie, args.max_conversations, args.full_history)
|
await scrape_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||||
elif args.entity == "accounts":
|
elif args.entity == "accounts":
|
||||||
await scrape_accounts(cookie, args.max_pages)
|
await scrape_accounts(cookie, args.max_pages)
|
||||||
elif args.entity == "wa-accounts":
|
elif args.entity == "wa-accounts":
|
||||||
@@ -2209,14 +2667,16 @@ async def main():
|
|||||||
await scrape_wa_friends(cookie, args.max_pages)
|
await scrape_wa_friends(cookie, args.max_pages)
|
||||||
elif args.entity == "wa-groups":
|
elif args.entity == "wa-groups":
|
||||||
await scrape_wa_groups(cookie, args.max_pages)
|
await scrape_wa_groups(cookie, args.max_pages)
|
||||||
|
elif args.entity == "chatroom-stats":
|
||||||
|
await scrape_chatroom_stats(cookie, args.day)
|
||||||
elif args.entity == "voice-calls":
|
elif args.entity == "voice-calls":
|
||||||
await scrape_voice_calls(cookie, args.max_pages)
|
await scrape_voice_calls(cookie, args.max_pages)
|
||||||
elif args.entity == "voice-clips":
|
elif args.entity == "voice-clips":
|
||||||
await scrape_voice_clips(cookie, args.max_employees)
|
await scrape_voice_clips(cookie, args.max_employees)
|
||||||
elif args.entity == "wa-messages":
|
elif args.entity == "wa-messages":
|
||||||
await scrape_wa_messages(cookie, args.max_conversations, args.full_history)
|
await scrape_wa_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||||
elif args.entity == "wa-group-messages":
|
elif args.entity == "wa-group-messages":
|
||||||
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
|
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
|
||||||
elif args.entity == "moments":
|
elif args.entity == "moments":
|
||||||
await scrape_moments(cookie, args.max_pages)
|
await scrape_moments(cookie, args.max_pages)
|
||||||
elif args.entity == "media":
|
elif args.entity == "media":
|
||||||
@@ -2224,4 +2684,9 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except CookieExpiredError as exc:
|
||||||
|
# 一句话可读 + 专用退出码,调度器/脚本日志第一眼就能定位;不再输出天书堆栈
|
||||||
|
print(f"[cookie-expired] {exc}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
|||||||
@@ -112,3 +112,22 @@ async def make_client() -> AsyncIterator[
|
|||||||
await client.aclose()
|
await client.aclose()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def make_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""独立内存 SQLite AsyncSession(不挂 API 依赖)——存储层/管理域测试用。
|
||||||
|
|
||||||
|
与 make_client 共用 StaticPool 单连接模式;每个测试独享全新 schema。
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
yield s
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -148,3 +148,113 @@ async def test_q_escapes_like_wildcards(make_client):
|
|||||||
# 只命中字面含 "100%" 的行;不因 % 通配漏扩到 "1001"
|
# 只命中字面含 "100%" 的行;不因 % 通配漏扩到 "1001"
|
||||||
assert body["total"] == 1
|
assert body["total"] == 1
|
||||||
assert [it["friend_id"] for it in body["items"]] == ["wx_pct"]
|
assert [it["friend_id"] for it in body["items"]] == ["wx_pct"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# owner 维度读 customer_account_link(共享好友,2026-09-08)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _seed_links(session):
|
||||||
|
from app.db.models import CustomerAccountLink as L
|
||||||
|
|
||||||
|
# 档案路径的旧行:共享好友 f_shared 档案 last-wins 挂在 wx_b 名下
|
||||||
|
session.add_all([
|
||||||
|
L(channel="wechat", owner_account_id="wx_a", friend_id="f_shared",
|
||||||
|
friend_remark="A的备注", friend_nickname="共享客户", last_chat_time=2000,
|
||||||
|
send_num=3, receive_num=1, from_type=1),
|
||||||
|
L(channel="wechat", owner_account_id="wx_b", friend_id="f_shared",
|
||||||
|
friend_remark="B的备注", friend_nickname="共享客户", last_chat_time=1000),
|
||||||
|
L(channel="wechat", owner_account_id="wx_a", friend_id="f_only_a",
|
||||||
|
friend_nickname="A独有", last_chat_time=3000),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owner_filter_reads_links(make_client):
|
||||||
|
"""owner 过滤走 link:共享好友在两个员工名下各出现,备注各是各的。"""
|
||||||
|
client = await make_client(_seed_links)
|
||||||
|
r_a = await client.get("/api/customers", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_a"})
|
||||||
|
assert r_a.status_code == 200
|
||||||
|
body_a = r_a.json()
|
||||||
|
assert body_a["total"] == 2
|
||||||
|
by_id = {it["friend_id"]: it for it in body_a["items"]}
|
||||||
|
# 排序:last_chat_time desc
|
||||||
|
assert [it["friend_id"] for it in body_a["items"]] == ["f_only_a", "f_shared"]
|
||||||
|
assert by_id["f_shared"]["friend_remark"] == "A的备注"
|
||||||
|
assert by_id["f_shared"]["send_num"] == 3
|
||||||
|
assert by_id["f_shared"]["from_type"] == 1
|
||||||
|
|
||||||
|
r_b = await client.get("/api/customers", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_b"})
|
||||||
|
body_b = r_b.json()
|
||||||
|
assert body_b["total"] == 1
|
||||||
|
assert body_b["items"][0]["friend_remark"] == "B的备注"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owner_filter_q_and_friend_id_on_links(make_client):
|
||||||
|
"""link 路径的 q 四列搜索 + friend_id 精确查找(聊天窗名字解析用)。"""
|
||||||
|
client = await make_client(_seed_links)
|
||||||
|
r = await client.get("/api/customers", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_a", "q": "A的备注"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert [it["friend_id"] for it in body["items"]] == ["f_shared"]
|
||||||
|
|
||||||
|
r2 = await client.get("/api/customers", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_a", "friend_id": "f_shared"})
|
||||||
|
body2 = r2.json()
|
||||||
|
assert body2["total"] == 1
|
||||||
|
assert body2["items"][0]["friend_nickname"] == "共享客户"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owner_filter_with_dept_falls_back_to_archive(make_client):
|
||||||
|
"""owner+department 组合回退档案路径(旧行为;link 不解析部门)。"""
|
||||||
|
from app.db.models import Customer as C, Staff as S
|
||||||
|
|
||||||
|
async def seed(session):
|
||||||
|
await _seed_links(session)
|
||||||
|
session.add(S(user_id="u1", name="员工", dept_id="d1"))
|
||||||
|
session.add(C(channel="wechat", owner_account_id="wx_a",
|
||||||
|
friend_id="f_archived", user_id="u1"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/customers", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_a", "department_id": "d1"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# 档案路径:只命中 user_id 属于 d1 的档案行(link 行不带 user_id)
|
||||||
|
assert [it["friend_id"] for it in body["items"]] == ["f_archived"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owner_link_path_carries_archive_raw_json(make_client):
|
||||||
|
"""link 路径按 friend_id 带出档案 raw_json(2026-09-09 WA 列表修复)。
|
||||||
|
|
||||||
|
WA 客户列表大量列只存在于 raw_json(userName/salePhone/createTime 等),
|
||||||
|
link 快照没存——不带 raw_json 时整表列空白。
|
||||||
|
"""
|
||||||
|
from app.db.models import Customer as C, CustomerAccountLink as L
|
||||||
|
|
||||||
|
async def seed(session):
|
||||||
|
session.add_all([
|
||||||
|
# 档案行挂在别的员工名下(共享),raw_json 里带员工侧 camelCase 字段
|
||||||
|
C(channel="whatsapp", friend_id="f1@s.whatsapp.net",
|
||||||
|
owner_account_id="wx_other", friend_nickname="Nick",
|
||||||
|
raw_json={"userName": "员工WA名", "salePhone": "13800000000",
|
||||||
|
"uid": "15600000000@s.whatsapp.net", "createTime": 1700000000000}),
|
||||||
|
L(channel="whatsapp", owner_account_id="wa_me",
|
||||||
|
friend_id="f1@s.whatsapp.net", friend_nickname="Nick", last_chat_time=100),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/customers", params={
|
||||||
|
"channel": "whatsapp", "owner_account_id": "wa_me"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
it = body["items"][0]
|
||||||
|
assert it["friend_nickname"] == "Nick" # link 快照
|
||||||
|
assert it["raw_json"]["userName"] == "员工WA名" # 档案 raw_json 带出
|
||||||
|
assert it["raw_json"]["salePhone"] == "13800000000"
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。
|
"""Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。
|
||||||
|
|
||||||
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||||
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
||||||
|
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.routers.export import EXPORT_MAX_ROWS
|
from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
|
||||||
from app.db.models import Message
|
from app.db.models import Customer, Message
|
||||||
|
|
||||||
|
|
||||||
async def _seed(session: AsyncSession) -> None:
|
async def _seed(session: AsyncSession) -> None:
|
||||||
@@ -160,3 +163,91 @@ async def test_export_group_ignores_owner_mismatch(make_client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "群消息1" in r.text
|
assert "群消息1" in r.text
|
||||||
assert "群消息2" in r.text
|
assert "群消息2" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _seed_zip(session: AsyncSession) -> None:
|
||||||
|
"""两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。"""
|
||||||
|
session.add_all([
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1",
|
||||||
|
friend_nickname="小蜜", friend_remark="蜜姐"),
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2",
|
||||||
|
friend_nickname="老王"),
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3",
|
||||||
|
friend_nickname='坏/名字<>:"|'),
|
||||||
|
Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f1", room_id=None, text="f1你好", timestamp=1000,
|
||||||
|
is_send=False, type="text", type_raw=1),
|
||||||
|
Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f2", room_id=None, text="f2在吗", timestamp=2000,
|
||||||
|
is_send=True, type="text", type_raw=1),
|
||||||
|
Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f2", room_id=None, text="f2更早", timestamp=500,
|
||||||
|
is_send=True, type="text", type_raw=1),
|
||||||
|
# f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除
|
||||||
|
Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f1", room_id="g@chatroom", text="群内消息不进zip",
|
||||||
|
timestamp=3000, is_send=True, type="text", type_raw=1),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_entries(r) -> dict[str, str]:
|
||||||
|
assert r.headers["content-type"].startswith("application/zip")
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
return {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_two_conversations_with_display_names(make_client):
|
||||||
|
"""zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f1,f2"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
entries = _zip_entries(r)
|
||||||
|
assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"]
|
||||||
|
assert "f1你好" in entries["01_蜜姐.html"]
|
||||||
|
assert "f2在吗" in entries["02_老王.html"]
|
||||||
|
# DM 查询排除 room 消息
|
||||||
|
assert "群内消息不进zip" not in "".join(entries.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_time_range_filter(make_client):
|
||||||
|
"""start/end 同样作用于 zip 内每个会话。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f2", "start": 1000})
|
||||||
|
assert r.status_code == 200
|
||||||
|
entries = _zip_entries(r)
|
||||||
|
assert "f2在吗" in entries["01_老王.html"]
|
||||||
|
assert "f2更早" not in entries["01_老王.html"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_filename_sanitized_and_fallback(make_client):
|
||||||
|
"""非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f3,f_unknown"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = sorted(_zip_entries(r))
|
||||||
|
assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0]
|
||||||
|
assert names[1] == "02_f_unknown.html"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_rejects_empty_and_over_limit(make_client):
|
||||||
|
"""空列表/超上限 422,防误传。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r_empty = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "})
|
||||||
|
assert r_empty.status_code == 422
|
||||||
|
many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1))
|
||||||
|
r_many = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many})
|
||||||
|
assert r_many.status_code == 422
|
||||||
|
|||||||
@@ -64,17 +64,19 @@ async def _seed(session: AsyncSession) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_default_list_returns_all_ordered_desc(make_client):
|
async def test_default_list_returns_all_ordered_desc(make_client):
|
||||||
"""No filters -> 3 groups, ordered by ``last_chat_time desc`` (nulls last)."""
|
"""No filters -> 3 groups;微信排序=owner 二进制字符串升序分组+room_id
|
||||||
|
升序(云客群统计同款,2026-09-10 终判:区分大小写,大写排小写前)。
|
||||||
|
owner 序:wa_owner_1 < wx_owner_1 < wx_owner_2 → [wa_room_x, room_a, room_b]。
|
||||||
|
"""
|
||||||
client = await make_client(_seed)
|
client = await make_client(_seed)
|
||||||
r = await client.get("/api/groups")
|
r = await client.get("/api/groups")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["total"] == 3
|
assert body["total"] == 3
|
||||||
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
|
|
||||||
assert [it["room_id"] for it in body["items"]] == [
|
assert [it["room_id"] for it in body["items"]] == [
|
||||||
|
"wa_room_x@s.whatsapp.net",
|
||||||
"room_a@chatroom",
|
"room_a@chatroom",
|
||||||
"room_b@chatroom",
|
"room_b@chatroom",
|
||||||
"wa_room_x@s.whatsapp.net",
|
|
||||||
]
|
]
|
||||||
for key in (
|
for key in (
|
||||||
"room_id",
|
"room_id",
|
||||||
@@ -344,3 +346,79 @@ async def test_q_room_name_substring_filter(make_client):
|
|||||||
# 空串 -> 不过滤
|
# 空串 -> 不过滤
|
||||||
r3 = await client.get("/api/groups", params={"q": ""})
|
r3 = await client.get("/api/groups", params={"q": ""})
|
||||||
assert r3.json()["total"] == 3
|
assert r3.json()["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 微信群统计扩展(2026-09-09):统计时段窗聚合 + 员工列 + 群备注/最新消息筛选
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _seed_stat(session: AsyncSession) -> None:
|
||||||
|
from app.db.models import ChatGroupMemberStats, MsgDailyStats, Staff, WechatAccount
|
||||||
|
|
||||||
|
session.add_all([
|
||||||
|
WechatAccount(wx_id="wx_owner_1", nick_name="玉", alias="Nancyjade",
|
||||||
|
user_id="u_wx_1", raw_json={"headImageUrl": "http://a/h.png"}),
|
||||||
|
Staff(user_id="u_wx_1", name="聂玉婷", phone="18500000000"),
|
||||||
|
ChatGroup(room_id="stat_room@chatroom", channel="wechat", room_name="统计群",
|
||||||
|
owner_account_id="wx_owner_1", owner_user_id="u_wx_1",
|
||||||
|
member_count=46, group_remark="VIP群", last_chat_time=5000),
|
||||||
|
# 窗内两天日行(0907+0908):沟通 3+4=7 条
|
||||||
|
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||||
|
peer_id="stat_room@chatroom", day=20260907, send_cnt=1, recv_cnt=2),
|
||||||
|
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||||
|
peer_id="stat_room@chatroom", day=20260908, send_cnt=2, recv_cnt=2),
|
||||||
|
# 窗外日行(0901)不应计入
|
||||||
|
MsgDailyStats(channel="wechat", owner_account_id="wx_owner_1", peer_kind="room",
|
||||||
|
peer_id="stat_room@chatroom", day=20260901, send_cnt=99, recv_cnt=99),
|
||||||
|
# 窗内进出:0907 +2/-1
|
||||||
|
ChatGroupMemberStats(channel="wechat", owner_account_id="wx_owner_1",
|
||||||
|
room_id="stat_room@chatroom", day=20260907, added=2, lost=1),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_window_aggregates_and_staff_columns(make_client):
|
||||||
|
"""msg_start/msg_end 窗内三聚合 + 员工微信/员工信息关联列。"""
|
||||||
|
client = await make_client(_seed_stat)
|
||||||
|
# 2026-09-07 ~ 2026-09-09(上海日 20260907..20260909,UTC ms)
|
||||||
|
r = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "room_id": "stat_room@chatroom",
|
||||||
|
"msg_start": 1788758400000, "msg_end": 1789017600000,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
it = r.json()["items"][0]
|
||||||
|
assert it["window_msg_count"] == 7 # 窗内日行求和,0901 排除
|
||||||
|
assert it["window_added"] == 2
|
||||||
|
assert it["window_lost"] == 1
|
||||||
|
assert it["group_remark"] == "VIP群"
|
||||||
|
assert it["staff_nickname"] == "玉"
|
||||||
|
assert it["staff_wx_alias"] == "Nancyjade"
|
||||||
|
assert it["staff_avatar"] == "http://a/h.png"
|
||||||
|
assert it["staff_name"] == "聂玉婷"
|
||||||
|
assert it["staff_phone"] == "18500000000"
|
||||||
|
|
||||||
|
# 无窗请求 → 聚合三列为 None(不误显 0)
|
||||||
|
r2 = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "room_id": "stat_room@chatroom"})
|
||||||
|
it2 = r2.json()["items"][0]
|
||||||
|
assert it2["window_msg_count"] is None
|
||||||
|
assert it2["window_added"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_remark_and_last_window_filters(make_client):
|
||||||
|
"""群备注子串 + 最新消息时间段过滤。"""
|
||||||
|
client = await make_client(_seed_stat)
|
||||||
|
r = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "group_remark": "VIP"})
|
||||||
|
assert [i["room_id"] for i in r.json()["items"]] == ["stat_room@chatroom"]
|
||||||
|
r2 = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "group_remark": "不存在"})
|
||||||
|
assert r2.json()["total"] == 0
|
||||||
|
|
||||||
|
# last_chat_time=5000 → last_start=4000 命中,last_start=6000 排除
|
||||||
|
r3 = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "last_start": 4000, "last_end": 9999})
|
||||||
|
assert "stat_room@chatroom" in [i["room_id"] for i in r3.json()["items"]]
|
||||||
|
r4 = await client.get("/api/groups", params={
|
||||||
|
"channel": "wechat", "last_start": 6000, "last_end": 9999})
|
||||||
|
assert r4.json()["total"] == 0
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Tests for ``POST /api/auth/login`` (username/password → shared API token).
|
||||||
|
|
||||||
|
Behavior pinned here:
|
||||||
|
|
||||||
|
* Correct credentials -> ``200 {"token": <api_token>}`` (the SPA then uses it
|
||||||
|
as the Bearer token for every subsequent call).
|
||||||
|
* Wrong username or password -> ``401`` with a generic message (no user
|
||||||
|
enumeration — the same detail for both cases).
|
||||||
|
* The login endpoint itself is exempt from the shared-token middleware —
|
||||||
|
obviously, the caller doesn't have a token yet.
|
||||||
|
* Brute-force guard: the 5th consecutive failure from one IP locks that IP
|
||||||
|
out for 15 minutes (``429`` even with correct credentials); a successful
|
||||||
|
login resets the failure counter.
|
||||||
|
* Works with auth disabled (empty ``API_TOKEN``) -> returns an empty token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api import auth as auth_module
|
||||||
|
from app.api.main import app
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def login_client(monkeypatch):
|
||||||
|
"""In-process client with a clean settings/login state per test."""
|
||||||
|
monkeypatch.delenv("API_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("ADMIN_USER", raising=False)
|
||||||
|
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
failures = getattr(auth_module, "_login_failures", None)
|
||||||
|
if failures is not None:
|
||||||
|
failures.clear()
|
||||||
|
ac = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||||
|
await ac.__aenter__()
|
||||||
|
yield ac
|
||||||
|
await ac.aclose()
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_correct_credentials_returns_token(login_client, monkeypatch):
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"token": "test-secret"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_wrong_password_returns_401(login_client, monkeypatch):
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "nope"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "用户名或密码错误"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_unknown_user_same_error_as_wrong_password(
|
||||||
|
login_client, monkeypatch
|
||||||
|
):
|
||||||
|
"""No user enumeration: unknown user and wrong password read identically."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "nobody", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "用户名或密码错误"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_credentials_come_from_env(login_client, monkeypatch):
|
||||||
|
"""ADMIN_USER / ADMIN_PASSWORD override the defaults."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
monkeypatch.setenv("ADMIN_USER", "boss")
|
||||||
|
monkeypatch.setenv("ADMIN_PASSWORD", "boss-pw")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "boss", "password": "boss-pw"}
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
legacy = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert legacy.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_auth_disabled_returns_empty_token(login_client):
|
||||||
|
"""Empty API_TOKEN (auth off) -> login still succeeds with an empty token."""
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"token": ""}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lockout_after_five_consecutive_failures(login_client, monkeypatch):
|
||||||
|
"""5 failures from one IP -> 6th attempt is 429 even with correct creds."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
for _ in range(5):
|
||||||
|
r = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
locked = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert locked.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
async def test_successful_login_resets_failure_counter(login_client, monkeypatch):
|
||||||
|
"""4 failures + a success + 4 more failures -> still not locked."""
|
||||||
|
monkeypatch.setenv("API_TOKEN", "s")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
for _ in range(4):
|
||||||
|
await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
for _ in range(4):
|
||||||
|
await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
||||||
|
)
|
||||||
|
still_ok = await login_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
||||||
|
)
|
||||||
|
assert still_ok.status_code == 200
|
||||||
@@ -476,3 +476,162 @@ async def test_msg_type_and_q_combine(make_client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert [it["msg_id"] for it in body["items"]] == ["ty_transfer"]
|
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"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import ChatGroup, Customer, Message
|
from app.db.models import ChatGroup, Customer, CustomerAccountLink, Message
|
||||||
|
|
||||||
OWNER = "wx_owner_s"
|
OWNER = "wx_owner_s"
|
||||||
CH = "wechat"
|
CH = "wechat"
|
||||||
@@ -45,6 +45,40 @@ async def test_sessions_friend_and_group(make_client):
|
|||||||
assert g["name"] == "群一号" and g["today_count"] == 1
|
assert g["name"] == "群一号" and g["today_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_shared_friend_name_from_link(make_client):
|
||||||
|
"""共享好友昵称读 link 快照(2026-09-09):档案行挂在别的员工名下时,
|
||||||
|
会话列表此前回退显示 wxid;link 表每账号一行,回填后都有昵称。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def seed(session: AsyncSession) -> None:
|
||||||
|
session.add_all([
|
||||||
|
# OWNER 与共享好友 t_shared 有会话
|
||||||
|
Message(msg_id="sh1", channel=CH, owner_account_id=OWNER, talker_id="t_shared",
|
||||||
|
room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"),
|
||||||
|
# 档案行(last-wins)挂在别的员工名下——按 owner 查档案必然查空
|
||||||
|
Customer(friend_id="t_shared", channel=CH,
|
||||||
|
owner_account_id="wx_other", friend_nickname="A**郑"),
|
||||||
|
# OWNER 名下的 per-owner link 快照
|
||||||
|
CustomerAccountLink(channel=CH, owner_account_id=OWNER, friend_id="t_shared",
|
||||||
|
friend_nickname="A**郑", friend_remark=None),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
|
||||||
|
assert r.status_code == 200
|
||||||
|
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
|
||||||
|
assert by["t_shared"]["name"] == "A**郑"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_name_falls_back_to_archive_without_link(make_client):
|
||||||
|
"""link 未覆盖的好友回退档案路径(空表过渡安全)。"""
|
||||||
|
client = await make_client(_seed)
|
||||||
|
r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
|
||||||
|
by = {s.get("talker_id"): s for s in r.json()["sessions"]}
|
||||||
|
assert by["t1"]["name"] == "Alice" # 档案行在 OWNER 名下
|
||||||
|
|
||||||
|
|
||||||
async def test_sessions_sorted_by_last_chat_time_desc(make_client):
|
async def test_sessions_sorted_by_last_chat_time_desc(make_client):
|
||||||
client = await make_client(_seed)
|
client = await make_client(_seed)
|
||||||
body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()
|
body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json()
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import time
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount
|
from app.db.models import (
|
||||||
|
ChatGroup,
|
||||||
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
|
Message,
|
||||||
|
PhoneCall,
|
||||||
|
WechatAccount,
|
||||||
|
)
|
||||||
|
|
||||||
OWNER = "wxid_o"
|
OWNER = "wxid_o"
|
||||||
UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId!
|
UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId!
|
||||||
@@ -39,6 +46,11 @@ async def _seed_counts(s: AsyncSession) -> None:
|
|||||||
# 删除/拉黑(现存量口径,忽略窗口)
|
# 删除/拉黑(现存量口径,忽略窗口)
|
||||||
s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1))
|
s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1))
|
||||||
s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True))
|
s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True))
|
||||||
|
# link 行(生产抓取经 upsert_customer 成对写;新客户/删除统计读 link)
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2"))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c9", delete_flag=1))
|
||||||
# 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径)
|
# 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径)
|
||||||
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
||||||
start_call_time=IN + 4, call_number="c1"))
|
start_call_time=IN + 4, call_number="c1"))
|
||||||
@@ -78,6 +90,9 @@ async def test_counts_default_window_last_7_days(make_client):
|
|||||||
s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now))
|
s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now))
|
||||||
s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER,
|
s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER,
|
||||||
add_time=now - 8 * DAY))
|
add_time=now - 8 * DAY))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_now", add_time=now))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_old",
|
||||||
|
add_time=now - 8 * DAY))
|
||||||
s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID,
|
s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID,
|
||||||
start_call_time=now, call_number="c_now"))
|
start_call_time=now, call_number="c_now"))
|
||||||
s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID,
|
s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID,
|
||||||
@@ -127,6 +142,12 @@ async def test_detail_new_customers_fields(make_client):
|
|||||||
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None,
|
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None,
|
||||||
friend_nickname="兜底客"))
|
friend_nickname="兜底客"))
|
||||||
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c1", add_time=IN,
|
||||||
|
friend_nickname="牛牛", friend_remark="大客户",
|
||||||
|
head_image_url="http://a/1.png"))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c2",
|
||||||
|
friend_nickname="兜底客"))
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c3", add_time=1_000))
|
||||||
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
||||||
timestamp=IN - 5))
|
timestamp=IN - 5))
|
||||||
await s.commit()
|
await s.commit()
|
||||||
@@ -238,6 +259,9 @@ async def test_detail_del_and_blocked_fields(make_client):
|
|||||||
s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER))
|
s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER))
|
||||||
s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other",
|
s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other",
|
||||||
delete_flag=1))
|
delete_flag=1))
|
||||||
|
# 删除读 link:c_del 补 link 行;is_block 保持档案(c_blk 不补)
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER, friend_id="c_del",
|
||||||
|
delete_flag=1, friend_nickname="已删客"))
|
||||||
s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER,
|
s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER,
|
||||||
delete_flag=0, is_block=False))
|
delete_flag=0, is_block=False))
|
||||||
await s.commit()
|
await s.commit()
|
||||||
@@ -394,3 +418,32 @@ async def test_new_customers_fallback_first_msg_before_window(make_client):
|
|||||||
assert r_count.status_code == r_detail.status_code == 200
|
assert r_count.status_code == r_detail.status_code == 200
|
||||||
assert r_count.json()["new_customers"] == 0
|
assert r_count.json()["new_customers"] == 0
|
||||||
assert r_detail.json()["rows"] == []
|
assert r_detail.json()["rows"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_new_customers_counts_shared_friend_via_link(make_client):
|
||||||
|
"""共享好友(档案挂别的员工名下)凭 link 行计入新客户(2026-09-09 口径修复)。
|
||||||
|
|
||||||
|
旧档案口径下 c_shared 对 OWNER 不可见(add_time 在窗内也不计);切 link
|
||||||
|
后按 per-owner 关系计数,与云客按员工统计一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def seed(s: AsyncSession) -> None:
|
||||||
|
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||||
|
# 档案行(last-wins)挂在别的员工名下
|
||||||
|
s.add(Customer(friend_id="c_shared", channel="wechat",
|
||||||
|
owner_account_id="wxid_other", add_time=IN))
|
||||||
|
# OWNER 名下的 link 关系
|
||||||
|
s.add(CustomerAccountLink(channel="wechat", owner_account_id=OWNER,
|
||||||
|
friend_id="c_shared", add_time=IN))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get(
|
||||||
|
"/api/staff-stats",
|
||||||
|
params={"wechat_id": OWNER, "kind": "new_customers",
|
||||||
|
"start": WIN_START, "end": WIN_END},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert [row["talker_id"] for row in body["rows"]] == ["c_shared"]
|
||||||
|
assert len(body["rows"]) == 1
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""sessions / staff-chat-stats 的汇总表读路径判别测试(2026-09-03)。
|
||||||
|
|
||||||
|
两个端点在 msg_daily_stats 有行时必须改读汇总表(毫秒级),空表时回退旧
|
||||||
|
实时聚合(部署回填前的守卫)。判别手法:**回填汇总后把 message 表删光**——
|
||||||
|
旧实现会返回空,只有真正读汇总表的实现还能吐出完整数据。
|
||||||
|
|
||||||
|
另配对拍测试:同一批种子,「无汇总(旧路径)」与「有汇总(新路径)」的
|
||||||
|
响应必须逐字段一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import ChatGroup, Customer, Message
|
||||||
|
from app.storage.summary import recompute_all
|
||||||
|
|
||||||
|
OWNER = "wx_owner_sum"
|
||||||
|
CH = "wechat"
|
||||||
|
_NOW = int(time.time() * 1000)
|
||||||
|
_MS_DAY = 86_400_000
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(session: AsyncSession) -> None:
|
||||||
|
session.add_all([
|
||||||
|
# t1:今天 1 发 1 收,2 天前还有一条
|
||||||
|
Message(msg_id="s1", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"),
|
||||||
|
Message(msg_id="s2", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 1000, type="text", type_raw=1, text="y"),
|
||||||
|
Message(msg_id="s3", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 2 * _MS_DAY, type="text", type_raw=1, text="old"),
|
||||||
|
# t2:只有 5 天前的消息(今日 0)
|
||||||
|
Message(msg_id="s4", channel=CH, owner_account_id=OWNER, talker_id="t2", room_id=None, is_send=True, timestamp=_NOW - 5 * _MS_DAY, type="text", type_raw=1, text="o2"),
|
||||||
|
# 群:今天 2 条
|
||||||
|
Message(msg_id="s5", channel=CH, owner_account_id=OWNER, talker_id="m1", room_id="room1@chatroom", is_send=True, timestamp=_NOW, type="text", type_raw=1, text="g1"),
|
||||||
|
Message(msg_id="s6", channel=CH, owner_account_id=OWNER, talker_id="m2", room_id="room1@chatroom", is_send=False, timestamp=_NOW - 2000, type="text", type_raw=1, text="g2"),
|
||||||
|
Customer(friend_id="t1", channel=CH, owner_account_id=OWNER, friend_nickname="Alice", friend_remark=None),
|
||||||
|
Customer(friend_id="t2", channel=CH, owner_account_id=OWNER, friend_nickname="Bob", friend_remark=None),
|
||||||
|
ChatGroup(room_id="room1@chatroom", channel=CH, owner_account_id=OWNER, room_name="群一号"),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_with_stats(session: AsyncSession) -> None:
|
||||||
|
await _seed(session)
|
||||||
|
await recompute_all(session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _stats_then_wipe(session: AsyncSession) -> None:
|
||||||
|
await _seed_with_stats(session)
|
||||||
|
await session.execute(sa.delete(Message))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── sessions ─────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_served_from_summary_when_populated(make_client):
|
||||||
|
"""回填后删光 message:响应仍完整 → 数据来自汇总表(旧实现此刻返回空)。"""
|
||||||
|
wiped = await make_client(_stats_then_wipe)
|
||||||
|
r = await wiped.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
by = {(s["type"], s.get("talker_id") or s.get("room_id")): s for s in body["sessions"]}
|
||||||
|
assert set(by) == {("friend", "t1"), ("friend", "t2"), ("group", "room1@chatroom")}
|
||||||
|
assert by[("friend", "t1")]["today_count"] == 2
|
||||||
|
assert by[("friend", "t1")]["last_chat_time"] == _NOW
|
||||||
|
assert by[("friend", "t2")]["today_count"] == 0
|
||||||
|
assert by[("group", "room1@chatroom")]["today_count"] == 2
|
||||||
|
assert by[("friend", "t1")]["name"] == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sessions_parity_and_legacy_guard(make_client):
|
||||||
|
"""对拍:无汇总(守卫回退旧路径)响应 == 有汇总(新路径)响应,且逐字段一致。"""
|
||||||
|
legacy = await make_client(_seed)
|
||||||
|
modern = await make_client(_seed_with_stats)
|
||||||
|
params = {"owner_account_id": OWNER, "channel": CH}
|
||||||
|
r1 = await legacy.get("/api/sessions", params=params)
|
||||||
|
r2 = await modern.get("/api/sessions", params=params)
|
||||||
|
assert r1.status_code == r2.status_code == 200
|
||||||
|
assert r1.json() == r2.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── staff-chat-stats ─────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staff_chat_stats_served_from_summary(make_client):
|
||||||
|
"""全时段窗口(旧实现实测 6.9s 的场景):回填后删光 message 仍出全量数字。"""
|
||||||
|
client = await make_client(_stats_then_wipe)
|
||||||
|
r = await client.get(
|
||||||
|
"/api/staff-chat-stats",
|
||||||
|
params={"owner_account_id": OWNER, "start": 0, "end": _NOW + _MS_DAY},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
by = {c["talker_id"]: c for c in body["customers"]}
|
||||||
|
assert set(by) == {"t1", "t2"} # 私聊口径,群不计
|
||||||
|
assert by["t1"]["send_count"] == 1 and by["t1"]["receive_count"] == 2
|
||||||
|
assert by["t1"]["last_time"] == _NOW
|
||||||
|
assert by["t2"]["send_count"] == 1 and by["t2"]["receive_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_staff_chat_stats_parity(make_client):
|
||||||
|
"""宽窗口:旧路径与汇总路径响应一致。"""
|
||||||
|
legacy = await make_client(_seed)
|
||||||
|
modern = await make_client(_seed_with_stats)
|
||||||
|
params = {"owner_account_id": OWNER, "start": 0, "end": _NOW + _MS_DAY}
|
||||||
|
r1 = await legacy.get("/api/staff-chat-stats", params=params)
|
||||||
|
r2 = await modern.get("/api/staff-chat-stats", params=params)
|
||||||
|
assert r1.status_code == r2.status_code == 200
|
||||||
|
assert r1.json() == r2.json()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""cookie 自动续期(2026-09-03):缓存优先级 + 撞过期自动重登重跑。
|
||||||
|
|
||||||
|
机制::func:`_load_cookie` 优先读缓存文件(自动续期成功时写入,
|
||||||
|
MEDIA_DIR/.yk_cookie_cache)——比 compose 注入的 YK_COOKIE 新;实体运行撞
|
||||||
|
:class:`CookieExpiredError` 时 :func:`_run_with_auto_relogin` 调浏览器表单
|
||||||
|
重登拿新 cookie、落缓存、整实体重跑一次(幂等,水位增量天然安全)。
|
||||||
|
二次过期不再重试(防死循环)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts import scrape_via_page as sv
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cache_file(tmp_path, monkeypatch):
|
||||||
|
"""把 cookie 缓存指到临时目录。"""
|
||||||
|
path = tmp_path / ".yk_cookie_cache"
|
||||||
|
monkeypatch.setenv("YK_COOKIE_CACHE", str(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── 缓存:读/写/优先级 ────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_cookie_prefers_cache_over_env(cache_file, monkeypatch):
|
||||||
|
monkeypatch.setenv("YK_COOKIE", "env-cookie")
|
||||||
|
cache_file.write_text("cache-cookie", encoding="utf-8")
|
||||||
|
assert sv._load_cookie() == "cache-cookie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_cookie_falls_back_to_env_when_no_cache(cache_file, monkeypatch):
|
||||||
|
monkeypatch.setenv("YK_COOKIE", "env-cookie")
|
||||||
|
assert sv._load_cookie() == "env-cookie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_then_load_roundtrip(cache_file):
|
||||||
|
sv._save_cookie_cache("fresh-cookie")
|
||||||
|
assert cache_file.read_text(encoding="utf-8").strip() == "fresh-cookie"
|
||||||
|
assert sv._load_cookie() == "fresh-cookie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_cache_unwritable_path_does_not_raise(monkeypatch):
|
||||||
|
# 持久化是尽力而为:路径不可写(本地无挂载等)不能炸,回退 env 语义仍在
|
||||||
|
monkeypatch.setenv("YK_COOKIE_CACHE", "/nonexistent-root/x/y/.cookie")
|
||||||
|
sv._save_cookie_cache("whatever") # 不应抛
|
||||||
|
|
||||||
|
|
||||||
|
# ── 撞过期自动重登重跑 ───────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retry_after_relogin(cache_file):
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def entity(cookie: str) -> str:
|
||||||
|
calls.append(cookie)
|
||||||
|
if cookie == "old":
|
||||||
|
raise sv.CookieExpiredError("云客 cookie 已过期")
|
||||||
|
return f"ok-with-{cookie}"
|
||||||
|
|
||||||
|
async def fake_relogin() -> str:
|
||||||
|
return "new"
|
||||||
|
|
||||||
|
result = await sv._run_with_auto_relogin(entity, "old", relogin=fake_relogin)
|
||||||
|
assert result == "ok-with-new"
|
||||||
|
assert calls == ["old", "new"] # 旧 cookie 失败一次,新 cookie 重跑成功
|
||||||
|
assert sv._load_cookie() == "new" # 新 cookie 已落缓存
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_relogin_when_other_error(cache_file):
|
||||||
|
async def entity(cookie: str):
|
||||||
|
raise RuntimeError("业务炸了")
|
||||||
|
|
||||||
|
async def fake_relogin() -> str:
|
||||||
|
raise AssertionError("不应触发重登")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="业务炸了"):
|
||||||
|
await sv._run_with_auto_relogin(entity, "old", relogin=fake_relogin)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_second_expiry_propagates_no_loop(cache_file):
|
||||||
|
"""重登后仍过期 → 直接抛出,绝不无限循环。"""
|
||||||
|
|
||||||
|
async def always_expired(cookie: str):
|
||||||
|
raise sv.CookieExpiredError("还是过期")
|
||||||
|
|
||||||
|
async def fake_relogin() -> str:
|
||||||
|
return "renewed"
|
||||||
|
|
||||||
|
with pytest.raises(sv.CookieExpiredError):
|
||||||
|
await sv._run_with_auto_relogin(always_expired, "old", relogin=fake_relogin)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relogin_failure_propagates(cache_file):
|
||||||
|
async def entity(cookie: str):
|
||||||
|
raise sv.CookieExpiredError("过期")
|
||||||
|
|
||||||
|
async def failing_relogin() -> str:
|
||||||
|
raise sv.CookieExpiredError("自动重登失败(无账密/验证码)")
|
||||||
|
|
||||||
|
with pytest.raises(sv.CookieExpiredError, match="自动重登失败"):
|
||||||
|
await sv._run_with_auto_relogin(entity, "old", relogin=failing_relogin)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""employee_call_daily / employee_metric_daily 生成判别测试(批次A Task5/6)。
|
||||||
|
|
||||||
|
判据(v2 §1.1 #2/#5、§1.2 #9、§1.3 #11):
|
||||||
|
- 通话无渠道维,独立小表;接通=duration>0;followup=24h 内任一账号有消息;
|
||||||
|
- 幽灵员工(owner 映射不上/不在 staff)过滤;
|
||||||
|
- 宽表消息计数 friend-only;串类与 fact 同源。
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
Customer,
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
FirstReplyFact,
|
||||||
|
Message,
|
||||||
|
MsgDailyStats,
|
||||||
|
PhoneCall,
|
||||||
|
Staff,
|
||||||
|
WechatAccount,
|
||||||
|
WhatsappAccount,
|
||||||
|
)
|
||||||
|
from app.storage.employee_metrics import recompute_call_days, recompute_metric_days
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(*args) -> int:
|
||||||
|
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_org(session):
|
||||||
|
session.add(Staff(user_id="u1", name="张三", is_on_job=True))
|
||||||
|
session.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||||||
|
# 第二个微信号:验证 followup「任一账号」语义(owner-IN 而非单账号)
|
||||||
|
session.add(WechatAccount(wx_id="wx2", user_id="u1"))
|
||||||
|
# WA 账号:验证 wa_voice_call 渠道收窄正例
|
||||||
|
session.add(WhatsappAccount(uid="wa1", user_id="u1"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_daily_aggregates(make_session):
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
|
||||||
|
call_number="p1", start_call_time=_ts(2026, 9, 1, 10, 0),
|
||||||
|
call_duration=120))
|
||||||
|
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
|
||||||
|
call_number="p2", start_call_time=_ts(2026, 9, 1, 15, 0),
|
||||||
|
call_duration=0)) # 未接通
|
||||||
|
# c3:WA 语音,接通,窗口内有 WA 消息 → followup 正例(渠道收窄匹配)
|
||||||
|
s.add(PhoneCall(call_id="c3", channel="wa_voice_call", user_id="u1",
|
||||||
|
call_number="p3", start_call_time=_ts(2026, 9, 1, 10, 0),
|
||||||
|
call_duration=60))
|
||||||
|
# c4:微信语音,接通,但窗口内只有 WA 消息 → followup 反例(渠道收窄)
|
||||||
|
s.add(PhoneCall(call_id="c4", channel="voice_call", user_id="u1",
|
||||||
|
call_number="p4", start_call_time=_ts(2026, 9, 1, 10, 0),
|
||||||
|
call_duration=90))
|
||||||
|
# c1 后 2h 员工**另一微信号 wx2** 有跟进消息(任一账号语义:owner-IN)
|
||||||
|
s.add(Message(msg_id="f1", channel="wechat", owner_account_id="wx2",
|
||||||
|
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
|
||||||
|
is_send=True, type="text", text="跟进"))
|
||||||
|
# c2(未接通)后 1h 有消息:即使任一方向也不算 followup(仅已接通判定)
|
||||||
|
s.add(Message(msg_id="f2", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="p2", timestamp=_ts(2026, 9, 1, 16, 0),
|
||||||
|
is_send=False, type="text", text="客户回拨?"))
|
||||||
|
# c3 后 2h WA 账号有消息 → 正例
|
||||||
|
s.add(Message(msg_id="f3", channel="whatsapp", owner_account_id="wa1",
|
||||||
|
talker_id="p3", timestamp=_ts(2026, 9, 1, 12, 0),
|
||||||
|
is_send=True, type="text", text="wa 跟进"))
|
||||||
|
# c4(voice_call→wechat)窗口内只有 WA 消息 → 不匹配渠道,反例
|
||||||
|
s.add(Message(msg_id="f4", channel="whatsapp", owner_account_id="wa1",
|
||||||
|
talker_id="p4", timestamp=_ts(2026, 9, 1, 12, 0),
|
||||||
|
is_send=True, type="text", text="错渠道"))
|
||||||
|
await s.commit()
|
||||||
|
n = await recompute_call_days(s, [20260901])
|
||||||
|
await s.commit()
|
||||||
|
assert n == 1
|
||||||
|
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
|
||||||
|
assert row.call_count == 4
|
||||||
|
assert row.call_seconds == 270 # 120+0+60+90;duration 单位=秒(Task0 已核实)
|
||||||
|
assert row.answered_call_count == 3 # c1/c3/c4
|
||||||
|
# c1(wx2 任一账号)+ c3(wa 渠道正例);c2 未接通守卫、c4 渠道收窄反例
|
||||||
|
assert row.call_followup_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_daily_ignores_unknown_staff(make_session):
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(PhoneCall(call_id="c9", channel="phone_call", user_id="ghost",
|
||||||
|
call_number="p9", start_call_time=_ts(2026, 9, 1, 10, 0),
|
||||||
|
call_duration=60))
|
||||||
|
await s.commit()
|
||||||
|
await recompute_call_days(s, [20260901])
|
||||||
|
await s.commit()
|
||||||
|
assert (await s.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(EmployeeCallDaily))).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_msg_rows(s, *, day=20260901, channel="wechat", owner="wx1",
|
||||||
|
kind="friend", peer="p1", send=3, recv=2):
|
||||||
|
s.add(MsgDailyStats(channel=channel, owner_account_id=owner, peer_kind=kind,
|
||||||
|
peer_id=peer, day=day, send_cnt=send, recv_cnt=recv))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_friend_only_caliber(make_session):
|
||||||
|
"""口径判别(v2 §1.1 #2 防线):room 行不进消息计数/串列,只进 active_rooms。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
await _seed_msg_rows(s, send=3, recv=2, kind="friend", peer="p1")
|
||||||
|
await _seed_msg_rows(s, send=10, recv=0, kind="room", peer="r1")
|
||||||
|
# friend 会话给一条消息供串扫描(无消息则无串)
|
||||||
|
s.add(Message(msg_id="p1-0", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="p1", timestamp=_ts(2026, 9, 1, 10, 0),
|
||||||
|
is_send=False, type="text", text="q"))
|
||||||
|
await s.commit()
|
||||||
|
await recompute_metric_days(s, [20260901])
|
||||||
|
await s.commit()
|
||||||
|
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||||
|
assert row.msg_sent == 3 and row.msg_recv == 2 # room 的 10 不进
|
||||||
|
assert row.active_rooms == 1
|
||||||
|
assert row.cust_threads == 1 and row.threads_answered == 0 # 未回复串
|
||||||
|
assert row.first_reply_p50_ms is None and row.first_reply_p90_ms is None # 空样本→None(≠0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_ghost_filtered(make_session):
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
await _seed_msg_rows(s, owner="wx-ghost", peer="pg")
|
||||||
|
await recompute_metric_days(s, [20260901])
|
||||||
|
await s.commit()
|
||||||
|
assert (await s.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_same_source_as_fact(make_session):
|
||||||
|
"""同源判别(v2 §1.1 #5 防线):
|
||||||
|
1) threads_answered ≡ fact 行数(同归属日);
|
||||||
|
2) 多日未回串宽表 threads < 日切 cust_threads 之和(分母不再膨胀);
|
||||||
|
3) 幂等。"""
|
||||||
|
from app.storage.first_reply_facts import recompute_pairs
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
# 客户 D1 10:00、D1 20:00、D2 09:00 三条消息,始终未回 → 1 个跨日串
|
||||||
|
rows = [(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 20, 0), False),
|
||||||
|
(_ts(2026, 9, 2, 9, 0), False)]
|
||||||
|
for i, (ts, is_send) in enumerate(rows):
|
||||||
|
s.add(Message(msg_id=f"g{i}", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pg", timestamp=ts, is_send=is_send,
|
||||||
|
type="text", text="x"))
|
||||||
|
# 日切口径的 msg_daily_stats:D1、D2 各计一个未回串(供对照)
|
||||||
|
await _seed_msg_rows(s, day=20260901, peer="pg", send=0, recv=2)
|
||||||
|
await _seed_msg_rows(s, day=20260902, peer="pg", send=0, recv=1)
|
||||||
|
await s.commit()
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "pg")])
|
||||||
|
await s.commit()
|
||||||
|
await recompute_metric_days(s, [20260901, 20260902])
|
||||||
|
await s.commit()
|
||||||
|
got = {r.day: r for r in (
|
||||||
|
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||||
|
# 1) fact 行数=0(未回),宽表 threads_answered 也=0 且同源
|
||||||
|
assert (await s.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one() == 0
|
||||||
|
# 2) 跨日串只归属 D2;日切 D1+D2=2,宽表合计=1
|
||||||
|
assert got[20260901].cust_threads == 0
|
||||||
|
assert got[20260902].cust_threads == 1
|
||||||
|
# 3) 幂等
|
||||||
|
await recompute_metric_days(s, [20260901, 20260902])
|
||||||
|
await s.commit()
|
||||||
|
assert len((await s.execute(
|
||||||
|
sa.select(EmployeeMetricDaily))).scalars().all()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_same_source_as_fact_answered(make_session):
|
||||||
|
"""同源判别正向路径(v2 §1.1 #5):被回串——threads_answered ≡ fact 行数,
|
||||||
|
单样本 p50=p90=fr_ms(锁分位非 None 映射)。"""
|
||||||
|
from app.storage.first_reply_facts import recompute_pairs
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
# 客户 10:00 提问,员工 10:25 回复 → fr=25min,串末条归属 09-01
|
||||||
|
fr_ms = 25 * 60 * 1000
|
||||||
|
s.add(Message(msg_id="an0", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 0),
|
||||||
|
is_send=False, type="text", text="q"))
|
||||||
|
s.add(Message(msg_id="an1", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pa", timestamp=_ts(2026, 9, 1, 10, 25),
|
||||||
|
is_send=True, type="text", text="a"))
|
||||||
|
await _seed_msg_rows(s, day=20260901, peer="pa", send=1, recv=1)
|
||||||
|
await s.commit()
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "pa")])
|
||||||
|
await s.commit()
|
||||||
|
await recompute_metric_days(s, [20260901])
|
||||||
|
await s.commit()
|
||||||
|
n_facts = (await s.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(FirstReplyFact))).scalar_one()
|
||||||
|
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||||
|
assert row.cust_threads == 1
|
||||||
|
assert row.threads_answered == 1 == n_facts
|
||||||
|
assert row.fr_sum_ms == fr_ms
|
||||||
|
assert row.first_reply_p50_ms == fr_ms == row.first_reply_p90_ms
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_lifecycle_counts(make_session):
|
||||||
|
"""生命周期列:cooling/dormant/reactivated 判定进宽表。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
# 客户 pc:D1 有互动后静默(历史≥5 条) → as_of=10-10 为 dormant;
|
||||||
|
# 10-11 回流 → 该日 reactivated
|
||||||
|
s.add(Customer(friend_id="pc", channel="wechat", owner_account_id="wx1",
|
||||||
|
delete_flag=0))
|
||||||
|
hist = []
|
||||||
|
for h in range(9, 17): # D1 8 条消息(≥5)
|
||||||
|
hist.append((_ts(2026, 9, 1, h, 0), h % 2 == 0))
|
||||||
|
for i, (ts, is_send) in enumerate(hist):
|
||||||
|
s.add(Message(msg_id=f"lc{i}", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pc", timestamp=ts, is_send=is_send,
|
||||||
|
type="text", text="x"))
|
||||||
|
await _seed_msg_rows(s, day=20260901, peer="pc", send=4, recv=4)
|
||||||
|
await s.commit()
|
||||||
|
# D41(10-11)客户回流一条
|
||||||
|
s.add(Message(msg_id="lc-b", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pc", timestamp=_ts(2026, 10, 11, 9, 0),
|
||||||
|
is_send=False, type="text", text="back"))
|
||||||
|
await _seed_msg_rows(s, day=20261011, peer="pc", send=0, recv=1)
|
||||||
|
await s.commit()
|
||||||
|
from app.storage.first_reply_facts import recompute_pairs
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "pc")])
|
||||||
|
await recompute_metric_days(s, [20261010, 20261011])
|
||||||
|
await s.commit()
|
||||||
|
got = {r.day: r for r in (
|
||||||
|
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||||
|
assert got[20261010].dormant_customer_count == 1 # 39 天静默+历史 8 条
|
||||||
|
assert got[20261011].reactivated_customer_count == 1 # 当日回流且 gap>30
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_lifecycle_hist_point_in_time(make_session):
|
||||||
|
"""hist 时点语义判别:回填历史日 M,沉睡门槛只计 ≤M 的消息。
|
||||||
|
|
||||||
|
客户 pd:X=09-01 共 2 条 + Y=10-02 共 3 条(全历史 5 条):
|
||||||
|
- M=10-01(X+30,<Y):gap≥30 达沉睡判定,但 hist_asof=2 < 5 → None
|
||||||
|
(全历史口径 hist=5 会把 M 误判 dormant——M 之后的消息虚增);
|
||||||
|
- D=11-01(Y+30):hist_asof=5 ≥ 5 → dormant。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(Customer(friend_id="pd", channel="wechat", owner_account_id="wx1",
|
||||||
|
delete_flag=0))
|
||||||
|
for i, (day, h, is_send) in enumerate([
|
||||||
|
(20260901, 10, False), (20260901, 11, True), # X 2 条
|
||||||
|
(20261002, 10, False), (20261002, 11, True), (20261002, 12, False), # Y 3 条
|
||||||
|
]):
|
||||||
|
s.add(Message(msg_id=f"pd{i}", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="pd",
|
||||||
|
timestamp=_ts(day // 10000, day // 100 % 100, day % 100, h, 0),
|
||||||
|
is_send=is_send, type="text", text="x"))
|
||||||
|
await _seed_msg_rows(s, day=20260901, peer="pd", send=1, recv=1)
|
||||||
|
await _seed_msg_rows(s, day=20261002, peer="pd", send=1, recv=2)
|
||||||
|
# M 是无消息日:state=None 时纯生命周期源不出行,补一条 room 行撑出
|
||||||
|
# 宽表行,让 dormant==0 有断言落点
|
||||||
|
await _seed_msg_rows(s, day=20261001, kind="room", peer="r1", send=1, recv=0)
|
||||||
|
await s.commit()
|
||||||
|
await recompute_metric_days(s, [20261001, 20261101])
|
||||||
|
await s.commit()
|
||||||
|
got = {r.day: r for r in (
|
||||||
|
await s.execute(sa.select(EmployeeMetricDaily))).scalars().all()}
|
||||||
|
assert got[20261001].dormant_customer_count == 0 # hist_asof=2<5 → None
|
||||||
|
assert got[20261101].dormant_customer_count == 1 # hist_asof=5 ≥ 5
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""first_reply_fact 跨日精确串算法判别测试(批次A Task3)。
|
||||||
|
|
||||||
|
四个核心判据(v2 §1.1 #1/#5、§9):
|
||||||
|
1. 跨夜串必须产生样本(日切口径丢失的场景);
|
||||||
|
2. 多日未回串只计一次、归属末条日(日切会按天重复计——分母膨胀);
|
||||||
|
3. 同源:threads_answered ≡ fact 行数(按归属日);
|
||||||
|
4. is_send=None 不参与串判定(与 _thread_stats 一致)。
|
||||||
|
|
||||||
|
评审修正补判据:
|
||||||
|
5. 同毫秒 F,T,F,T 交换 → reply_msg_id 进 PK 去重,不撞主键(原崩溃回归);
|
||||||
|
6. 同毫秒并列按 (ts, msg_id) 定序,结果确定不随 SQL 等键序抖动。
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.db.models import FirstReplyFact, Message, MsgDailyStats
|
||||||
|
from app.storage.first_reply_facts import (
|
||||||
|
all_friend_pairs,
|
||||||
|
compute_conversation,
|
||||||
|
recompute_pairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(*args) -> int:
|
||||||
|
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------- 纯函数 ----------------------------
|
||||||
|
|
||||||
|
def test_overnight_thread_recovered():
|
||||||
|
"""客户 23:00 发→员工次日 9:00 回:fr_ms≈10h,归属 D1(v2 §1.1 #1)。"""
|
||||||
|
facts, agg = compute_conversation(
|
||||||
|
[(_ts(2026, 9, 1, 23, 0), False, "f1"),
|
||||||
|
(_ts(2026, 9, 2, 9, 0), True, "t1")]
|
||||||
|
)
|
||||||
|
assert len(facts) == 1
|
||||||
|
assert facts[0].fr_ms == 10 * 3600 * 1000
|
||||||
|
assert facts[0].day == 20260901
|
||||||
|
assert agg[20260901].answered == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiday_open_thread_counts_once_at_end_day():
|
||||||
|
"""客户消息跨 D1/D2 无回复:1 个串归属 D2;日切口径会 D1、D2 各计 1。"""
|
||||||
|
facts, agg = compute_conversation(
|
||||||
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 20, 0), False, "f2"),
|
||||||
|
(_ts(2026, 9, 2, 9, 0), False, "f3")]
|
||||||
|
)
|
||||||
|
assert facts == []
|
||||||
|
assert 20260901 not in agg
|
||||||
|
assert agg[20260902].threads == 1 and agg[20260902].answered == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_closes_thread_and_opens_new():
|
||||||
|
"""员工回复后客户再发=新串;串内多条客户消息取末条为 end。"""
|
||||||
|
facts, agg = compute_conversation(
|
||||||
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 5), False, "f2"),
|
||||||
|
(_ts(2026, 9, 1, 10, 30), True, "t1"), # 回复1:fr=30-10:05=25min
|
||||||
|
(_ts(2026, 9, 1, 11, 0), False, "f3"),
|
||||||
|
(_ts(2026, 9, 1, 12, 0), True, "t2")]) # 回复2:fr=12:00-11:00=60min
|
||||||
|
assert len(facts) == 2
|
||||||
|
assert facts[0].fr_ms == 25 * 60 * 1000
|
||||||
|
assert agg[20260901].threads == 2 and agg[20260901].answered == 2
|
||||||
|
assert agg[20260901].fr_sum_ms == 85 * 60 * 1000 # 25min + 60min
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_direction_ignored():
|
||||||
|
facts, _agg = compute_conversation(
|
||||||
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 10), None, "u1"),
|
||||||
|
(_ts(2026, 9, 1, 10, 20), True, "t1")]
|
||||||
|
)
|
||||||
|
assert len(facts) == 1 # None 不打断串:仍视为对 10:00 串的回复
|
||||||
|
|
||||||
|
|
||||||
|
def test_staff_message_outside_thread_is_noop():
|
||||||
|
facts, agg = compute_conversation(
|
||||||
|
[(_ts(2026, 9, 1, 9, 0), True, "t1"), (_ts(2026, 9, 1, 10, 0), True, "t2")]
|
||||||
|
)
|
||||||
|
assert facts == [] and agg == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsorted_rows_raise_or_documented():
|
||||||
|
"""调用方契约:rows 须升序。乱序输入行为未定义——文档断言 + 防御排序
|
||||||
|
在 load_conversation 里做(与 summary.py 惯例一致)。"""
|
||||||
|
# 纯函数不做防御(性能);落库路径负责排序。此处仅锁定契约注释。
|
||||||
|
import app.storage.first_reply_facts as m
|
||||||
|
assert "升序" in m.compute_conversation.__doc__
|
||||||
|
|
||||||
|
|
||||||
|
def test_tie_break_by_msg_id_deterministic():
|
||||||
|
"""同毫秒 [F(m1), T(m2)]:按 (ts, msg_id) 定序 F 在前 → 1 个被回串;
|
||||||
|
同一 multiset 无论入参顺序,排序后结果一致(SQL 等键序未定义,不能
|
||||||
|
随查询抖动)。"""
|
||||||
|
same = _ts(2026, 9, 1, 10, 0)
|
||||||
|
rows = [(same, False, "m1"), (same, True, "m2")]
|
||||||
|
ordered = sorted(rows, key=lambda r: (r[0], r[2]))
|
||||||
|
assert ordered[0][2] == "m1" # F(m1) 先于 T(m2)
|
||||||
|
facts, agg = compute_conversation(ordered)
|
||||||
|
assert len(facts) == 1 and facts[0].reply_msg_id == "m2"
|
||||||
|
assert agg[20260901].answered == 1
|
||||||
|
shuffled = [rows[1], rows[0]] # 同一 multiset,入参顺序相反
|
||||||
|
facts2, agg2 = compute_conversation(
|
||||||
|
sorted(shuffled, key=lambda r: (r[0], r[2])))
|
||||||
|
assert facts2 == facts and agg2 == agg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------- 落库 ----------------------------
|
||||||
|
|
||||||
|
async def _seed_conversation(session, *, channel="wechat", owner="wx1",
|
||||||
|
peer="p1", rows):
|
||||||
|
for ts, is_send, msg_id in rows:
|
||||||
|
session.add(Message(msg_id=msg_id, channel=channel,
|
||||||
|
owner_account_id=owner, talker_id=peer,
|
||||||
|
timestamp=ts, is_send=is_send, type="text",
|
||||||
|
text="x"))
|
||||||
|
session.add(MsgDailyStats(channel=channel, owner_account_id=owner,
|
||||||
|
peer_kind="friend", peer_id=peer,
|
||||||
|
day=20260901, send_cnt=1, recv_cnt=1))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recompute_pairs_persists_and_idempotent(make_session):
|
||||||
|
s = make_session
|
||||||
|
await _seed_conversation(
|
||||||
|
s, rows=[(_ts(2026, 9, 1, 23, 0), False, "p1-f0"),
|
||||||
|
(_ts(2026, 9, 2, 9, 0), True, "p1-t1")])
|
||||||
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
assert n == 1
|
||||||
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
||||||
|
assert len(rows) == 1 and rows[0].fr_ms == 10 * 3600 * 1000
|
||||||
|
assert rows[0].reply_msg_id == "p1-t1"
|
||||||
|
# 幂等:重跑不重复
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
assert len((await s.execute(sa.select(FirstReplyFact))).scalars().all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_ms_ftft_two_distinct_facts(make_session):
|
||||||
|
"""同毫秒 F,T,F,T 交换:两个样本 (thread_end_ts, reply_ts) 完全并列
|
||||||
|
——reply_msg_id 进 PK 去重后恰好 2 行(评审复现的主键冲突回归)。"""
|
||||||
|
s = make_session
|
||||||
|
same = _ts(2026, 9, 1, 10, 0)
|
||||||
|
await _seed_conversation(s, rows=[
|
||||||
|
(same, False, "m1"), (same, True, "m2"),
|
||||||
|
(same, False, "m3"), (same, True, "m4"),
|
||||||
|
])
|
||||||
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
assert n == 1
|
||||||
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert {r.reply_msg_id for r in rows} == {"m2", "m4"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recompute_pairs_replaces_stale(make_session):
|
||||||
|
"""会话追加消息后重算,旧样本被替换(delete+insert 语义)。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_conversation(
|
||||||
|
s, rows=[(_ts(2026, 9, 1, 10, 0), False, "p1-f0"),
|
||||||
|
(_ts(2026, 9, 1, 11, 0), True, "p1-t1")])
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
s.add(Message(msg_id="p1-new", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
|
||||||
|
is_send=False, type="text", text="y"))
|
||||||
|
await s.commit()
|
||||||
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
||||||
|
assert len(rows) == 1 # 新串未回,只剩旧样本
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recompute_pairs_empty_conversation(make_session):
|
||||||
|
"""stats 行在但 message 无合格行(空会话/全被过滤):删旧行不插新——
|
||||||
|
delete+insert 语义对空会话同样成立(幂等清空)。"""
|
||||||
|
s = make_session
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="p1", day=20260901))
|
||||||
|
# 预置历史残留 fact,重算后应被清掉
|
||||||
|
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
|
||||||
|
thread_end_ts=_ts(2026, 9, 1, 10, 0),
|
||||||
|
reply_ts=_ts(2026, 9, 1, 11, 0), reply_msg_id="stale",
|
||||||
|
day=20260901, fr_ms=3600 * 1000))
|
||||||
|
await s.commit()
|
||||||
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
||||||
|
await s.commit()
|
||||||
|
assert n == 1
|
||||||
|
assert (await s.execute(sa.select(FirstReplyFact))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_friend_pairs_only_friend(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="room", peer_id="r1", day=20260901))
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="p1", day=20260901))
|
||||||
|
await s.commit()
|
||||||
|
assert await all_friend_pairs(s) == [("wechat", "wx1", "p1")]
|
||||||
@@ -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,72 @@
|
|||||||
|
"""客户生命周期判定纯函数测试(批次A Task4)。
|
||||||
|
|
||||||
|
口径(v2 §1.1 #3 + v1 §21 默认,已用户确认):新客 7 天、沉睡 30 天、
|
||||||
|
历史≥5 条才算沉睡(对齐 churn real min_msgs=5)。analytics 现有页
|
||||||
|
(churn/客户资产)暂不切换(口径差异 v2 §5 标注),不动 636 既有测试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.analytics.lifecycle import DORMANT_DAYS, MIN_HISTORY_MSGS, NEW_DAYS, classify, day_add
|
||||||
|
|
||||||
|
|
||||||
|
def test_constants_locked_to_confirmed_defaults():
|
||||||
|
assert NEW_DAYS == 7 and DORMANT_DAYS == 30 and MIN_HISTORY_MSGS == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_customer():
|
||||||
|
# 首次沟通在 7 天窗口内
|
||||||
|
assert (
|
||||||
|
classify(first_day=20260901, last_day=20260903, prev_day=None, as_of=20260905, hist_msgs=2)
|
||||||
|
== "new"
|
||||||
|
)
|
||||||
|
# 窗口边界:as_of-6=20260830 含当天共 7 天 → new;再早一天 → 非 new
|
||||||
|
assert classify(20260830, 20260830, None, 20260905, 100) == "new"
|
||||||
|
assert classify(20260829, 20260829, None, 20260905, 100) == "cooling"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reactivated_requires_gap_over_30d():
|
||||||
|
# 当日回流且前次间隔>30 天
|
||||||
|
assert (
|
||||||
|
classify(
|
||||||
|
first_day=20260101, last_day=20260905, prev_day=20260801, as_of=20260905, hist_msgs=50
|
||||||
|
)
|
||||||
|
== "reactivated"
|
||||||
|
)
|
||||||
|
# 间隔仅 9 天 → 只是 active
|
||||||
|
assert (
|
||||||
|
classify(
|
||||||
|
first_day=20260101, last_day=20260905, prev_day=20260827, as_of=20260905, hist_msgs=50
|
||||||
|
)
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
# 间隔恰好 30 天 → 严格 >30 不算回流,落回 active
|
||||||
|
assert (
|
||||||
|
classify(
|
||||||
|
first_day=20260101, last_day=20260905, prev_day=20260806, as_of=20260905, hist_msgs=50
|
||||||
|
)
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
# 回流必须当日互动:前次间隔 31 天(>30)但 last_day=as_of-1 → 不算回流,按 gap=1 落 active
|
||||||
|
assert classify(20260101, 20260904, 20260804, 20260905, 50) == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_cooling_dormant_boundaries():
|
||||||
|
f, m = 20260101, 100
|
||||||
|
assert classify(f, 20260830, None, 20260905, m) == "active" # 6 天
|
||||||
|
assert classify(f, 20260829, None, 20260905, m) == "cooling" # 7 天
|
||||||
|
assert classify(f, 20260807, None, 20260905, m) == "cooling" # 29 天
|
||||||
|
assert classify(f, 20260806, None, 20260905, m) == "dormant" # 30 天
|
||||||
|
|
||||||
|
|
||||||
|
def test_dormant_requires_history():
|
||||||
|
# ≥30 天无互动但历史<5 条:不计入任何状态(None)
|
||||||
|
assert classify(20260101, 20260806, None, 20260905, 4) is None
|
||||||
|
assert classify(20260101, 20260806, None, 20260905, 5) == "dormant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_interaction_returns_none():
|
||||||
|
assert classify(None, None, None, 20260905, 0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_add():
|
||||||
|
assert day_add(20260831, 1) == 20260901
|
||||||
|
assert day_add(20260901, -7) == 20260825
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""管理域确定性异常规则判别测试(批次B Task B3,v1 §5.4 异常卡片)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.management.anomalies import detect_anomalies
|
||||||
|
from app.management.employee_metrics import PeriodMetrics
|
||||||
|
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _pm(**overrides):
|
||||||
|
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。
|
||||||
|
|
||||||
|
与 test_management_scoring._pm 同式(测试间不共享 helper,复制保持独立)。
|
||||||
|
"""
|
||||||
|
base = {
|
||||||
|
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||||
|
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||||
|
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||||
|
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||||
|
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||||
|
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||||
|
"end_active_friends": 0, "reactivated_customer": 0,
|
||||||
|
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||||
|
"call_followup": 0,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return PeriodMetrics(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect(cur, base, *, n_days=7, user_id="u1", config=None):
|
||||||
|
return detect_anomalies(
|
||||||
|
user_id, cur, base, n_days=n_days,
|
||||||
|
config=config if config is not None else DEFAULT_SCORE_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. reply_p90_worsened ----
|
||||||
|
|
||||||
|
def test_p90_worsened_warning_with_pct():
|
||||||
|
"""×1.6 → warning;标题百分比=(160-100)/100=60%;affected=未回串数。"""
|
||||||
|
cur = _pm(cust_threads=20, threads_answered=12, fr_p90_ms=9_600_000) # 160min
|
||||||
|
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) # 100min
|
||||||
|
cards = _detect(cur, base)
|
||||||
|
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||||
|
c = cards[0]
|
||||||
|
assert c["severity"] == "warning"
|
||||||
|
assert c["title"] == "回复 P90 较个人基线上升 60%"
|
||||||
|
assert c["current_value"] == 9_600_000
|
||||||
|
assert c["baseline_value"] == 6_000_000
|
||||||
|
assert c["unit"] == "ms"
|
||||||
|
assert c["sample_size"] == 20
|
||||||
|
assert c["affected_count"] == 8 # 20 串 - 12 已回 = 期内未回串数
|
||||||
|
assert c["drilldown"] == {"type": "metric",
|
||||||
|
"query": {"metric_code": "first_reply_p90",
|
||||||
|
"user_id": "u1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_p90_worsened_critical_at_3x():
|
||||||
|
cur = _pm(cust_threads=20, threads_answered=20, fr_p90_ms=18_000_000)
|
||||||
|
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||||
|
cards = _detect(cur, base)
|
||||||
|
assert len(cards) == 1
|
||||||
|
assert cards[0]["severity"] == "critical"
|
||||||
|
assert "上升 200%" in cards[0]["title"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_p90_boundaries_strict_compare():
|
||||||
|
"""×1.5 恰好 → 严格大于不成立不触发;×2.5 恰好 → warning 非 critical;
|
||||||
|
threads 恰好 = soft(10) → ≥soft 触发。"""
|
||||||
|
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||||
|
assert _detect(_pm(cust_threads=20, fr_p90_ms=9_000_000), base) == []
|
||||||
|
cards = _detect(_pm(cust_threads=20, fr_p90_ms=15_000_000), base)
|
||||||
|
assert [c["severity"] for c in cards] == ["warning"]
|
||||||
|
cards = _detect(_pm(cust_threads=10, fr_p90_ms=9_600_000), base)
|
||||||
|
assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_p90_below_soft_threads_not_fired():
|
||||||
|
cur = _pm(cust_threads=5, fr_p90_ms=9_600_000) # <soft 10
|
||||||
|
base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000)
|
||||||
|
assert _detect(cur, base) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 2. 基线缺失 ----
|
||||||
|
|
||||||
|
def test_baseline_none_all_rules_silent():
|
||||||
|
"""新员工无个人基线:即便 p90 恶化/活动骤降/过载形态俱在也不出卡。"""
|
||||||
|
cur = _pm(cust_threads=20, threads_answered=5, fr_p90_ms=30_000_000,
|
||||||
|
active_friends=85, msg_sent=10, msg_recv=10)
|
||||||
|
assert _detect(cur, None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 3. reply_rate_dropped ----
|
||||||
|
|
||||||
|
def test_reply_rate_dropped_warning():
|
||||||
|
"""0.8→0.5(0.5 < 0.7×0.8=0.56)→ warning;百分比 37.5%→38%。"""
|
||||||
|
cur = _pm(cust_threads=20, reply_2h_count=10, threads_answered=15) # 0.5
|
||||||
|
base = _pm(cust_threads=40, reply_2h_count=32, threads_answered=40) # 0.8
|
||||||
|
cards = _detect(cur, base)
|
||||||
|
assert [c["rule_code"] for c in cards] == ["reply_rate_dropped"]
|
||||||
|
c = cards[0]
|
||||||
|
assert c["severity"] == "warning"
|
||||||
|
assert c["title"] == "2h 回复率较个人基线下降 38%"
|
||||||
|
assert c["current_value"] == pytest.approx(0.5)
|
||||||
|
assert c["baseline_value"] == pytest.approx(0.8)
|
||||||
|
assert c["unit"] == "0-1"
|
||||||
|
assert c["sample_size"] == 20
|
||||||
|
assert c["affected_count"] == 5
|
||||||
|
assert c["drilldown"] == {"type": "metric",
|
||||||
|
"query": {"metric_code": "unreplied",
|
||||||
|
"user_id": "u1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_rate_small_drop_not_fired():
|
||||||
|
"""0.8→0.6(0.6 ≥ 0.56)→ 不触发。"""
|
||||||
|
cur = _pm(cust_threads=20, reply_2h_count=12) # 0.6
|
||||||
|
base = _pm(cust_threads=40, reply_2h_count=32) # 0.8
|
||||||
|
assert _detect(cur, base) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_baseline_no_threads_not_fired():
|
||||||
|
"""基线无串样本 → 基线率 None → 不判(基线值 None 不触发)。"""
|
||||||
|
cur = _pm(cust_threads=20, reply_2h_count=0)
|
||||||
|
base = _pm(cust_threads=0) # derive → reply_2h_rate None
|
||||||
|
assert _detect(cur, base) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 4. activity_drop ----
|
||||||
|
|
||||||
|
def test_activity_drop_triggered():
|
||||||
|
"""当前 7 天日均 10 < 基线 28 天日均 30×0.5=15 → warning。"""
|
||||||
|
cur = _pm(msg_sent=40, msg_recv=30, active_friends=25) # 70/7=10 条/天
|
||||||
|
base = _pm(msg_sent=600, msg_recv=240, days_with_data=20) # 840/28=30 条/天
|
||||||
|
cards = _detect(cur, base)
|
||||||
|
assert [c["rule_code"] for c in cards] == ["activity_drop"]
|
||||||
|
c = cards[0]
|
||||||
|
assert c["severity"] == "warning"
|
||||||
|
assert c["title"] == "日均消息量较个人基线下降 67%" # 66.7→67
|
||||||
|
assert c["current_value"] == pytest.approx(10.0)
|
||||||
|
assert c["baseline_value"] == pytest.approx(30.0)
|
||||||
|
assert c["unit"] == "条"
|
||||||
|
assert c["sample_size"] == 25
|
||||||
|
assert c["affected_count"] == 25
|
||||||
|
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_drop_sparse_baseline_not_fired():
|
||||||
|
"""基线 days_with_data=3 < min_days(5) → 基线不可比,不触发。"""
|
||||||
|
base = _pm(msg_sent=600, msg_recv=240, days_with_data=3)
|
||||||
|
cur = _pm(msg_sent=40, msg_recv=30)
|
||||||
|
assert _detect(cur, base) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 5. workload_overload ----
|
||||||
|
|
||||||
|
def test_workload_overload_triggered():
|
||||||
|
cards = _detect(_pm(active_friends=85), _pm())
|
||||||
|
assert [c["rule_code"] for c in cards] == ["workload_overload"]
|
||||||
|
c = cards[0]
|
||||||
|
assert c["severity"] == "warning"
|
||||||
|
assert c["title"] == "活跃客户 85 人超过负载上限 80 人"
|
||||||
|
assert c["current_value"] == 85
|
||||||
|
assert c["baseline_value"] is None # 规则与个人基线无关,阈值在 band 配置
|
||||||
|
assert c["unit"] == "人"
|
||||||
|
assert c["sample_size"] == 85
|
||||||
|
assert c["affected_count"] == 85
|
||||||
|
assert c["drilldown"] == {"type": "employee", "query": {"user_id": "u1"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workload_overload_at_limit_not_fired():
|
||||||
|
assert _detect(_pm(active_friends=80), _pm()) == [] # 严格大于上限
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 6. 配置驱动与组合 ----
|
||||||
|
|
||||||
|
def test_thresholds_read_from_config():
|
||||||
|
"""soft_threads/band high 改配置即改判——阈值不硬编码。"""
|
||||||
|
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||||
|
cfg["sample_guards"]["soft_threads"] = 25
|
||||||
|
cfg["dimensions"]["workload"]["metrics"]["active_friends_band"]["high"] = 90
|
||||||
|
cur = _pm(cust_threads=20, fr_p90_ms=9_600_000, active_friends=85)
|
||||||
|
base = _pm(cust_threads=40, fr_p90_ms=6_000_000)
|
||||||
|
# threads 20 < soft25 → p90/rate 不评;85 ≤ 90 → 不 overload
|
||||||
|
assert _detect(cur, base, config=cfg) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_rules_stack_in_order():
|
||||||
|
"""p90 ×5 critical + activity 骤降 + overload 90>80 同时触发,按规则表序输出;
|
||||||
|
两侧回复率同为 0(0<0 不成立)→ rate 规则不触发。"""
|
||||||
|
cur = _pm(cust_threads=30, threads_answered=10, fr_p90_ms=30_000_000,
|
||||||
|
active_friends=90, msg_sent=10, msg_recv=10)
|
||||||
|
base = _pm(cust_threads=60, threads_answered=60, fr_p90_ms=6_000_000,
|
||||||
|
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||||
|
cards = _detect(cur, base)
|
||||||
|
assert [c["rule_code"] for c in cards] == [
|
||||||
|
"reply_p90_worsened", "activity_drop", "workload_overload"]
|
||||||
|
assert cards[0]["severity"] == "critical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_structure_all_fields():
|
||||||
|
"""单卡 12 字段齐全且类型正确(v1 §5.4)。"""
|
||||||
|
cur = _pm(cust_threads=20, reply_2h_count=20, threads_answered=12,
|
||||||
|
fr_p90_ms=9_600_000, active_friends=30,
|
||||||
|
msg_sent=80, msg_recv=30) # 日均 110/7≈15.7 ≥ 15 → activity 不触发
|
||||||
|
base = _pm(cust_threads=40, reply_2h_count=40, threads_answered=40,
|
||||||
|
fr_p90_ms=6_000_000, active_friends=30,
|
||||||
|
msg_sent=600, msg_recv=240, days_with_data=20)
|
||||||
|
cards = _detect(cur, base, user_id="u9")
|
||||||
|
assert len(cards) == 1
|
||||||
|
c = cards[0]
|
||||||
|
assert set(c) == {
|
||||||
|
"rule_code", "severity", "employee_user_id", "title",
|
||||||
|
"current_value", "baseline_value", "unit", "sample_size",
|
||||||
|
"affected_count", "reason", "drilldown", "suggested_action",
|
||||||
|
}
|
||||||
|
assert isinstance(c["rule_code"], str)
|
||||||
|
assert c["severity"] in ("warning", "critical")
|
||||||
|
assert c["employee_user_id"] == "u9"
|
||||||
|
assert isinstance(c["title"], str) and c["title"]
|
||||||
|
assert isinstance(c["current_value"], (int, float))
|
||||||
|
assert c["baseline_value"] is None or isinstance(
|
||||||
|
c["baseline_value"], (int, float))
|
||||||
|
assert isinstance(c["unit"], str)
|
||||||
|
assert isinstance(c["sample_size"], int)
|
||||||
|
assert isinstance(c["affected_count"], int)
|
||||||
|
assert isinstance(c["reason"], str) and c["reason"]
|
||||||
|
assert set(c["drilldown"]) == {"type", "query"}
|
||||||
|
assert isinstance(c["drilldown"]["query"], dict)
|
||||||
|
assert isinstance(c["suggested_action"], str) and c["suggested_action"]
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"""管理 API 六只读端点判别测试(批次B Task B4)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 /
|
||||||
|
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
|
||||||
|
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
|
||||||
|
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
|
||||||
|
metric_version);批次D 补:prev_kpis 环比块+msg_delta(含前窗零数据
|
||||||
|
→None)与 anomalies employee_name enrich(详情端点不带);
|
||||||
|
- employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
|
||||||
|
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
|
||||||
|
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
|
||||||
|
- sections:五段各有 trend 数组与四对比;
|
||||||
|
- drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant
|
||||||
|
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
|
||||||
|
- metric-definitions:trial 标记/口径说明/config_source 回退。
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import datetime as dt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
Customer,
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
EmployeeScoreConfig,
|
||||||
|
FirstReplyFact,
|
||||||
|
Message,
|
||||||
|
MsgDailyStats,
|
||||||
|
PhoneCall,
|
||||||
|
Staff,
|
||||||
|
WechatAccount,
|
||||||
|
WhatsappAccount,
|
||||||
|
)
|
||||||
|
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够)
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(*args) -> int:
|
||||||
|
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
|
||||||
|
cols = {
|
||||||
|
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
|
||||||
|
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||||
|
"new_customer_count": 0, "cooling_customer_count": 0,
|
||||||
|
"dormant_customer_count": 0, "reactivated_customer_count": 0,
|
||||||
|
"data_complete": True,
|
||||||
|
}
|
||||||
|
cols.update(kw)
|
||||||
|
return EmployeeMetricDaily(
|
||||||
|
day=day, channel=channel, user_id=user_id,
|
||||||
|
metric_version=metric_version, **cols
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
|
||||||
|
end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000
|
||||||
|
return FirstReplyFact(
|
||||||
|
channel=channel, owner_account_id=owner, peer_id=peer,
|
||||||
|
thread_end_ts=end, reply_ts=end + fr_ms,
|
||||||
|
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_common(s):
|
||||||
|
"""三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/
|
||||||
|
u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/
|
||||||
|
drilldown 全部判据。"""
|
||||||
|
for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True),
|
||||||
|
("u3", "王五", False)):
|
||||||
|
s.add(Staff(user_id=uid, name=name, is_on_job=onjob))
|
||||||
|
s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信",
|
||||||
|
user_name="张三"))
|
||||||
|
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
|
||||||
|
s.add(WhatsappAccount(uid="wa2", user_id="u2"))
|
||||||
|
s.add(WhatsappAccount(uid="wa3", user_id="u3"))
|
||||||
|
|
||||||
|
# ---- 当前窗宽表(0901-0905)----
|
||||||
|
for d in range(1, 6):
|
||||||
|
s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2,
|
||||||
|
threads_answered=2, fr_sum_ms=240_000, active_friends=3,
|
||||||
|
active_rooms=1, cooling_customer_count=2,
|
||||||
|
dormant_customer_count=1,
|
||||||
|
first_reply_p50_ms=110_000, first_reply_p90_ms=220_000))
|
||||||
|
s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10,
|
||||||
|
cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000,
|
||||||
|
active_friends=4, cooling_customer_count=1,
|
||||||
|
reactivated_customer_count=1))
|
||||||
|
s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3",
|
||||||
|
msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1))
|
||||||
|
# ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)----
|
||||||
|
for d in range(27, 32):
|
||||||
|
s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14))
|
||||||
|
s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5))
|
||||||
|
|
||||||
|
# ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ----
|
||||||
|
s.add(_fact(120_000, peer="pA", day=20260901, i=0))
|
||||||
|
s.add(_fact(120_000, peer="pB", day=20260902, i=1))
|
||||||
|
s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2))
|
||||||
|
s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3))
|
||||||
|
for owner, peer, day, i in (("wx1", "pA", 20260827, 10),
|
||||||
|
("wx1", "pB", 20260828, 11),
|
||||||
|
("wx2", "pC", 20260827, 12),
|
||||||
|
("wx2", "pD", 20260828, 13)):
|
||||||
|
s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i))
|
||||||
|
|
||||||
|
# ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ----
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="p1", day=20260901,
|
||||||
|
send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0),
|
||||||
|
last_is_send=False))
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="p2", day=20260902,
|
||||||
|
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
|
||||||
|
peer_kind="friend", peer_id="p3", day=20260901,
|
||||||
|
send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3",
|
||||||
|
peer_kind="friend", peer_id="p4", day=20260903,
|
||||||
|
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
# drilldown cooling/dormant 客户互动史(全历史,窗外):
|
||||||
|
# f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling
|
||||||
|
for day, n in ((20260701, 6), (20260801, 6)):
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="f1", day=day,
|
||||||
|
send_cnt=n, recv_cnt=0,
|
||||||
|
last_ts=_ts(day // 10000, day // 100 % 100,
|
||||||
|
day % 100, 10, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
for day in (20260801, 20260825):
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id="f2", day=day,
|
||||||
|
send_cnt=2, recv_cnt=2,
|
||||||
|
last_ts=_ts(2026, 8, day % 100, 11, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
|
||||||
|
# ---- 客户档案(f9 已删,须被 drilldown 剔除)----
|
||||||
|
s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1",
|
||||||
|
friend_nickname="客户一", user_id="u1", delete_flag=0))
|
||||||
|
s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1",
|
||||||
|
friend_nickname="客户二", user_id="u1", delete_flag=0))
|
||||||
|
s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1",
|
||||||
|
friend_nickname="已删客户", user_id="u1", delete_flag=1))
|
||||||
|
|
||||||
|
# ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)----
|
||||||
|
s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1",
|
||||||
|
talker_id="p1", is_send=False, type="text", text="在吗",
|
||||||
|
timestamp=_ts(2026, 9, 1, 10, 0)))
|
||||||
|
|
||||||
|
# ---- 通话 ----
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
|
||||||
|
call_seconds=100, answered_call_count=1,
|
||||||
|
call_followup_count=1))
|
||||||
|
s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1,
|
||||||
|
call_seconds=60, answered_call_count=1,
|
||||||
|
call_followup_count=0))
|
||||||
|
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
|
||||||
|
start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120,
|
||||||
|
call_number="13800000001"))
|
||||||
|
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
|
||||||
|
start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0,
|
||||||
|
call_number="13800000002"))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 参数与错误
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_missing_start_422(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/dashboard", params={"end": END})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_span_and_calendar_422(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/dashboard",
|
||||||
|
params={"start": 20250101, "end": 20261231})
|
||||||
|
assert r.status_code == 422
|
||||||
|
r = await client.get("/api/management/dashboard",
|
||||||
|
params={"start": 20260230, "end": 20260905})
|
||||||
|
assert r.status_code == 422 # 日历非法(2 月 30 日)
|
||||||
|
r = await client.get("/api/management/dashboard",
|
||||||
|
params={"start": 20260906, "end": 20260901})
|
||||||
|
assert r.status_code == 422 # start > end
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_unknown_metric_422(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "bogus",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "unknown metric_code" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_page_size_over_100_422(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END,
|
||||||
|
"page_size": 101})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employee_detail_404(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/ghost",
|
||||||
|
params={"start": START, "end": END})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_employee_404(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/ghost/drilldown",
|
||||||
|
params={"metric_code": "unreplied",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- dashboard
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_structure_and_kpis(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/dashboard",
|
||||||
|
params={"channel": "all", "start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
for key in ("scope", "data_as_of", "metric_version", "kpis",
|
||||||
|
"communication_trend", "service_trend", "lifecycle",
|
||||||
|
"dimension_summary", "anomalies", "action_summary",
|
||||||
|
"config_source", "caliber_notes"):
|
||||||
|
assert key in body, key
|
||||||
|
assert body["metric_version"] == "v1"
|
||||||
|
assert body["config_source"] == "default"
|
||||||
|
assert body["scope"] == {"channel": "all", "start": START, "end": END,
|
||||||
|
"compare": "previous"}
|
||||||
|
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
|
||||||
|
|
||||||
|
k = body["kpis"]
|
||||||
|
# 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1)
|
||||||
|
assert k["active_customers"] == 4
|
||||||
|
assert k["active_employees"] == 3
|
||||||
|
assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50
|
||||||
|
assert k["cust_threads"] == 25 # 10 + 10 + 5
|
||||||
|
assert k["threads_answered"] == 20 # 10 + 10 + 0
|
||||||
|
assert k["call_count"] == 3
|
||||||
|
assert k["call_seconds"] == 160
|
||||||
|
assert k["answered_call"] == 2
|
||||||
|
# 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5
|
||||||
|
assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1,
|
||||||
|
"reactivated": 5}
|
||||||
|
assert k["lifecycle"] == body["lifecycle"]
|
||||||
|
|
||||||
|
# 前窗环比(批次D):prev 窗 0827-0831 与基线窗种子重叠——
|
||||||
|
# u1(14+14)×5 + u2(5+5)×5 = 190;宽表无 active_friends/串列 → 0
|
||||||
|
pk = body["prev_kpis"]
|
||||||
|
assert set(pk) == {"active_customers", "active_employees",
|
||||||
|
"msg_total", "cust_threads"}
|
||||||
|
assert pk == {"active_customers": 0, "active_employees": 2,
|
||||||
|
"msg_total": 190, "cust_threads": 0}
|
||||||
|
assert body["msg_delta"] == round((155 - 190) / 190, 3) # -0.184
|
||||||
|
|
||||||
|
# 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
|
||||||
|
trend = body["communication_trend"]
|
||||||
|
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)]
|
||||||
|
assert trend[0]["msg_total"] == 31
|
||||||
|
assert trend[0]["active_customers"] == 8
|
||||||
|
assert trend[0]["call_cnt"] == 2
|
||||||
|
assert trend[1]["call_cnt"] == 1
|
||||||
|
assert trend[4] == {"day": 20260905, "msg_total": 31,
|
||||||
|
"active_customers": 8, "call_cnt": 0}
|
||||||
|
|
||||||
|
# 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k;
|
||||||
|
# reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5)
|
||||||
|
st = {t["day"]: t for t in body["service_trend"]}
|
||||||
|
assert st[20260901]["fr_p50_ms"] == 120_000
|
||||||
|
assert st[20260901]["fr_p90_ms"] == 600_000
|
||||||
|
assert st[20260901]["reply_2h_rate"] == 0.4
|
||||||
|
assert st[20260903]["fr_p50_ms"] is None # 无 fact 日
|
||||||
|
assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0
|
||||||
|
|
||||||
|
# 维度摘要:5 维全员计数,总数=员工数 3
|
||||||
|
dims = body["dimension_summary"]
|
||||||
|
assert set(dims) == {"customer", "service", "action", "workload", "risk"}
|
||||||
|
for d in dims.values():
|
||||||
|
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
|
||||||
|
assert "avg_score" in d
|
||||||
|
|
||||||
|
# 异常:critical 在前(u2),warning 其后按 affected_count desc;
|
||||||
|
# dashboard 卡片 enrich employee_name(批次D,前端跳员工详情用)
|
||||||
|
anoms = body["anomalies"]
|
||||||
|
assert anoms[0]["severity"] == "critical"
|
||||||
|
assert anoms[0]["employee_user_id"] == "u2"
|
||||||
|
assert anoms[0]["employee_name"] == "李四"
|
||||||
|
assert all(a["severity"] == "critical" for a in anoms[:1])
|
||||||
|
assert all(a["severity"] == "warning" for a in anoms[1:])
|
||||||
|
assert all(a["employee_name"] == "张三" for a in anoms[1:])
|
||||||
|
|
||||||
|
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
|
||||||
|
"sensitive_pending": 0}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_prev_zero_no_delta(make_client):
|
||||||
|
"""前窗无数据(whatsapp 视图前窗零宽表)→ prev_kpis 四键全 0、
|
||||||
|
msg_delta None(prev=0 不算环比,None 而非 0/负∞)。"""
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/dashboard",
|
||||||
|
params={"channel": "whatsapp", "start": START,
|
||||||
|
"end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["kpis"]["msg_total"] == 50 # 仅 u3 的 whatsapp 宽表
|
||||||
|
assert body["prev_kpis"] == {"active_customers": 0,
|
||||||
|
"active_employees": 0, "msg_total": 0,
|
||||||
|
"cust_threads": 0}
|
||||||
|
assert body["msg_delta"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- employees
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_default_sort_and_paging(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"channel": "all", "start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
assert body["page"] == 1
|
||||||
|
assert body["page_size"] == 20
|
||||||
|
assert body["metric_version"] == "v1"
|
||||||
|
assert body["config_source"] == "default"
|
||||||
|
assert len(body["rows"]) == 3
|
||||||
|
|
||||||
|
# 默认 anomaly_severity 排序:含 critical 的 u2 在最前
|
||||||
|
# (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning)
|
||||||
|
ids = [row["user_id"] for row in body["rows"]]
|
||||||
|
assert ids == ["u2", "u1", "u3"]
|
||||||
|
u2, u1, u3 = body["rows"]
|
||||||
|
assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2
|
||||||
|
assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2
|
||||||
|
assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0
|
||||||
|
|
||||||
|
# 行结构:metrics 全字段 / comparisons 四值 / score 摘要
|
||||||
|
assert u1["name"] == "张三" and u1["is_on_job"] is True
|
||||||
|
assert u1["metrics"]["cust_threads"] == 10
|
||||||
|
assert u1["metrics"]["end_active_friends"] == 3
|
||||||
|
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
|
||||||
|
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
|
||||||
|
assert set(u1["comparisons"]) >= {
|
||||||
|
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
|
||||||
|
"active_friends", "call_count"}
|
||||||
|
assert "active_friends_band" not in u1["comparisons"]
|
||||||
|
assert "call_band" not in u1["comparisons"]
|
||||||
|
for c in u1["comparisons"].values():
|
||||||
|
assert set(c) == {"current", "prev", "baseline", "median"}
|
||||||
|
assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000
|
||||||
|
assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000
|
||||||
|
assert set(u1["score"]["dimensions"]) == {
|
||||||
|
"customer", "service", "action", "workload", "risk"}
|
||||||
|
for d in u1["score"]["dimensions"].values():
|
||||||
|
assert set(d) == {"score", "status"}
|
||||||
|
assert u1["score"]["trial"] is True
|
||||||
|
assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_channel_filter(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"channel": "wechat", "start": START,
|
||||||
|
"end": END})
|
||||||
|
ids = [row["user_id"] for row in r.json()["rows"]]
|
||||||
|
assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"channel": "whatsapp", "start": START,
|
||||||
|
"end": END})
|
||||||
|
ids = [row["user_id"] for row in r.json()["rows"]]
|
||||||
|
assert ids == ["u2", "u3"] # u1 无 WA 账号
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_keyword(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END, "keyword": "张"})
|
||||||
|
assert [row["user_id"] for row in r.json()["rows"]] == ["u1"]
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END, "keyword": "U3"})
|
||||||
|
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_status_filter(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END,
|
||||||
|
"status": "on_job"})
|
||||||
|
assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"]
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END,
|
||||||
|
"status": "offline"})
|
||||||
|
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_min_days_insufficient(make_client):
|
||||||
|
"""B2 评审遗留接线:days_with_data < min_days → score_status 覆盖
|
||||||
|
insufficient_sample,但可评明细(total_score)保留展示。"""
|
||||||
|
|
||||||
|
async def seed(s):
|
||||||
|
s.add(Staff(user_id="u9", name="赵九", is_on_job=True))
|
||||||
|
s.add(WechatAccount(wx_id="wx9", user_id="u9"))
|
||||||
|
# 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分
|
||||||
|
for d in (1, 2):
|
||||||
|
s.add(_mday(20260900 + d, user_id="u9", cust_threads=15,
|
||||||
|
threads_answered=15, fr_sum_ms=900_000,
|
||||||
|
active_friends=10))
|
||||||
|
s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0))
|
||||||
|
s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1))
|
||||||
|
for i in range(6): # 6 个 distinct peer → workload band 可评
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||||
|
peer_kind="friend", peer_id=f"q{i}",
|
||||||
|
day=20260901, send_cnt=1, recv_cnt=0,
|
||||||
|
last_ts=_ts(2026, 9, 1, 12, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = r.json()["rows"][0]
|
||||||
|
assert row["user_id"] == "u9"
|
||||||
|
assert row["metrics"]["days_with_data"] == 2
|
||||||
|
assert row["score"]["score_status"] == "insufficient_sample"
|
||||||
|
assert row["score"]["total_score"] is not None # 明细保留展示
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employees_db_score_config_guard(make_client):
|
||||||
|
"""config_source=db 路径:激活配置的 min_days=9 生效——u1 五天数据
|
||||||
|
在默认配置(min_days=5)下 scored,在 DB 配置下翻为 insufficient_sample。"""
|
||||||
|
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
cfg = copy.deepcopy(DEFAULT_SCORE_CONFIG)
|
||||||
|
cfg["version"] = "v1-test"
|
||||||
|
cfg["name"] = "评审测试配置"
|
||||||
|
cfg["sample_guards"]["min_days"] = 9
|
||||||
|
s.add(EmployeeScoreConfig(version="v1-test", name="评审测试配置",
|
||||||
|
status="active", config_json=cfg,
|
||||||
|
created_by="test"))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees",
|
||||||
|
params={"channel": "all", "start": START,
|
||||||
|
"end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["config_source"] == "db"
|
||||||
|
u1 = next(row for row in body["rows"] if row["user_id"] == "u1")
|
||||||
|
assert u1["metrics"]["days_with_data"] == 5
|
||||||
|
assert u1["score"]["score_status"] == "insufficient_sample" # 5 < 9
|
||||||
|
assert u1["score"]["total_score"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employee_detail_full(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1",
|
||||||
|
params={"channel": "all", "start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["user_id"] == "u1"
|
||||||
|
assert body["name"] == "张三"
|
||||||
|
assert body["accounts"] == [
|
||||||
|
{"channel": "wechat", "account_id": "wx1",
|
||||||
|
"nick_name": "张三的微信", "user_name": "张三"}]
|
||||||
|
# metrics 全字段(22 列 namedtuple)
|
||||||
|
assert len(body["metrics"]) == 22
|
||||||
|
assert body["metrics"]["cust_threads"] == 10
|
||||||
|
# score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平
|
||||||
|
assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][
|
||||||
|
"score"] == 100.0
|
||||||
|
assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000
|
||||||
|
assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2
|
||||||
|
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
|
||||||
|
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠:
|
||||||
|
# 5 天 × msg_sent 14 = 70);详情端点 anomalies 不带 employee_name
|
||||||
|
# (自指无意义,仅 dashboard enrich)
|
||||||
|
assert len(body["anomalies"]) == 2
|
||||||
|
assert all(a["severity"] == "warning" for a in body["anomalies"])
|
||||||
|
assert all("employee_name" not in a for a in body["anomalies"])
|
||||||
|
assert body["prev"]["msg_sent"] == 70
|
||||||
|
assert body["baseline"]["msg_sent"] == 70
|
||||||
|
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sections
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employee_sections(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
base = "/api/management/employees/u1/metrics"
|
||||||
|
for section in ("overview", "service", "customer", "workload", "calls"):
|
||||||
|
r = await client.get(base, params={
|
||||||
|
"section": section, "channel": "wechat",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 200, section
|
||||||
|
body = r.json()
|
||||||
|
assert body["section"] == section
|
||||||
|
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
|
||||||
|
assert all("day" in t for t in body["trend"])
|
||||||
|
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
|
||||||
|
assert set(body["comparisons"]["reply_2h_rate"]) == {
|
||||||
|
"current", "prev", "baseline", "median"}
|
||||||
|
|
||||||
|
r = await client.get(base, params={"section": "service",
|
||||||
|
"channel": "wechat",
|
||||||
|
"start": START, "end": END})
|
||||||
|
body = r.json()
|
||||||
|
assert body["metrics"]["cust_threads"] == 10
|
||||||
|
assert body["metrics"]["reply_2h_count"] == 2
|
||||||
|
t0 = body["trend"][0]
|
||||||
|
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
|
||||||
|
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
|
||||||
|
"first_reply_p90_ms": 220_000}
|
||||||
|
|
||||||
|
r = await client.get(base, params={"section": "calls", "channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
calls = {t["day"]: t for t in r.json()["trend"]}
|
||||||
|
assert calls[20260901]["call_count"] == 2
|
||||||
|
assert calls[20260901]["call_followup_count"] == 1
|
||||||
|
assert calls[20260905]["call_count"] == 0 # 零填充
|
||||||
|
assert r.json()["metrics"]["call_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- drilldown
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_fr_p90(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
url = "/api/management/employees/u2/drilldown"
|
||||||
|
r = await client.get(url, params={
|
||||||
|
"metric_code": "first_reply_p90", "channel": "all",
|
||||||
|
"start": START, "end": END, "page": 1, "page_size": 1})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
assert len(body["items"]) == 1
|
||||||
|
item = body["items"][0]
|
||||||
|
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
|
||||||
|
assert item["fr_ms"] == 600_000
|
||||||
|
assert item["peer_id"] == "pD"
|
||||||
|
assert item["channel"] == "wechat"
|
||||||
|
assert item["owner_account_id"] == "wx2"
|
||||||
|
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
|
||||||
|
r = await client.get(url, params={
|
||||||
|
"metric_code": "first_reply_p90", "channel": "all",
|
||||||
|
"start": START, "end": END, "page": 2, "page_size": 1})
|
||||||
|
assert r.json()["items"][0]["peer_id"] == "pC"
|
||||||
|
# u2 的 WA 账号无 fact 行
|
||||||
|
r = await client.get(url, params={
|
||||||
|
"metric_code": "first_reply_p50", "channel": "whatsapp",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.json()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_unreplied(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "unreplied",
|
||||||
|
"channel": "wechat",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
|
||||||
|
assert body["items"][0]["peer_id"] == "p1"
|
||||||
|
assert body["items"][0]["owner_account_id"] == "wx1"
|
||||||
|
# u2 名下无未回候选(p3 尾部为员工消息)
|
||||||
|
r = await client.get("/api/management/employees/u2/drilldown",
|
||||||
|
params={"metric_code": "unreplied",
|
||||||
|
"channel": "wechat",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.json()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_lifecycle_customers(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "cooling_customers",
|
||||||
|
"channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
|
||||||
|
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
|
||||||
|
"nickname": "客户二",
|
||||||
|
"last_day": 20260825, "state": "cooling"}
|
||||||
|
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "dormant_customers",
|
||||||
|
"channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["items"][0]["friend_id"] == "f1"
|
||||||
|
assert body["items"][0]["state"] == "dormant"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_shared_customer_history(make_client):
|
||||||
|
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
|
||||||
|
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
|
||||||
|
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
|
||||||
|
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
for day, month in ((20260701, 7), (20260801, 8)):
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
|
||||||
|
peer_kind="friend", peer_id="f3", day=day,
|
||||||
|
send_cnt=3, recv_cnt=3,
|
||||||
|
last_ts=_ts(2026, month, day % 100, 12, 0),
|
||||||
|
last_is_send=True))
|
||||||
|
s.add(Customer(friend_id="f3", channel="wechat",
|
||||||
|
owner_account_id="wx1", friend_nickname="共享客户",
|
||||||
|
user_id="u1", delete_flag=0))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "dormant_customers",
|
||||||
|
"channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
|
||||||
|
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
|
||||||
|
assert f3 == {"friend_id": "f3", "channel": "wechat",
|
||||||
|
"nickname": "共享客户",
|
||||||
|
"last_day": 20260801, "state": "dormant"}
|
||||||
|
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
|
||||||
|
r = await client.get("/api/management/employees/u2/drilldown",
|
||||||
|
params={"metric_code": "dormant_customers",
|
||||||
|
"channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.json()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_drilldown_call_followup(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/employees/u1/drilldown",
|
||||||
|
params={"metric_code": "call_followup",
|
||||||
|
"channel": "all",
|
||||||
|
"start": START, "end": END})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
# start_call_time desc:c2(0902)在前
|
||||||
|
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
|
||||||
|
assert set(body["items"][0]) == {
|
||||||
|
"call_id", "channel", "call_number", "start_call_time",
|
||||||
|
"call_duration"}
|
||||||
|
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
|
||||||
|
"answered_call_count": 1,
|
||||||
|
"call_followup_count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- metric-definitions
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_definitions(make_client):
|
||||||
|
async def seed(s):
|
||||||
|
await _seed_common(s)
|
||||||
|
|
||||||
|
client = await make_client(seed)
|
||||||
|
r = await client.get("/api/management/metric-definitions")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["definitions"]["cust_threads"]["unit"] == "串"
|
||||||
|
assert body["score_config"]["trial"] is True
|
||||||
|
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
|
||||||
|
assert body["config_source"] == "default" # 未种子配置→回退默认
|
||||||
|
assert "1:1" in body["caliber_notes"]
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""管理域 6 张新表的模型冒烟测试(批次A Task1)。
|
||||||
|
|
||||||
|
只验证:建表可用、主键/唯一约束生效、默认值正确。业务逻辑在
|
||||||
|
first_reply_facts / employee_metrics 的测试里。
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
EmployeeScoreConfig,
|
||||||
|
EmployeeScoreSnapshot,
|
||||||
|
FirstReplyFact,
|
||||||
|
ManagementFollowup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_first_reply_fact_roundtrip(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
|
||||||
|
thread_end_ts=1_700_000_000_000,
|
||||||
|
reply_ts=1_700_000_600_000, reply_msg_id="m1",
|
||||||
|
day=20260901, fr_ms=600_000))
|
||||||
|
await s.commit()
|
||||||
|
val = (await s.execute(sa.select(FirstReplyFact.fr_ms))).scalar_one()
|
||||||
|
assert val == 600_000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_first_reply_fact_unique_sample(make_session):
|
||||||
|
s = make_session
|
||||||
|
kwargs = {"channel": "wechat", "owner_account_id": "wx1", "peer_id": "p1",
|
||||||
|
"thread_end_ts": 1, "reply_ts": 2, "reply_msg_id": "m1",
|
||||||
|
"day": 20260901, "fr_ms": 1}
|
||||||
|
s.add(FirstReplyFact(**kwargs))
|
||||||
|
await s.commit()
|
||||||
|
s.add(FirstReplyFact(**kwargs))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_versioning_coexists(make_session):
|
||||||
|
s = make_session
|
||||||
|
for v in ("v1", "v2"):
|
||||||
|
s.add(EmployeeMetricDaily(day=20260901, channel="wechat",
|
||||||
|
user_id="u1", metric_version=v))
|
||||||
|
await s.commit()
|
||||||
|
n = (await s.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(EmployeeMetricDaily))).scalar_one()
|
||||||
|
assert n == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_defaults_zero_not_none(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
|
||||||
|
await s.commit()
|
||||||
|
row = (await s.execute(sa.select(EmployeeMetricDaily))).scalar_one()
|
||||||
|
assert row.msg_sent == 0 and row.cust_threads == 0 and row.fr_sum_ms == 0
|
||||||
|
assert row.metric_version == "v1"
|
||||||
|
assert row.data_complete is False or row.data_complete == 0 # SQLite 0/1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metric_daily_pk_duplicate_rejected(make_session):
|
||||||
|
"""同 (day, channel, user_id, metric_version) 复合主键防重。
|
||||||
|
|
||||||
|
Task 6 delete+insert 幂等重算的安全网:版本内重复落表直接 IntegrityError。
|
||||||
|
"""
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
|
||||||
|
await s.commit()
|
||||||
|
s.add(EmployeeMetricDaily(day=20260901, channel="wechat", user_id="u1"))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_daily_roundtrip(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
|
||||||
|
call_seconds=164, answered_call_count=1,
|
||||||
|
call_followup_count=1))
|
||||||
|
await s.commit()
|
||||||
|
row = (await s.execute(sa.select(EmployeeCallDaily))).scalar_one()
|
||||||
|
assert row.call_seconds == 164
|
||||||
|
|
||||||
|
|
||||||
|
async def test_call_daily_pk_duplicate_rejected(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u1"))
|
||||||
|
await s.commit()
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u1"))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_score_config_version_unique(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeScoreConfig(version="v1.0", name="一期试算",
|
||||||
|
config_json={"dimensions": {}}))
|
||||||
|
await s.commit()
|
||||||
|
s.add(EmployeeScoreConfig(version="v1.0", name="重复",
|
||||||
|
config_json={}))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_score_config_config_json_not_null(make_session):
|
||||||
|
"""config_json NOT NULL——注解与 DDL 一致(修复:去掉 | None)。
|
||||||
|
|
||||||
|
注意 JSON 列语义:Python None 经 JSON 序列化变成字符串 'null' 而非
|
||||||
|
SQL NULL,不会违反约束;测 NOT NULL 须用 sa.null() 显式发 SQL NULL。
|
||||||
|
"""
|
||||||
|
s = make_session
|
||||||
|
s.add(EmployeeScoreConfig(version="v1.0", name="空配置",
|
||||||
|
config_json=sa.null()))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_batch_user_unique(make_session):
|
||||||
|
s = make_session
|
||||||
|
common = {"snapshot_batch_id": "b1", "period_type": "week",
|
||||||
|
"period_start": 20260901, "period_end": 20260907,
|
||||||
|
"score_config_version": "v1.0", "metric_version": "v1",
|
||||||
|
"data_as_of": 20260907}
|
||||||
|
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
|
||||||
|
await s.commit()
|
||||||
|
s.add(EmployeeScoreSnapshot(user_id="u1", **common))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_followup_defaults(make_session):
|
||||||
|
s = make_session
|
||||||
|
s.add(ManagementFollowup(employee_user_id="u1", source_type="manual"))
|
||||||
|
await s.commit()
|
||||||
|
row = (await s.execute(sa.select(ManagementFollowup))).scalar_one()
|
||||||
|
assert row.status == "open"
|
||||||
|
assert row.created_at is not None
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""管理域期聚合查询判别测试(批次B Task B1)。
|
||||||
|
|
||||||
|
判据(v2 §3 / B1 规格):
|
||||||
|
- 分位=fact 表最近邻秩 ceil(q·n) 精确值(整除算术,无 ceil 方言坑),
|
||||||
|
多账号/跨渠道样本按员工合并;
|
||||||
|
- 宽表 SUM 按 (day∈窗, metric_version='v1', channel) 过滤;
|
||||||
|
active_rooms_avg 摊到全窗天数(含无数据日);
|
||||||
|
- 期末生命周期存量=每 (user, channel) 最新 ≤end 行,跨渠道相加;
|
||||||
|
- distinct 活跃客户=窗内 (uid, peer) 去重(peer_kind='friend')——
|
||||||
|
同一员工多账号的共享客户只计 1(与宽表日值口径一致);
|
||||||
|
- 窗口工具:闭区间 day_range/等长前窗/28 天个人基线窗;
|
||||||
|
- 幽灵员工(不在 staff)过滤;user_ids 交集;通话恒全渠道。
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
EmployeeCallDaily,
|
||||||
|
EmployeeMetricDaily,
|
||||||
|
FirstReplyFact,
|
||||||
|
MsgDailyStats,
|
||||||
|
Staff,
|
||||||
|
WechatAccount,
|
||||||
|
WhatsappAccount,
|
||||||
|
)
|
||||||
|
from app.management.employee_metrics import (
|
||||||
|
PeriodMetrics,
|
||||||
|
baseline_window,
|
||||||
|
day_range,
|
||||||
|
period_metrics,
|
||||||
|
prev_window,
|
||||||
|
team_medians,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(*args) -> int:
|
||||||
|
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_org(s, users=("u1", "u2")):
|
||||||
|
for u in users:
|
||||||
|
s.add(Staff(user_id=u, name=u, is_on_job=True))
|
||||||
|
s.add(WechatAccount(wx_id="wx1", user_id="u1"))
|
||||||
|
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
|
||||||
|
cols = {
|
||||||
|
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
|
||||||
|
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||||
|
"new_customer_count": 0, "cooling_customer_count": 0,
|
||||||
|
"dormant_customer_count": 0, "reactivated_customer_count": 0,
|
||||||
|
"data_complete": True,
|
||||||
|
}
|
||||||
|
cols.update(kw)
|
||||||
|
return EmployeeMetricDaily(
|
||||||
|
day=day, channel=channel, user_id=user_id,
|
||||||
|
metric_version=metric_version, **cols
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
|
||||||
|
end = _ts(2026, 9, 1, 10, 0) + i * 60_000
|
||||||
|
return FirstReplyFact(
|
||||||
|
channel=channel, owner_account_id=owner, peer_id=peer,
|
||||||
|
thread_end_ts=end, reply_ts=end + fr_ms,
|
||||||
|
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_percentile_exact(make_session):
|
||||||
|
"""5 样本 p50=180s/p90=300s(ceil(2.5)=3、ceil(4.5)=5,1-based);
|
||||||
|
2 样本 p50=10s/p90=20s;窗外行不参与。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
for i, sec in enumerate([60, 120, 180, 240, 300]):
|
||||||
|
s.add(_fact(sec * 1000, i=i))
|
||||||
|
for i, sec in enumerate([10, 20]):
|
||||||
|
s.add(_fact(sec * 1000, owner="wx2", peer="q1", i=i))
|
||||||
|
# 窗外(0906>end)长尾:混入则 p90=999s
|
||||||
|
s.add(_fact(999_000, i=9, day=20260906))
|
||||||
|
await s.commit()
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||||
|
assert m["u1"].fr_p50_ms == 180_000
|
||||||
|
assert m["u1"].fr_p90_ms == 300_000
|
||||||
|
assert m["u2"].fr_p50_ms == 10_000
|
||||||
|
assert m["u2"].fr_p90_ms == 20_000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_period_sums_vs_daily_rows(make_session):
|
||||||
|
"""宽表两天行 SUM 正确;days_with_data=2;active_rooms_avg 摊到全窗
|
||||||
|
5 天(含无数据日);channel/metric_version/窗外行都不计。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(_mday(20260901, msg_sent=3, msg_recv=5, cust_threads=2,
|
||||||
|
threads_answered=1, fr_sum_ms=7_000, active_rooms=4,
|
||||||
|
reactivated_customer_count=1))
|
||||||
|
s.add(_mday(20260903, msg_sent=4, msg_recv=6, cust_threads=3,
|
||||||
|
threads_answered=2, fr_sum_ms=11_000, active_rooms=6,
|
||||||
|
reactivated_customer_count=2))
|
||||||
|
# 反例三连:错渠道 / 错版本 / 窗外
|
||||||
|
s.add(_mday(20260901, channel="whatsapp", msg_sent=100))
|
||||||
|
s.add(_mday(20260901, metric_version="v2", msg_sent=200))
|
||||||
|
s.add(_mday(20260906, msg_sent=400))
|
||||||
|
await s.commit()
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||||
|
u1 = m["u1"]
|
||||||
|
assert u1.msg_sent == 7
|
||||||
|
assert u1.msg_recv == 11
|
||||||
|
assert u1.cust_threads == 5
|
||||||
|
assert u1.threads_answered == 3
|
||||||
|
assert u1.fr_sum_ms == 18_000
|
||||||
|
assert u1.days_with_data == 2
|
||||||
|
assert u1.active_rooms_avg == 2.0 # (4+6)/5 全窗天数,非 2 有数据日
|
||||||
|
assert u1.reactivated_customer == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_end_of_period_stock(make_session):
|
||||||
|
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
|
||||||
|
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
|
||||||
|
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
|
||||||
|
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
|
||||||
|
dormant_customer_count=3, active_friends=7))
|
||||||
|
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
|
||||||
|
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
|
||||||
|
active_friends=3))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
|
||||||
|
assert m1["u1"].new_customer == 1
|
||||||
|
assert m1["u1"].cooling_customer == 2
|
||||||
|
assert m1["u1"].dormant_customer == 3
|
||||||
|
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
|
||||||
|
|
||||||
|
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||||
|
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
|
||||||
|
assert m2["u1"].new_customer == 0
|
||||||
|
assert m2["u1"].end_active_friends == 7
|
||||||
|
|
||||||
|
m3 = await period_metrics(s, 20260901, 20260905, None)
|
||||||
|
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
|
||||||
|
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_distinct_active_cross_day(make_session):
|
||||||
|
"""同 peer 两天两行只计 1;room 行与幽灵 owner 不计。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
for day, peer in [(20260901, "p1"), (20260902, "p1"), (20260902, "p2")]:
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="friend", peer_id=peer, day=day))
|
||||||
|
# room 行不计入活跃客户
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
||||||
|
peer_kind="room", peer_id="r1", day=20260902))
|
||||||
|
# 幽灵 owner(account_map 无映射)不计
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
|
||||||
|
peer_kind="friend", peer_id="p9", day=20260902))
|
||||||
|
await s.commit()
|
||||||
|
m = await period_metrics(s, 20260901, 20260902, "wechat")
|
||||||
|
assert m["u1"].active_friends == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_active_friends_shared_peer_across_accounts(make_session):
|
||||||
|
"""多账号共享客户去重:u1 的 wx1/wx3 都会话 p1 → active_friends=1
|
||||||
|
(账号归并到员工后按 peer 去重,非 (owner, peer) 双计)。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(WechatAccount(wx_id="wx3", user_id="u1"))
|
||||||
|
await s.commit()
|
||||||
|
for owner in ("wx1", "wx3"):
|
||||||
|
s.add(MsgDailyStats(channel="wechat", owner_account_id=owner,
|
||||||
|
peer_kind="friend", peer_id="p1", day=20260901))
|
||||||
|
await s.commit()
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||||
|
assert m["u1"].active_friends == 1
|
||||||
|
assert m["u2"].active_friends == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_helpers():
|
||||||
|
# day_range 闭区间 + 月末边界
|
||||||
|
assert day_range(20260901, 20260903) == [20260901, 20260902, 20260903]
|
||||||
|
assert day_range(20260831, 20260901) == [20260831, 20260901]
|
||||||
|
# prev_window 等长紧邻前窗(7 天窗 → 7 天窗;单日 → 单日)
|
||||||
|
assert prev_window(20260908, 20260914) == (20260901, 20260907)
|
||||||
|
assert prev_window(20260901, 20260901) == (20260831, 20260831)
|
||||||
|
# baseline_window:紧邻窗前 28 天([0915] → [0818, 0914])
|
||||||
|
assert baseline_window(20260915) == (20260818, 20260914)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ghost_and_channel_none(make_session):
|
||||||
|
"""幽灵(不在 staff)宽表行/fact 行不计;channel=None 合并两渠道
|
||||||
|
amap——u1(wx1+wa1)的宽表 SUM 与 fact 分位都跨渠道归并;
|
||||||
|
无数据员工(u2)也以零值行出现。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(WhatsappAccount(uid="wa1", user_id="u1"))
|
||||||
|
# 账号存在但映射到非 staff 用户 → 幽灵
|
||||||
|
s.add(WechatAccount(wx_id="wxg", user_id="ghost"))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
s.add(_mday(20260901, msg_sent=3))
|
||||||
|
s.add(_mday(20260901, channel="whatsapp", msg_sent=4))
|
||||||
|
s.add(_mday(20260901, user_id="ghost", msg_sent=50))
|
||||||
|
s.add(_fact(100_000, owner="wx1", i=0))
|
||||||
|
s.add(_fact(300_000, channel="whatsapp", owner="wa1", peer="w1", i=1))
|
||||||
|
s.add(_fact(50_000, owner="wxg", i=2))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, None)
|
||||||
|
assert set(m) == {"u1", "u2"} # ghost 不在;零数据 u2 在
|
||||||
|
u1 = m["u1"]
|
||||||
|
assert u1.msg_sent == 7 # 两渠道行都归 u1
|
||||||
|
assert u1.days_with_data == 1 # 双渠道同日只计 1 天(DISTINCT day)
|
||||||
|
assert u1.fr_p50_ms == 100_000 # [100s,300s] 合并后 ceil(0.5*2)=1
|
||||||
|
assert u1.fr_p90_ms == 300_000 # ceil(0.9*2)=2
|
||||||
|
assert u1.reply_2h_count == 2
|
||||||
|
assert u1.reply_24h_count == 2
|
||||||
|
assert m["u2"].msg_sent == 0
|
||||||
|
assert m["u2"].fr_p50_ms is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reply_counts(make_session):
|
||||||
|
"""fr=90min/3h/25h → 2h_count=1、24h_count=2(2h 是 24h 的子集)。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(_fact(5_400_000, i=0)) # 90min ≤2h
|
||||||
|
s.add(_fact(10_800_000, i=1)) # 3h ∈(2h,24h]
|
||||||
|
s.add(_fact(90_000_000, i=2)) # 25h >24h
|
||||||
|
await s.commit()
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||||
|
assert m["u1"].reply_2h_count == 1
|
||||||
|
assert m["u1"].reply_24h_count == 2
|
||||||
|
assert m["u2"].reply_2h_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_ids_filter_and_calls(make_session):
|
||||||
|
"""user_ids 与 staff 求交(未知 id 剔除/空集→{});通话四列按窗 SUM
|
||||||
|
且不受 channel 参数影响(无渠道维,恒全渠道)。"""
|
||||||
|
s = make_session
|
||||||
|
await _seed_org(s)
|
||||||
|
s.add(_mday(20260901, user_id="u1", msg_sent=1))
|
||||||
|
s.add(_mday(20260901, user_id="u2", msg_sent=2))
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=3,
|
||||||
|
call_seconds=600, answered_call_count=2,
|
||||||
|
call_followup_count=1))
|
||||||
|
s.add(EmployeeCallDaily(day=20260902, user_id="u1", call_count=1,
|
||||||
|
call_seconds=60, answered_call_count=1,
|
||||||
|
call_followup_count=0))
|
||||||
|
s.add(EmployeeCallDaily(day=20260901, user_id="u2", call_count=9))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
m = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=["u1", "nobody"])
|
||||||
|
assert set(m) == {"u1"}
|
||||||
|
assert m["u1"].msg_sent == 1
|
||||||
|
u1 = m["u1"]
|
||||||
|
assert u1.call_count == 4
|
||||||
|
assert u1.call_seconds == 660
|
||||||
|
assert u1.answered_call == 3
|
||||||
|
assert u1.call_followup == 1
|
||||||
|
|
||||||
|
empty = await period_metrics(s, 20260901, 20260905, "wechat", user_ids=[])
|
||||||
|
assert empty == {}
|
||||||
|
|
||||||
|
# channel="whatsapp" 时通话仍计入(恒全渠道)
|
||||||
|
mwa = await period_metrics(s, 20260901, 20260905, "whatsapp")
|
||||||
|
assert mwa["u1"].call_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_medians():
|
||||||
|
"""奇数样本取中位、偶数取均值;None 剔除;全 None → None;user_id 不参与。"""
|
||||||
|
|
||||||
|
def _pm(**kw):
|
||||||
|
base = dict(zip(PeriodMetrics._fields, [None] * len(PeriodMetrics._fields)))
|
||||||
|
base.update(user_id="x", days_with_data=0, msg_sent=0, msg_recv=0,
|
||||||
|
cust_threads=0, threads_answered=0, fr_sum_ms=0,
|
||||||
|
reply_2h_count=0, reply_24h_count=0, active_friends=0,
|
||||||
|
active_rooms_avg=0.0, new_customer=0, cooling_customer=0,
|
||||||
|
dormant_customer=0, reactivated_customer=0,
|
||||||
|
call_count=0, call_seconds=0, answered_call=0,
|
||||||
|
call_followup=0)
|
||||||
|
base.update(kw)
|
||||||
|
return PeriodMetrics(**base)
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
"u1": _pm(user_id="u1", msg_sent=10, fr_p50_ms=None),
|
||||||
|
"u2": _pm(user_id="u2", msg_sent=20, fr_p50_ms=100),
|
||||||
|
"u3": _pm(user_id="u3", msg_sent=30, fr_p50_ms=200),
|
||||||
|
}
|
||||||
|
med = team_medians(metrics)
|
||||||
|
assert med["msg_sent"] == 20
|
||||||
|
assert med["fr_p50_ms"] == 150.0 # None 剔除后 [100,200] 均值
|
||||||
|
assert "user_id" not in med
|
||||||
|
assert team_medians({})["msg_sent"] is None
|
||||||
|
assert team_medians({"u1": _pm()})["fr_p50_ms"] is None
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""管理域评分配置/引擎判别测试(批次B Task B2)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from app.db.models import EmployeeScoreConfig
|
||||||
|
from app.management.employee_metrics import PeriodMetrics
|
||||||
|
from app.management.metric_definitions import (
|
||||||
|
DEFAULT_SCORE_CONFIG,
|
||||||
|
METRIC_DEFINITIONS,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
from app.management.scoring import normalize, score_employee
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_weights_sum_and_structure():
|
||||||
|
dims = DEFAULT_SCORE_CONFIG["dimensions"]
|
||||||
|
assert sum(d["weight"] for d in dims.values()) == 100
|
||||||
|
# 一期 pending_data 的维度不参与试算
|
||||||
|
available = [d for d in dims.values() if d.get("status") != "pending_data"]
|
||||||
|
assert sum(d["weight"] for d in available) == 70 # 30+25+15
|
||||||
|
assert dims["action"]["status"] == "pending_data"
|
||||||
|
assert dims["risk"]["status"] == "pending_data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_normalization_declared():
|
||||||
|
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_rejects_bad_weight_sum():
|
||||||
|
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||||
|
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
|
||||||
|
problems = validate_config(bad)
|
||||||
|
assert any("权重" in p for p in problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_definitions_cover_scored_metrics():
|
||||||
|
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
|
||||||
|
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
|
||||||
|
for code in dim.get("metrics", {}):
|
||||||
|
# band 型指标的 key 带 _band 后缀,spec 里有 metric 字段:
|
||||||
|
spec = dim["metrics"][code]
|
||||||
|
src = spec.get("metric", code)
|
||||||
|
assert src in METRIC_DEFINITIONS
|
||||||
|
m = METRIC_DEFINITIONS[src]
|
||||||
|
assert m["name"] and m["unit"] and m["direction"] in (1, -1)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_and_idempotent(make_session):
|
||||||
|
from app.management.metric_definitions import _seed_score_config # 纯会话函数
|
||||||
|
s = make_session
|
||||||
|
v1 = await _seed_score_config(s)
|
||||||
|
await s.commit()
|
||||||
|
assert v1 == "v1.0"
|
||||||
|
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
|
||||||
|
assert len(rows) == 1 and rows[0].status == "active"
|
||||||
|
# 幂等:已存在同版本返回 None 不新建
|
||||||
|
assert await _seed_score_config(s) is None
|
||||||
|
await s.commit()
|
||||||
|
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 评分引擎(Task B2) ----
|
||||||
|
|
||||||
|
def _pm(**overrides):
|
||||||
|
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。"""
|
||||||
|
base = {
|
||||||
|
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
|
||||||
|
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
|
||||||
|
"fr_p50_ms": None, "fr_p90_ms": None,
|
||||||
|
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||||
|
"active_friends": 0, "active_rooms_avg": 0.0,
|
||||||
|
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
|
||||||
|
"end_active_friends": 0, "reactivated_customer": 0,
|
||||||
|
"call_count": 0, "call_seconds": 0, "answered_call": 0,
|
||||||
|
"call_followup": 0,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return PeriodMetrics(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_marks(**overrides):
|
||||||
|
"""三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。"""
|
||||||
|
full = dict(
|
||||||
|
cust_threads=50, reply_2h_count=50, reply_24h_count=50,
|
||||||
|
fr_p90_ms=600_000, # 10min ≤ 30min 满分线
|
||||||
|
end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6
|
||||||
|
active_friends=50, # band [5,80] 内且样本 ok
|
||||||
|
call_count=20, # band [1,200] 内
|
||||||
|
)
|
||||||
|
full.update(overrides)
|
||||||
|
return _pm(**full)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_target_three_segments():
|
||||||
|
spec = {"type": "target", "floor": 0.40, "target": 0.85}
|
||||||
|
assert normalize(spec, 0.20) == 0.0 # ≤floor
|
||||||
|
assert normalize(spec, 0.40) == 0.0 # floor 处 0
|
||||||
|
assert normalize(spec, 0.625) == 50.0 # 中点 50
|
||||||
|
assert normalize(spec, 0.85) == 100.0 # target 处 100
|
||||||
|
assert normalize(spec, 0.99) == 100.0 # ≥target 截断
|
||||||
|
assert normalize(spec, None) is None # 无值≠0 分
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_reverse_ends_and_midpoint():
|
||||||
|
spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000}
|
||||||
|
assert normalize(spec, 1_800_000) == 100.0 # 30min 满分
|
||||||
|
assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100
|
||||||
|
assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50
|
||||||
|
assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分
|
||||||
|
assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0
|
||||||
|
assert normalize(spec, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_band_three_segments():
|
||||||
|
spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80,
|
||||||
|
"basis": "period_distinct"}
|
||||||
|
assert normalize(spec, 3) is None # <low 负载不足→不评分
|
||||||
|
assert normalize(spec, 4.9) is None
|
||||||
|
assert normalize(spec, 5) == 100.0 # 含下限
|
||||||
|
assert normalize(spec, 80) == 100.0 # 含上限
|
||||||
|
assert normalize(spec, 81) == 80.0 # 超上限 80 不再加
|
||||||
|
assert normalize(spec, 500) == 80.0
|
||||||
|
assert normalize(spec, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_threads_rates_none_not_zero():
|
||||||
|
"""cust_threads=0 → 回复率无值(None),不是 0%;service 维度 pending。"""
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=0))
|
||||||
|
for code in ("reply_2h_rate", "reply_24h_rate"):
|
||||||
|
md = res["metrics_detail"][code]
|
||||||
|
assert md["value"] is None
|
||||||
|
assert md["score"] is None
|
||||||
|
assert res["dimensions"]["service"]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_sample_employee():
|
||||||
|
"""cust_threads=5(<soft 10):service 维度 insufficient(率有值 0.0 但
|
||||||
|
不评分);customer 期末无存量 pending;workload 样本 af=0 也不足——
|
||||||
|
可用维度=0 → 整员工不评分。"""
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=5))
|
||||||
|
assert res["dimensions"]["service"]["status"] == "insufficient"
|
||||||
|
assert res["dimensions"]["service"]["score"] is None
|
||||||
|
assert res["dimensions"]["customer"]["status"] == "pending"
|
||||||
|
assert res["dimensions"]["workload"]["status"] == "insufficient"
|
||||||
|
assert res["total_score"] is None
|
||||||
|
assert res["grade"] is None
|
||||||
|
assert res["score_status"] == "insufficient_sample"
|
||||||
|
md = res["metrics_detail"]["reply_2h_rate"]
|
||||||
|
assert md["value"] == 0.0 and md["score"] is None
|
||||||
|
assert md["sample_size"] == 5 and md["sample_status"] == "insufficient"
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_confidence_still_scored():
|
||||||
|
"""cust_threads=15 ∈ [soft10, min30):照评但逐指标 low_confidence。"""
|
||||||
|
cur = _pm(cust_threads=15, reply_2h_count=15, reply_24h_count=15,
|
||||||
|
fr_p90_ms=600_000)
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||||
|
svc = res["dimensions"]["service"]
|
||||||
|
assert svc["status"] == "scored" and svc["score"] == 100.0
|
||||||
|
for code in ("reply_2h_rate", "reply_24h_rate", "fr_p90_ms"):
|
||||||
|
assert svc["metrics"][code]["sample_status"] == "low_confidence"
|
||||||
|
assert svc["metrics"][code]["score"] == 100.0
|
||||||
|
# 仅 service 可用(25/25)→ 重归一后仍满分
|
||||||
|
assert res["score_status"] == "scored"
|
||||||
|
assert res["total_score"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_marks_rescale_100_of_70():
|
||||||
|
"""三可用维度(权重 30+25+15=70)全满分 → 100/70 重归一 = 100,A。"""
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||||
|
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||||
|
assert res["dimensions"]["service"]["score"] == 100.0
|
||||||
|
assert res["dimensions"]["workload"]["score"] == 100.0
|
||||||
|
assert res["total_score"] == 100
|
||||||
|
assert res["grade"] == "A"
|
||||||
|
assert res["score_status"] == "scored"
|
||||||
|
# 部分:service 满分但 active_share=0(floor 下)→ (0+25+15)/70=57 → E
|
||||||
|
res2 = score_employee(
|
||||||
|
DEFAULT_SCORE_CONFIG,
|
||||||
|
_full_marks(end_active_friends=1, cooling_customer=10,
|
||||||
|
dormant_customer=10), # 1/21≈0.05 ≤ floor 0.20 → 0 分
|
||||||
|
)
|
||||||
|
assert res2["total_score"] == 57
|
||||||
|
assert res2["grade"] == "E"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_dimensions_not_scored():
|
||||||
|
"""action/risk 一期 pending_data:status=pending、无 metrics、不参与归一。"""
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
|
||||||
|
for key in ("action", "risk"):
|
||||||
|
d = res["dimensions"][key]
|
||||||
|
assert d["status"] == "pending"
|
||||||
|
assert d["score"] is None
|
||||||
|
assert "metrics" not in d
|
||||||
|
scored_w = sum(d["weight"] for d in res["dimensions"].values()
|
||||||
|
if d["status"] == "scored")
|
||||||
|
assert scored_w == 70 # pending 的 30 权重不进分母
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_share_derivation():
|
||||||
|
"""end_active 6/(6+cooling3+dormant1)=0.6 → 达 target 线 100 分。"""
|
||||||
|
cur = _pm(cust_threads=50, end_active_friends=6,
|
||||||
|
cooling_customer=3, dormant_customer=1)
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
|
||||||
|
md = res["metrics_detail"]["active_share"]
|
||||||
|
assert md["value"] == 0.6
|
||||||
|
assert md["score"] == 100.0
|
||||||
|
assert res["dimensions"]["customer"]["score"] == 100.0
|
||||||
|
assert md["sample_status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_detail_four_compare_slots():
|
||||||
|
"""value/prev/baseline/median 四对比位透出;band 明细的 median 按
|
||||||
|
源指标名(active_friends)取 team;单位/名称来自元数据。"""
|
||||||
|
cur = _pm(cust_threads=40, reply_2h_count=30, reply_24h_count=38,
|
||||||
|
fr_p90_ms=600_000, end_active_friends=6, cooling_customer=3,
|
||||||
|
dormant_customer=1, active_friends=40, call_count=10)
|
||||||
|
prev = _pm(cust_threads=40, reply_2h_count=20, reply_24h_count=30,
|
||||||
|
fr_p90_ms=3_600_000, end_active_friends=3, cooling_customer=6,
|
||||||
|
dormant_customer=1, active_friends=20, call_count=5)
|
||||||
|
base = _pm(cust_threads=40, reply_2h_count=10)
|
||||||
|
team = {"reply_2h_rate": 0.7, "active_friends": 33.0}
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, cur, baseline=base,
|
||||||
|
team=team, prev=prev)
|
||||||
|
md = res["metrics_detail"]["reply_2h_rate"]
|
||||||
|
assert md["value"] == 0.75
|
||||||
|
assert md["prev"] == 0.5
|
||||||
|
assert md["baseline"] == 0.25
|
||||||
|
assert md["median"] == 0.7
|
||||||
|
assert md["unit"] == "0-1"
|
||||||
|
assert md["name"] == "2h回复率"
|
||||||
|
# band 型明细键=配置 code,median 按源指标名查 team
|
||||||
|
band = res["metrics_detail"]["active_friends_band"]
|
||||||
|
assert band["median"] == 33.0
|
||||||
|
assert band["unit"] == "人"
|
||||||
|
assert res["metrics_detail"]["fr_p90_ms"]["prev"] == 3_600_000
|
||||||
|
assert res["metrics_detail"]["active_share"]["prev"] == 0.3 # 3/(3+6+1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trial_flag_from_config():
|
||||||
|
"""试算标记透传:默认配置恒 True(v2 §0 全默认+试算)。"""
|
||||||
|
res = score_employee(DEFAULT_SCORE_CONFIG, _pm())
|
||||||
|
assert res["trial"] is True
|
||||||
|
# 标记来自 config 而非写死——正式版翻 False 只需改配置
|
||||||
|
no_trial = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||||
|
no_trial["trial"] = False
|
||||||
|
assert score_employee(no_trial, _pm())["trial"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_threshold_shapes():
|
||||||
|
"""阈值形状四查:type 合法/target floor<target/reverse 先后/band 区间。
|
||||||
|
默认配置自身必须通过。"""
|
||||||
|
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||||
|
|
||||||
|
def _bad(mutate):
|
||||||
|
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||||
|
mutate(cfg["dimensions"])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
bad_type = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||||
|
.update(type="linear"))
|
||||||
|
assert any("call_band" in p and "类型非法" in p
|
||||||
|
for p in validate_config(bad_type))
|
||||||
|
|
||||||
|
bad_target = _bad(lambda d: d["customer"]["metrics"]["active_share"]
|
||||||
|
.update(floor=0.70)) # floor>target 0.60
|
||||||
|
assert any("active_share" in p and "floor<target" in p
|
||||||
|
for p in validate_config(bad_target))
|
||||||
|
|
||||||
|
bad_reverse = _bad(lambda d: d["service"]["metrics"]["fr_p90_ms"]
|
||||||
|
.update(target_ms=20_000_000)) # > floor_ms 14.4M
|
||||||
|
assert any("fr_p90_ms" in p and "target_ms<floor_ms" in p
|
||||||
|
for p in validate_config(bad_reverse))
|
||||||
|
|
||||||
|
bad_band = _bad(lambda d: d["workload"]["metrics"]["call_band"]
|
||||||
|
.update(low=300)) # low>high 200
|
||||||
|
assert any("call_band" in p and "low<high" in p
|
||||||
|
for p in validate_config(bad_band))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_grade_bounds_shape():
|
||||||
|
"""grade_bounds 须含 A/B/C/D 且严格降序(B4 DB 配置加载预埋)。"""
|
||||||
|
missing = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||||
|
del missing["grade_bounds"]["A"]
|
||||||
|
assert any("grade_bounds" in p for p in validate_config(missing))
|
||||||
|
|
||||||
|
non_desc = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||||
|
non_desc["grade_bounds"].update(A=80, B=90) # A<B 非降序
|
||||||
|
assert any("grade_bounds" in p and "严格降序" in p
|
||||||
|
for p in validate_config(non_desc))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_cross_dimension_code_unique():
|
||||||
|
"""同指标 code 出现于两维度:metrics_detail 扁平 dict 会碰撞,须报问题。"""
|
||||||
|
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
|
||||||
|
# 把 customer 维的 active_share 整个抄进 service 维 → code 跨维度重复
|
||||||
|
dup["dimensions"]["service"]["metrics"]["active_share"] = \
|
||||||
|
dup["dimensions"]["customer"]["metrics"]["active_share"]
|
||||||
|
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_source_name_unique():
|
||||||
|
"""两个 code 指向同一源指标:comparisons 按源名建键会静默覆盖,须报问题。"""
|
||||||
|
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
|
||||||
|
# service 里造一个不同 code 但同指向 active_friends 的 band(源名冲突)
|
||||||
|
dup["dimensions"]["service"]["metrics"]["load_band"] = {
|
||||||
|
"type": "band", "metric": "active_friends", "low": 1, "high": 50,
|
||||||
|
}
|
||||||
|
assert any("同一源指标 active_friends" in p for p in validate_config(dup))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_default_config_clean():
|
||||||
|
"""默认配置自身必须全绿(空问题列表)。"""
|
||||||
|
assert validate_config(DEFAULT_SCORE_CONFIG) == []
|
||||||
@@ -25,6 +25,7 @@ from app.db.base import Base
|
|||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
ChatGroup,
|
ChatGroup,
|
||||||
Customer,
|
Customer,
|
||||||
|
CustomerAccountLink,
|
||||||
Department,
|
Department,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
Message,
|
Message,
|
||||||
@@ -39,6 +40,7 @@ from app.storage.repos import (
|
|||||||
_build_column_values,
|
_build_column_values,
|
||||||
_coerce_bool,
|
_coerce_bool,
|
||||||
_coerce_int,
|
_coerce_int,
|
||||||
|
prune_customer_links,
|
||||||
upsert_chat_group,
|
upsert_chat_group,
|
||||||
upsert_customer,
|
upsert_customer,
|
||||||
upsert_customers,
|
upsert_customers,
|
||||||
@@ -107,6 +109,116 @@ def test_coerce_int_returns_none_for_non_numeric_string():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_customer_shared_friend_maintains_links():
|
||||||
|
"""共享好友:档案全局单行(last-wins),link 每个账号各一行(2026-09-08)。
|
||||||
|
|
||||||
|
同一 friend_id 先后在 ownerA/ownerB 名下 upsert——customer 只有一行
|
||||||
|
且归属=后写;customer_account_link 两行,各自带自己的备注快照与
|
||||||
|
fromType(来自 raw_json)。
|
||||||
|
"""
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
base = {
|
||||||
|
"friend_id": "wxid_shared",
|
||||||
|
"channel": "wechat",
|
||||||
|
"friend_nickname": "共享客户",
|
||||||
|
"phone": "13800000000",
|
||||||
|
"send_num": 10,
|
||||||
|
"receive_num": 4,
|
||||||
|
}
|
||||||
|
await upsert_customer(session, {
|
||||||
|
**base, "owner_account_id": "wx_a",
|
||||||
|
"friend_remark": "A的备注", "raw_json": {"fromType": 1},
|
||||||
|
})
|
||||||
|
await session.commit()
|
||||||
|
await upsert_customer(session, {
|
||||||
|
**base, "owner_account_id": "wx_b",
|
||||||
|
"friend_remark": "B的备注", "raw_json": {"fromType": 2},
|
||||||
|
})
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
custs = (await session.execute(
|
||||||
|
sa.select(Customer).execution_options(populate_existing=True)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(custs) == 1
|
||||||
|
assert custs[0].owner_account_id == "wx_b" # 档案 last-wins
|
||||||
|
|
||||||
|
links = (await session.execute(
|
||||||
|
sa.select(CustomerAccountLink).execution_options(populate_existing=True)
|
||||||
|
)).scalars().all()
|
||||||
|
assert {(l.owner_account_id, l.friend_remark) for l in links} == {
|
||||||
|
("wx_a", "A的备注"), ("wx_b", "B的备注"),
|
||||||
|
}
|
||||||
|
by_owner = {l.owner_account_id: l for l in links}
|
||||||
|
assert by_owner["wx_a"].from_type == 1
|
||||||
|
assert by_owner["wx_b"].from_type == 2
|
||||||
|
assert by_owner["wx_a"].send_num == 10
|
||||||
|
assert by_owner["wx_a"].phone == "13800000000"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_customer_without_owner_skips_link():
|
||||||
|
"""无 owner_account_id 的档案 upsert(理论路径)只写 customer,不碰 link。"""
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await upsert_customer(session, {
|
||||||
|
"friend_id": "wxid_solo", "channel": "wechat",
|
||||||
|
})
|
||||||
|
await session.commit()
|
||||||
|
n_links = (await session.execute(
|
||||||
|
sa.select(sa.func.count()).select_from(CustomerAccountLink)
|
||||||
|
)).scalar_one()
|
||||||
|
assert n_links == 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_prune_customer_links_removes_departed():
|
||||||
|
"""对账:保留名单外的 link 被删;空名单删光;他人名下不动。"""
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
for owner, fid in [("wx_a", "f1"), ("wx_a", "f2"), ("wx_a", "f3"),
|
||||||
|
("wx_b", "f2")]:
|
||||||
|
await upsert_customer(session, {
|
||||||
|
"friend_id": fid, "channel": "wechat",
|
||||||
|
"owner_account_id": owner,
|
||||||
|
})
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
deleted = await prune_customer_links(
|
||||||
|
session, channel="wechat", owner_account_id="wx_a",
|
||||||
|
keep_friend_ids=["f1", "f3"],
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
assert deleted == 1
|
||||||
|
|
||||||
|
left = (await session.execute(
|
||||||
|
sa.select(CustomerAccountLink.friend_id, CustomerAccountLink.owner_account_id)
|
||||||
|
)).all()
|
||||||
|
assert set(left) == {("f1", "wx_a"), ("f3", "wx_a"), ("f2", "wx_b")}
|
||||||
|
|
||||||
|
# 空名单=该账号全部删除(wx_b 的保留)
|
||||||
|
deleted_all = await prune_customer_links(
|
||||||
|
session, channel="wechat", owner_account_id="wx_a",
|
||||||
|
keep_friend_ids=[],
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
assert deleted_all == 2
|
||||||
|
left2 = (await session.execute(
|
||||||
|
sa.select(CustomerAccountLink.friend_id, CustomerAccountLink.owner_account_id)
|
||||||
|
)).all()
|
||||||
|
assert set(left2) == {("f2", "wx_b")}
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def test_upsert_customer_is_idempotent_and_updates_version():
|
async def test_upsert_customer_is_idempotent_and_updates_version():
|
||||||
"""Upsert same friend_id twice with different version -> 1 row, version updated."""
|
"""Upsert same friend_id twice with different version -> 1 row, version updated."""
|
||||||
engine = await _make_engine()
|
engine = await _make_engine()
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
|
||||||
|
|
||||||
|
行为测试是底线——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.staff_summary import rolling_days
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_today(sched_engine) -> int:
|
||||||
|
"""seed 当日一条 wechat friend 会话 + 敏感词,返回 seed 用的 day 键。"""
|
||||||
|
_, 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()
|
||||||
|
return today
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tick_calls_management_chain_in_order(sched_engine, monkeypatch):
|
||||||
|
"""两段链完整顺序:recompute_days → scan → pairs_active_in_days →
|
||||||
|
recompute_pairs → recompute_metric_days → recompute_call_days。
|
||||||
|
|
||||||
|
记录桩替换全部六个上游——_analytics_tick 段内函数延迟 import,
|
||||||
|
monkeypatch 模块属性即命中(惯例见 test_analytics_tick_swallows_errors)。
|
||||||
|
"""
|
||||||
|
engine, maker = sched_engine
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def rec(name: str, ret):
|
||||||
|
async def _stub(*args, **kwargs):
|
||||||
|
calls.append(name)
|
||||||
|
return ret
|
||||||
|
return _stub
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
|
||||||
|
rec("recompute_days", 0))
|
||||||
|
monkeypatch.setattr("app.analytics.sensitive.scanner.run_incremental",
|
||||||
|
rec("scan", {"scanned": 0, "hits": 0}))
|
||||||
|
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
|
||||||
|
rec("pairs_active_in_days", []))
|
||||||
|
monkeypatch.setattr("app.storage.first_reply_facts.recompute_pairs",
|
||||||
|
rec("recompute_pairs", 0))
|
||||||
|
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
|
||||||
|
rec("recompute_metric_days", 0))
|
||||||
|
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
|
||||||
|
rec("recompute_call_days", 0))
|
||||||
|
|
||||||
|
sch = SyncScheduler(engine=engine, session_maker=maker)
|
||||||
|
await sch._analytics_tick()
|
||||||
|
assert calls == [
|
||||||
|
"recompute_days", "scan", # 段1
|
||||||
|
"pairs_active_in_days", "recompute_pairs", # 段2
|
||||||
|
"recompute_metric_days", "recompute_call_days",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tick_management_failure_isolated(sched_engine, monkeypatch, caplog):
|
||||||
|
"""段2 失败隔离:recompute_metric_days 抛 RuntimeError → ①tick 不抛;
|
||||||
|
②记 "management metrics tick failed";③段1 已提交的 StatDailyStaff
|
||||||
|
不被段2 回滚(两段独立事务)。
|
||||||
|
"""
|
||||||
|
engine, maker = sched_engine
|
||||||
|
await _seed_today(sched_engine)
|
||||||
|
|
||||||
|
async def _boom(*args, **kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_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( # ②段2 独立记日志
|
||||||
|
"management metrics tick failed" in r.getMessage() for r in caplog.records
|
||||||
|
)
|
||||||
|
async with maker() as s: # ③段1 结果已提交
|
||||||
|
n = (await s.execute(
|
||||||
|
select(func.count()).select_from(StatDailyStaff)
|
||||||
|
)).scalar_one()
|
||||||
|
assert n >= 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tick_management_rolling_window(sched_engine, monkeypatch):
|
||||||
|
"""管理域三函数收到的 days == rolling_days(today, 3)(长度 3,当日+前
|
||||||
|
两天),且与 staff 段捕获的窗口一致(两段共用函数顶提取的同一 today)。
|
||||||
|
"""
|
||||||
|
engine, maker = sched_engine
|
||||||
|
got: dict[str, list[int]] = {}
|
||||||
|
|
||||||
|
def cap(name: str, ret):
|
||||||
|
async def _stub(session, days, *args, **kwargs):
|
||||||
|
got[name] = list(days)
|
||||||
|
return ret
|
||||||
|
return _stub
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
|
||||||
|
cap("staff", 0))
|
||||||
|
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
|
||||||
|
cap("pairs", []))
|
||||||
|
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
|
||||||
|
cap("wide", 0))
|
||||||
|
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
|
||||||
|
cap("call", 0))
|
||||||
|
|
||||||
|
sch = SyncScheduler(engine=engine, session_maker=maker)
|
||||||
|
await sch._analytics_tick()
|
||||||
|
|
||||||
|
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
|
||||||
|
assert len(rd) == 3 # 当日 + 前两天
|
||||||
|
assert got["staff"] == got["pairs"] == got["wide"] == got["call"] == rd
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""云客鉴权失败信封检测(2026-09-03 wa-friends 崩溃事故的防护)。
|
||||||
|
|
||||||
|
事故:cookie 过期时云客返回 ``{"code":"-20020","data":"账号认证已过期…",
|
||||||
|
"success":false}``——WA 路径对 str data 调 .get 直接 AttributeError 天书,
|
||||||
|
微信路径更糟:静默当空数据(55 员工 rows=0 无任何告警,同步一无所获)。
|
||||||
|
|
||||||
|
守卫:_browser_fetch 统一走 :func:`_check_auth_envelope`,识别鉴权失败信封
|
||||||
|
抛 :class:`CookieExpiredError`(调用方中止本轮,日志一句话可读)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.scrape_via_page import CookieExpiredError, _check_auth_envelope
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_fail_envelope_raises():
|
||||||
|
resp = {
|
||||||
|
"code": "-20020",
|
||||||
|
"data": "账号认证已过期,请重新登录",
|
||||||
|
"message": "鉴权失败,token验证失败",
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
with pytest.raises(CookieExpiredError, match="cookie"):
|
||||||
|
_check_auth_envelope(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_dict_resp_passes_through():
|
||||||
|
# 响应体不是 dict(如字符串)时不应崩——原样放行由调用方语义处理
|
||||||
|
assert _check_auth_envelope("plain string") == "plain string"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_success_envelope_passes():
|
||||||
|
resp = {"code": "0", "data": {"totalCount": 5, "overviews": [{"a": 1}]}, "success": True}
|
||||||
|
assert _check_auth_envelope(resp) is resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_error_codes_do_not_raise():
|
||||||
|
# 业务错误(如 -90000 系统错误)不是鉴权问题——照旧返回,由调用方决定
|
||||||
|
resp = {"code": "-90000", "data": "系统错误", "success": False}
|
||||||
|
assert _check_auth_envelope(resp) is resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_as_int_20020_also_raises():
|
||||||
|
# 云客 code 时而是字符串时而是数字——两种都识别
|
||||||
|
resp = {"code": 20020, "data": "账号认证已过期,请重新登录", "success": False}
|
||||||
|
with pytest.raises(CookieExpiredError):
|
||||||
|
_check_auth_envelope(resp)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""Tests for the msg_daily_stats 汇总层(日粒度会话统计)。
|
||||||
|
|
||||||
|
四个被钉死的行为:
|
||||||
|
|
||||||
|
1. **日桶口径**:day 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期
|
||||||
|
函数——旧实现 from_unixtime 跟随 MySQL 会话时区,UTC 容器下和今日数
|
||||||
|
的上海口径不一致,这是顺带修复的旧病)。
|
||||||
|
2. **写路径钩子**::func:`app.storage.repos.upsert_messages` 落库后自动
|
||||||
|
重算本批触及的「会话×天」行——页面抓取器/CLI 手动同步全部路径共用此
|
||||||
|
入口,一处挂全。幂等:同一批重放不产生重复/漂移。
|
||||||
|
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,
|
||||||
|
从而同时覆盖钩子行为)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import 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 Message, MsgDailyStats
|
||||||
|
from app.storage.repos import upsert_messages
|
||||||
|
from app.storage.summary import day_key, recompute_all
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _sh(y, m, d, h, mi=0, s=0) -> int:
|
||||||
|
return int(
|
||||||
|
datetime(y, m, d, h, mi, s, tzinfo=_SH).timestamp() * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(y, m, d, h, mi=0, s=0) -> int:
|
||||||
|
return int(
|
||||||
|
datetime(y, m, d, h, mi, s, tzinfo=timezone.utc).timestamp() * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def 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)
|
||||||
|
async with maker() as s:
|
||||||
|
yield s
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── day_key:上海自然日 ──────────────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_key_shanghai_calendar_day():
|
||||||
|
# 上海 2026-09-03 09:30 → 20260903
|
||||||
|
assert day_key(_sh(2026, 9, 3, 9, 30)) == 20260903
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_key_uses_shanghai_not_utc():
|
||||||
|
# UTC 2026-09-02 16:30 = 上海 2026-09-03 00:30 → 必须归入 0903(旧
|
||||||
|
# from_unixtime 在 UTC 容器里会归入 0902——口径修复点)
|
||||||
|
assert day_key(_utc(2026, 9, 2, 16, 30)) == 20260903
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_key_midnight_boundary():
|
||||||
|
# 上海零点整属于新的一天;前一天 23:59:59 属于前一天
|
||||||
|
assert day_key(_sh(2026, 9, 3, 0, 0, 0)) == 20260903
|
||||||
|
assert day_key(_sh(2026, 9, 2, 23, 59, 59)) == 20260902
|
||||||
|
|
||||||
|
|
||||||
|
# ── 写路径钩子:upsert_messages 自动维护日行 ────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(msg_id, ts, *, owner="wx_a", talker="t1", room=None, is_send=True):
|
||||||
|
return dict(
|
||||||
|
msg_id=msg_id,
|
||||||
|
channel="wechat",
|
||||||
|
owner_account_id=owner,
|
||||||
|
talker_id=talker if room is None else "room_member",
|
||||||
|
room_id=room,
|
||||||
|
is_send=is_send,
|
||||||
|
timestamp=ts,
|
||||||
|
type="text",
|
||||||
|
type_raw=1,
|
||||||
|
text="x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _stats_rows(session):
|
||||||
|
rows = (await session.execute(select(MsgDailyStats))).scalars().all()
|
||||||
|
return {
|
||||||
|
(r.peer_kind, r.peer_id, r.day): (r.send_cnt, r.recv_cnt, r.last_ts, r.last_is_send)
|
||||||
|
for r in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_messages_maintains_daily_rows(session):
|
||||||
|
await upsert_messages(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
_msg("m1", _sh(2026, 9, 3, 10, 0), is_send=True),
|
||||||
|
_msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False),
|
||||||
|
_msg("m3", _sh(2026, 9, 3, 11, 30), is_send=True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
rows = await _stats_rows(session)
|
||||||
|
assert set(rows) == {("friend", "t1", 20260903)}
|
||||||
|
assert rows[("friend", "t1", 20260903)] == (2, 1, _sh(2026, 9, 3, 11, 30), True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_messages_splits_days_and_kinds(session):
|
||||||
|
await upsert_messages(
|
||||||
|
session,
|
||||||
|
[
|
||||||
|
_msg("d1", _sh(2026, 9, 2, 23, 59), talker="t1"),
|
||||||
|
_msg("d2", _sh(2026, 9, 3, 0, 1), talker="t1"),
|
||||||
|
_msg("g1", _sh(2026, 9, 3, 9, 0), room="room1@chatroom", is_send=None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
rows = await _stats_rows(session)
|
||||||
|
# 跨上海零点的两条消息分属两天;群消息单独一行(room 维度)
|
||||||
|
assert set(rows) == {
|
||||||
|
("friend", "t1", 20260902),
|
||||||
|
("friend", "t1", 20260903),
|
||||||
|
("room", "room1@chatroom", 20260903),
|
||||||
|
}
|
||||||
|
# is_send=NULL 的消息:两个方向都不计(对齐旧聚合 case 语义),但占用行
|
||||||
|
assert rows[("room", "room1@chatroom", 20260903)] == (0, 0, _sh(2026, 9, 3, 9, 0), None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_messages_idempotent(session):
|
||||||
|
batch = [
|
||||||
|
_msg("m1", _sh(2026, 9, 3, 10, 0)),
|
||||||
|
_msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False),
|
||||||
|
]
|
||||||
|
await upsert_messages(session, batch)
|
||||||
|
await session.commit()
|
||||||
|
await upsert_messages(session, batch) # 同批重放(跨页重复是常态)
|
||||||
|
await session.commit()
|
||||||
|
rows = await _stats_rows(session)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[("friend", "t1", 20260903)] == (1, 1, _sh(2026, 9, 3, 11, 0), False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upsert_messages_null_timestamp_skipped(session):
|
||||||
|
await upsert_messages(session, [_msg("n1", None)])
|
||||||
|
await session.commit()
|
||||||
|
assert await _stats_rows(session) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 全量重算(夜间自愈/部署回填)────────────────────────────────────────── #
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recompute_all_rebuilds_and_is_idempotent(session):
|
||||||
|
# 先写一批消息(顺带产生日行),再篡改一条日行模拟漂移,全量重算应修复
|
||||||
|
await upsert_messages(
|
||||||
|
session,
|
||||||
|
[_msg("m1", _sh(2026, 9, 3, 10, 0)), _msg("m2", _sh(2026, 9, 3, 11, 0), is_send=False)],
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
bad = (await session.execute(select(MsgDailyStats))).scalars().one()
|
||||||
|
bad.send_cnt = 999 # 漂移
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
n = await recompute_all(session)
|
||||||
|
assert n >= 1
|
||||||
|
rows = await _stats_rows(session)
|
||||||
|
assert rows[("friend", "t1", 20260903)] == (1, 1, _sh(2026, 9, 3, 11, 0), False)
|
||||||
|
|
||||||
|
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)."""
|
"""start() wires a daily CronTrigger (full) + an IntervalTrigger (messages)."""
|
||||||
sched = SyncScheduler(
|
sched = SyncScheduler(
|
||||||
full_sync_hour=3,
|
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):
|
with patch.object(scheduler_module, "AsyncIOScheduler", return_value=spy):
|
||||||
await sched.start()
|
await sched.start()
|
||||||
|
|
||||||
# Three jobs registered (2026-08-21 加每周全量对齐).
|
# Four jobs registered (2026-08-21 加每周全量对齐;2026-09-04 加 4h 分析链).
|
||||||
assert spy.add_job.call_count == 3
|
assert spy.add_job.call_count == 4
|
||||||
job_ids = {c.kwargs["id"] for c in spy.add_job.call_args_list}
|
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).
|
# Full sync -> CronTrigger(hour=3); messages -> IntervalTrigger(hours=6).
|
||||||
# add_job was called as (func, trigger, kwargs...) so trigger is args[1].
|
# 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.args[0].__name__ == "_safe_page_run"
|
||||||
assert weekly_call.kwargs["args"] == ["full-history"]
|
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.
|
# coalesce=True + max_instances=1 protects against overlap.
|
||||||
for c in spy.add_job.call_args_list:
|
for c in spy.add_job.call_args_list:
|
||||||
assert c.kwargs["coalesce"] is True
|
assert c.kwargs["coalesce"] is True
|
||||||
@@ -559,7 +572,12 @@ async def test_safe_page_run_full_invokes_bash_full_sync(engine, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
|
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
|
||||||
"""messages job = friends→wa-friends→四类消息→moments 浅扫的命令序列。"""
|
"""messages job = accounts→friends→四类消息(活跃窗)→moments 浅扫的命令序列。
|
||||||
|
|
||||||
|
2026-09-10 调度根治:头部刷员工账号表(员工列表时间最多滞后 4h);
|
||||||
|
消息实体只扫近 24h 活跃对话(--active-window-hours 24,防序列 15h+
|
||||||
|
挤死 full);陈旧对话每日 full 兜底。
|
||||||
|
"""
|
||||||
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
|
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
|
||||||
commands: list[list[str]] = []
|
commands: list[list[str]] = []
|
||||||
|
|
||||||
@@ -571,22 +589,26 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
|
|||||||
|
|
||||||
await sched._safe_page_run("messages")
|
await sched._safe_page_run("messages")
|
||||||
|
|
||||||
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列,
|
assert len(commands) == 10
|
||||||
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
|
|
||||||
assert len(commands) == 7
|
|
||||||
for args in commands:
|
for args in commands:
|
||||||
assert args[1].endswith("scrape_via_page.py")
|
assert args[1].endswith("scrape_via_page.py")
|
||||||
|
|
||||||
def entity_of(args: list[str]) -> str:
|
def entity_of(args: list[str]) -> str:
|
||||||
return args[args.index("--entity") + 1]
|
return args[args.index("--entity") + 1]
|
||||||
|
|
||||||
assert entity_of(commands[0]) == "friends"
|
# 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
|
||||||
assert entity_of(commands[1]) == "wa-friends"
|
assert entity_of(commands[0]) == "accounts"
|
||||||
for args in commands[2:6]:
|
assert entity_of(commands[1]) == "wa-accounts"
|
||||||
assert entity_of(args) in (
|
assert entity_of(commands[2]) == "friends"
|
||||||
"messages", "wa-messages", "group-messages", "wa-group-messages",
|
assert entity_of(commands[3]) == "wa-friends"
|
||||||
)
|
# 群消息前刷群活跃(chatroom-stats 全窗——群活跃窗判定源,2026-09-10)
|
||||||
assert args[args.index("--max-conversations") + 1] == "99999"
|
entities_seq = [entity_of(a) for a in commands]
|
||||||
|
assert entities_seq.index("chatroom-stats") < entities_seq.index("group-messages")
|
||||||
|
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
|
||||||
|
for args in commands:
|
||||||
|
if entity_of(args) in ("messages", "wa-messages", "group-messages", "wa-group-messages"):
|
||||||
|
assert args[args.index("--max-conversations") + 1] == "99999"
|
||||||
|
assert args[args.index("--active-window-hours") + 1] == "24"
|
||||||
# moments 浅扫在序列末尾:每员工最多 3 页
|
# moments 浅扫在序列末尾:每员工最多 3 页
|
||||||
moments_cmd = commands[-1]
|
moments_cmd = commands[-1]
|
||||||
assert entity_of(moments_cmd) == "moments"
|
assert entity_of(moments_cmd) == "moments"
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ WORKDIR /app
|
|||||||
# Install deps first (cache-friendly: only re-installs when package*.json
|
# Install deps first (cache-friendly: only re-installs when package*.json
|
||||||
# changes; node_modules is never copied from the host via .dockerignore).
|
# changes; node_modules is never copied from the host via .dockerignore).
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --no-audit --no-fund # ci 对锁同步严格,本地/容器 npm 版本差异致 EUSAGE
|
# registry 指定 npmmirror(国内构建加速,ECS 上直连 npmjs 慢/超时)
|
||||||
|
RUN npm install --no-audit --no-fund --registry=https://registry.npmmirror.com # ci 对锁同步严格,本地/容器 npm 版本差异致 EUSAGE
|
||||||
|
|
||||||
# Copy sources and build the production bundle into ./dist.
|
# Copy sources and build the production bundle into ./dist.
|
||||||
COPY tsconfig*.json vite.config.ts index.html ./
|
COPY tsconfig*.json vite.config.ts index.html ./
|
||||||
|
|||||||
+8
-1
@@ -24,8 +24,15 @@ server {
|
|||||||
# network; if the name changes, update here. Streaming endpoints
|
# network; if the name changes, update here. Streaming endpoints
|
||||||
# (/api/media/) work without buffering because we turn proxy_buffering
|
# (/api/media/) work without buffering because we turn proxy_buffering
|
||||||
# off — matches the dev-server behaviour (vite proxies /api/media first).
|
# 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/ {
|
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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
Generated
+33
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
@@ -1045,6 +1046,22 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.14.3",
|
"version": "2.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
|
||||||
@@ -1756,6 +1773,7 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2221,6 +2239,21 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"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": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
|||||||
+9
-1
@@ -1,7 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import MainLayout from '@/layouts/MainLayout.vue'
|
import MainLayout from '@/layouts/MainLayout.vue'
|
||||||
|
|
||||||
|
// 登录页等 public 页面(meta.public)不套 MainLayout — 侧栏导航是登录后
|
||||||
|
// 才有的东西,登录页必须全屏裸渲染(router-view 直接出组件)。
|
||||||
|
const route = useRoute()
|
||||||
|
const isPublic = computed(() => route.meta.public === true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MainLayout class="yk-app" />
|
<router-view v-if="isPublic" />
|
||||||
|
<MainLayout v-else class="yk-app" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 })
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user